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.
package/lib/index.js ADDED
@@ -0,0 +1,1961 @@
1
+ import { A as QODER_GLOBAL_AUTH_PATH, B as qoderCredentialIdentity, C as probeModel, D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, F as QODER_AUTH_FILENAME, G as qoderPluginDataDir, H as QODER_DATA_DIR_ENV, I as QODER_PAT_ENV_CN, K as qoderStateDir, L as QODER_PAT_ENV_GLOBAL, M as QODER_GLOBAL_STATUS_PATH, N as QODER_PROBE_PATH, O as QoderCatalog, P as QODER_STATUS_PATH, R as QoderCredentialStore, S as PROBE_EFFORT_CANDIDATES, T as QoderCatalogStore, U as QODER_DATA_DIR_NAME, V as qoderOwnAuthPath, W as qoderMachineIdPath, _ 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_GLOBAL_PROBE_PATH, k as QODER_AUTH_PATH, l as qoderHostHeartbeatPath, m as QoderUpstreamClient, n as GLOBAL_VARIANT, o as clearHostHeartbeat, p as KIND_STATUS, r as QODER_VARIANTS, s as isHeartbeatProcessAlive, t as CHINA_VARIANT, u as readHostHeartbeat, v as normalizeCredits, w as randomSentinel, x as getMachineId, y as validateApiKey, z as patTail } from "./variants-jbFhMKO8.js";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { createProvider } from "@earendil-works/pi-ai";
7
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
8
+ import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
9
+ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
10
+ import { createServer } from "node:http";
11
+ import { Readable } from "node:stream";
12
+ //#region src/loopback.ts
13
+ /**
14
+ * Shared loopback gates for the plugin's local HTTP surfaces: the loopback
15
+ * shim and the same-origin web-status route. Both are only ever meant to be
16
+ * addressed through the machine's loopback interface.
17
+ *
18
+ * @module dsh-qoder-connect/loopback
19
+ */
20
+ /** Loopback hostnames a local plugin surface may be addressed by. */
21
+ const LOOPBACK_HOSTS = /* @__PURE__ */ new Set([
22
+ "127.0.0.1",
23
+ "localhost",
24
+ "[::1]"
25
+ ]);
26
+ /** Strip the optional :port from a Host header value, IPv6-bracket aware. */
27
+ function hostnameOfHost(host) {
28
+ let hostname = host.trim().toLowerCase();
29
+ if (hostname.startsWith("[")) {
30
+ const end = hostname.indexOf("]");
31
+ return end === -1 ? hostname : hostname.slice(0, end + 1);
32
+ }
33
+ const colon = hostname.lastIndexOf(":");
34
+ if (colon !== -1 && !hostname.slice(0, colon).includes(":") && /^\d+$/.test(hostname.slice(colon + 1))) hostname = hostname.slice(0, colon);
35
+ return hostname;
36
+ }
37
+ /**
38
+ * The request's Host header must name the loopback interface. A DNS-rebinding
39
+ * page (attacker domain re-resolved to 127.0.0.1) sends its own domain in
40
+ * Host, so this check drops those before any routing happens.
41
+ */
42
+ function hostIsLoopback(host) {
43
+ if (host === void 0 || host.trim() === "") return false;
44
+ return LOOPBACK_HOSTS.has(hostnameOfHost(host));
45
+ }
46
+ /**
47
+ * A browser-sent Origin (present header) must be loopback. Non-browser
48
+ * clients (the plugin's own fetch calls) send no Origin at all and pass.
49
+ */
50
+ function originIsLoopback(origin) {
51
+ if (origin === void 0 || origin.trim() === "") return true;
52
+ try {
53
+ const { hostname } = new URL(origin);
54
+ return LOOPBACK_HOSTS.has(hostname) || hostname === "::1";
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+ //#endregion
60
+ //#region src/auth-route.ts
61
+ /**
62
+ * PAT route: saves (validate-then-persist) or clears one variant's token.
63
+ *
64
+ * The state-changing endpoints the plugin exposes share one guard shape — a
65
+ * loopback Host and Origin, plus the in-process key the card receives with its
66
+ * status document — because loopback alone is *not* authentication: any local
67
+ * process can address `127.0.0.1`, and this route writes a credential to disk.
68
+ *
69
+ * The region is never taken from the request. It is fixed by the route the
70
+ * browser called (one route per variant), so a card for one product can never
71
+ * steer a token into the other's upstream — a token pasted into the Qoder
72
+ * Global card is validated against, and stored for, Qoder Global only.
73
+ *
74
+ * @module dsh-qoder-connect/auth-route
75
+ */
76
+ /** Largest control body accepted; a Personal Access Token is a short string. */
77
+ const MAX_BODY_BYTES$1 = 16384;
78
+ function json$2(res, status, body) {
79
+ const payload = JSON.stringify(body);
80
+ res.writeHead(status, {
81
+ "Content-Type": "application/json",
82
+ "Content-Length": Buffer.byteLength(payload)
83
+ });
84
+ res.end(payload);
85
+ }
86
+ /** Read the request body with a hard ceiling. */
87
+ async function readBody$2(req) {
88
+ const chunks = [];
89
+ let total = 0;
90
+ for await (const chunk of req) {
91
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
92
+ total += buffer.length;
93
+ if (total > MAX_BODY_BYTES$1) return void 0;
94
+ chunks.push(buffer);
95
+ }
96
+ return Buffer.concat(chunks).toString("utf8");
97
+ }
98
+ /** Parse and shape-check a PAT request; unknown fields are ignored, not trusted. */
99
+ function parseRequest(text) {
100
+ let parsed;
101
+ try {
102
+ parsed = JSON.parse(text);
103
+ } catch {
104
+ return;
105
+ }
106
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
107
+ const wrapped = parsed;
108
+ const action = wrapped["action"];
109
+ if (action === "clear") return { action };
110
+ if (action === "save-pat") {
111
+ const pat = wrapped["pat"];
112
+ if (typeof pat !== "string" || pat.trim() === "") return void 0;
113
+ return {
114
+ action,
115
+ pat
116
+ };
117
+ }
118
+ }
119
+ /**
120
+ * Strip token-like content from a message before it reaches the browser.
121
+ *
122
+ * The route reports failures to a same-origin card, and a validation error
123
+ * body is the one input here that is not the plugin's own prose.
124
+ * Belt-and-braces: everything this route produces is already a summary, and
125
+ * this keeps a future one from carrying a credential across.
126
+ */
127
+ function safeMessage$1(error) {
128
+ return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|pat)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
129
+ }
130
+ /** Mint the per-process PAT control key. */
131
+ function createAuthKey() {
132
+ return randomBytes(24).toString("hex");
133
+ }
134
+ /** Constant-time key comparison; a length mismatch is a failure, not a crash. */
135
+ function keyMatches$1(expected, presented) {
136
+ if (presented === void 0 || presented.length !== expected.length) return false;
137
+ const a = Buffer.from(expected);
138
+ const b = Buffer.from(presented);
139
+ return a.length === b.length && timingSafeEqual(a, b);
140
+ }
141
+ /**
142
+ * The PAT route's handler, extracted so tests can mount it on a bare server
143
+ * with a known key.
144
+ *
145
+ * @param deps - the save/clear operations for one variant.
146
+ * @param key - the in-process control key this route requires.
147
+ * @returns the Node request handler.
148
+ */
149
+ function qoderAuthHandler(deps, key) {
150
+ return async (req, res) => {
151
+ if (req.method !== "POST") {
152
+ json$2(res, 405, { error: "method not allowed" });
153
+ return;
154
+ }
155
+ if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
156
+ json$2(res, 403, { error: "request-not-trusted" });
157
+ return;
158
+ }
159
+ if (!keyMatches$1(key, req.headers["x-qoder-auth-key"])) {
160
+ json$2(res, 403, { error: "invalid-auth-key" });
161
+ return;
162
+ }
163
+ const body = await readBody$2(req);
164
+ if (body === void 0) {
165
+ json$2(res, 413, { error: "body too large" });
166
+ return;
167
+ }
168
+ const request = parseRequest(body);
169
+ if (request === void 0) {
170
+ json$2(res, 400, { error: "invalid action" });
171
+ return;
172
+ }
173
+ try {
174
+ if (request.action === "clear") {
175
+ await deps.clear();
176
+ json$2(res, 200, { ok: true });
177
+ return;
178
+ }
179
+ json$2(res, 200, await deps.save(request.pat));
180
+ } catch (error) {
181
+ json$2(res, 200, {
182
+ ok: false,
183
+ error: safeMessage$1(error)
184
+ });
185
+ }
186
+ };
187
+ }
188
+ /** Mount the POST PAT route on an optional webServer context. */
189
+ function registerQoderAuthRoute(ctx, deps, key) {
190
+ const path = deps.path ?? "/plugins/dsh-qoder-connect/auth";
191
+ ctx.effect(() => {
192
+ const dispose = ctx.webServer.register({
193
+ kind: "exact",
194
+ path,
195
+ handler: qoderAuthHandler(deps, key)
196
+ });
197
+ return () => {
198
+ dispose();
199
+ };
200
+ }, "dsh-qoder-connect: PAT route");
201
+ }
202
+ //#endregion
203
+ //#region src/adapter.ts
204
+ /**
205
+ * The Qoder pi-ai provider: one loopback-backed adapter per variant route,
206
+ * registered into the Harness LLM seam, assembled from public
207
+ * `dsh-llm-pi-ai` extension points the way `dsh-codex-connect` assembles its
208
+ * Codex route.
209
+ *
210
+ * @module dsh-qoder-connect/adapter
211
+ */
212
+ /** Default provider route owned by this bundle (the China variant). */
213
+ const QODER_PROVIDER = "qoder";
214
+ /** Provider idle ceiling while one stream read is outstanding. */
215
+ const QODER_STREAM_IDLE_TIMEOUT_MS = 3e5;
216
+ /**
217
+ * Image-request budgets at the dsh-llm-pi-ai defaults; the profile type made
218
+ * them required in 0.1.1-rc.2. They bound requests to models whose catalog
219
+ * entry declares `supportsImages`; text-only models never receive images.
220
+ */
221
+ const REQUEST_IMAGE_BUDGETS = {
222
+ maxRequestImageBytes: 20971520,
223
+ requestImagePixelBudget: 4194304,
224
+ requestImageMaxBytes: 1048576
225
+ };
226
+ /**
227
+ * Inert pi-ai auth plane. The Qoder routes authenticate only through the
228
+ * shim shared secret resolved per request by `resolveApiKey`, so pi-ai's own
229
+ * credential lifecycle and ambient discovery must never manufacture a
230
+ * credential for them. `PiAiAdapterOptions.auth` is required since 0.1.1-rc.2;
231
+ * every ambient question here answers "nothing stored, nothing set".
232
+ */
233
+ const INERT_AUTH = {
234
+ credentials: {
235
+ async read() {},
236
+ async list() {
237
+ return [];
238
+ },
239
+ async modify() {
240
+ throw new Error("dsh-qoder-connect: Qoder routes have no pi-ai credential lifecycle");
241
+ },
242
+ async delete() {}
243
+ },
244
+ authContext: {
245
+ async env() {},
246
+ async fileExists() {
247
+ return false;
248
+ }
249
+ }
250
+ };
251
+ /** No per-token pricing is knowable for a subscription quota; report zero. */
252
+ const NO_COST = {
253
+ input: 0,
254
+ output: 0,
255
+ cacheRead: 0,
256
+ cacheWrite: 0
257
+ };
258
+ /**
259
+ * The suffix appended to a model's display name so its billing rate is visible
260
+ * wherever the name is shown.
261
+ *
262
+ * The separator is a middle dot rather than a hyphen or colon: model names
263
+ * commonly contain hyphens, so a hyphen separator would be ambiguous about
264
+ * where the name ends and the rate begins.
265
+ */
266
+ const RATE_SEPARATOR = " · ";
267
+ /**
268
+ * Append the billing rate to one model's display name.
269
+ *
270
+ * The rate rides the *name* alone: since DSH 0.1.2 the composer's model seat
271
+ * (`ModelSelect`) renders `model.name` only — `description` is no longer read
272
+ * there at all. The `/model` popup renders the name too, so a separate
273
+ * `description` copy would duplicate the rate depending on client generation.
274
+ *
275
+ * This is display-only and cannot affect routing: the wire request is built
276
+ * from `model.id` (pi-ai's completions API sets `model: model.id`), the
277
+ * selection a picker submits is `{provider, model: id, reasoningEffort}`, and
278
+ * `dsh-llm` validates `name` as a non-empty string without comparing its
279
+ * contents. Nothing in the host resolves a model *by* name.
280
+ */
281
+ /** The catalog display suffix: the normalized billing rate, when the row has one. */
282
+ function displaySuffix(info) {
283
+ const rate = normalizeCredits(info.billing?.credits);
284
+ return rate === void 0 || rate === "" ? void 0 : rate;
285
+ }
286
+ /** Append the catalog display suffix to one model's display name. */
287
+ function withCatalogDisplay(name, info) {
288
+ const suffix = displaySuffix(info);
289
+ return suffix === void 0 ? name : `${name}${RATE_SEPARATOR}${suffix}`;
290
+ }
291
+ /**
292
+ * Resolve a Qoder model's reasoning capability into pi-ai's
293
+ * `thinkingLevelMap` (every level pinned to its wire spelling or `null` for
294
+ * unsupported), mirroring `dsh-llm-pi-ai`'s own `resolveModelReasoning`.
295
+ *
296
+ * Two sources, strictly ordered (`docs/reasoning-effort-probe-plan.md` §5):
297
+ *
298
+ * 1. **The declared set.** When the upstream declares a non-empty
299
+ * `supportedEfforts`, exactly those values are offered and nothing else.
300
+ * This always wins: an observation never widens or narrows a declared set.
301
+ * 2. **A local observation.** Rows the upstream left undeclared normally get
302
+ * no control at all — their selectable set is client-side knowledge the
303
+ * catalog does not carry. If the user authorized a probe and it established
304
+ * that the upstream *validates* the parameter, the verified spellings are
305
+ * offered.
306
+ *
307
+ * A `non-validating` observation deliberately yields no control: an upstream
308
+ * that accepts values that cannot exist would make every per-level acceptance
309
+ * a false positive.
310
+ *
311
+ * `off` is offered only when the upstream declares `canDisableThinking: true`.
312
+ * It is never probed — disabling thinking is a separate capability, and the
313
+ * per-model acceptance of `off` cannot be inferred from the row's shape.
314
+ *
315
+ * The offered set is described internally as "verified accepted", never as
316
+ * "verified effective": acceptance proves the upstream did not reject the
317
+ * spelling, not that it changes what the model does.
318
+ */
319
+ function reasoningFields(info, observed) {
320
+ const reasoning = info.reasoning;
321
+ if (reasoning === void 0 || reasoning.supports !== true) return { reasoning: false };
322
+ const declared = reasoning.supportedEfforts;
323
+ const efforts = declared !== void 0 && declared.length > 0 ? declared : observed?.validation === "validating" && observed.efforts.length > 0 ? observed.efforts : void 0;
324
+ if (efforts === void 0) return { reasoning: false };
325
+ return {
326
+ reasoning: true,
327
+ thinkingLevelMap: {
328
+ off: reasoning.canDisableThinking === true && declared !== void 0 && declared.length > 0 ? "off" : null,
329
+ minimal: null,
330
+ low: efforts.includes("low") ? "low" : null,
331
+ medium: efforts.includes("medium") ? "medium" : null,
332
+ high: efforts.includes("high") ? "high" : null,
333
+ xhigh: efforts.includes("xhigh") ? "xhigh" : null,
334
+ max: efforts.includes("max") ? "max" : null
335
+ }
336
+ };
337
+ }
338
+ /** Build one pi-ai model descriptor pointing at the loopback shim. */
339
+ function toPiModel(info, baseUrl, observed, providerId = QODER_PROVIDER) {
340
+ return {
341
+ id: info.id,
342
+ name: info.name,
343
+ api: "openai-completions",
344
+ provider: providerId,
345
+ baseUrl,
346
+ input: info.supportsImages === true ? ["text", "image"] : ["text"],
347
+ ...reasoningFields(info, observed),
348
+ cost: NO_COST,
349
+ contextWindow: info.contextWindow,
350
+ maxTokens: info.maxTokens,
351
+ compat: { maxTokensField: "max_tokens" }
352
+ };
353
+ }
354
+ /**
355
+ * Assemble the adapter. The provider's `getModels` reads the live catalog,
356
+ * and every model's `baseUrl` is re-resolved per read so the shim's
357
+ * ephemeral port applies from the first snapshot after startup.
358
+ *
359
+ * The profile is constructed by hand rather than through dsh-llm-pi-ai's
360
+ * internal `resolveProfiles()`: that helper is not part of the package's
361
+ * public export surface (root entry, `lib/` deep imports blocked by the
362
+ * exports map, `src/` not shipped), so hand-assembly is the only supported
363
+ * path and every newly required field must be adopted here explicitly —
364
+ * `modelErrors` since 0.1.5-alpha.2 (#12).
365
+ */
366
+ function createQoderAdapter(options) {
367
+ const { shim, store, catalog, resolveAttachments, observe } = options;
368
+ const providerId = options.providerId ?? "qoder";
369
+ const displayName = options.displayName ?? "Qoder";
370
+ const buildModels = () => {
371
+ const baseUrl = `${shim.baseUrl()}/v1`;
372
+ return catalog.current().map((info) => toPiModel(info, baseUrl, observe?.(info.id), providerId));
373
+ };
374
+ const provider = {
375
+ ...createProvider({
376
+ id: providerId,
377
+ name: displayName,
378
+ auth: { apiKey: {
379
+ name: "Qoder shim shared secret",
380
+ async resolve({ credential }) {
381
+ const apiKey = credential?.key;
382
+ return apiKey === void 0 || apiKey.length === 0 ? void 0 : {
383
+ auth: { apiKey },
384
+ source: displayName
385
+ };
386
+ }
387
+ } },
388
+ models: buildModels(),
389
+ api: openAICompletionsApi()
390
+ }),
391
+ getModels: () => buildModels()
392
+ };
393
+ const profile = {
394
+ provider: providerId,
395
+ displayName,
396
+ streamIdleTimeoutMs: QODER_STREAM_IDLE_TIMEOUT_MS,
397
+ retryPolicy: resolveRetryPolicy(void 0, `dsh-qoder-connect:${providerId} retryPolicy`),
398
+ configuredMaxTokens: /* @__PURE__ */ new Map(),
399
+ modelErrors: /* @__PURE__ */ new Map(),
400
+ ...REQUEST_IMAGE_BUDGETS,
401
+ piProvider: provider
402
+ };
403
+ let profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
404
+ return {
405
+ adapter: new QoderPiAiAdapter(catalog, {
406
+ profiles: () => profiles,
407
+ auth: INERT_AUTH,
408
+ resolveApiKey: async () => shim.token(),
409
+ ...resolveAttachments === void 0 ? {} : { resolveAttachments }
410
+ }),
411
+ invalidate: () => {
412
+ profiles = /* @__PURE__ */ new Map([[providerId, profile]]);
413
+ }
414
+ };
415
+ }
416
+ /**
417
+ * The Qoder variant route's adapter: `PiAiAdapter` with the billing rate folded
418
+ * into the catalog answers it returns to the DSH model pickers.
419
+ *
420
+ * `PiAiAdapter.listModels()` and `.resolveModel()` build their answers straight
421
+ * from the pi-ai descriptors, which carry no billing fact, so the rate is
422
+ * layered on here by looking the model up in the live catalog. Both overrides
423
+ * delegate to `super` and then rewrite only the display fields, so streaming,
424
+ * capability resolution, and effort mapping stay exactly as `dsh-llm-pi-ai`
425
+ * implements them.
426
+ *
427
+ * A model missing from the catalog (an id the shim would serve but the last
428
+ * upstream refresh did not list) falls through with its name untouched rather
429
+ * than being dropped: catalog membership is advisory, and the seam tolerates
430
+ * serving an unlisted id.
431
+ */
432
+ var QoderPiAiAdapter = class extends PiAiAdapter {
433
+ catalog;
434
+ constructor(catalog, options) {
435
+ super(options);
436
+ this.catalog = catalog;
437
+ }
438
+ /** Catalog entry for one model id, or undefined when the catalog omits it. */
439
+ infoFor(model) {
440
+ return this.catalog.current().find((entry) => entry.id === model);
441
+ }
442
+ async listModels(provider) {
443
+ return (await super.listModels(provider)).map((model) => {
444
+ const info = this.infoFor(model.id);
445
+ if (info === void 0) return model;
446
+ return {
447
+ ...model,
448
+ name: withCatalogDisplay(model.name, info)
449
+ };
450
+ });
451
+ }
452
+ async resolveModel(provider, model, signal) {
453
+ const resolved = await super.resolveModel(provider, model, signal);
454
+ const info = this.infoFor(model);
455
+ if (info === void 0) return resolved;
456
+ return {
457
+ ...resolved,
458
+ name: withCatalogDisplay(resolved.name, info)
459
+ };
460
+ }
461
+ };
462
+ //#endregion
463
+ //#region src/shim.ts
464
+ /**
465
+ * Loopback OpenAI-compatible endpoint. The pi-ai provider points here; the
466
+ * shim guards the local boundary and hands the raw JSON body to the upstream
467
+ * client, which translates it onto the Qoder transport. It binds 127.0.0.1
468
+ * only and never serves another interface.
469
+ *
470
+ * Inbound hardening: the loopback bind alone is not a trust boundary (any
471
+ * local process or a DNS-rebinding page can reach 127.0.0.1), so every
472
+ * request must carry a loopback Host header, browser-sent Origins must be
473
+ * loopback, chat POSTs must be application/json, and the Authorization
474
+ * header must carry the shim's per-process shared secret. The plugin's
475
+ * own client satisfies all four by construction; local attackers cannot
476
+ * read the secret out of the plugin process's memory.
477
+ *
478
+ * @module dsh-qoder-connect/shim
479
+ */
480
+ const REQUEST_BODY_LIMIT = 67108864;
481
+ /** Chat-completion POSTs must carry a JSON body type (simple-request CSRF drops here). */
482
+ function isJsonContentType(req) {
483
+ const type = req.headers["content-type"];
484
+ return typeof type === "string" && type.trim().toLowerCase().startsWith("application/json");
485
+ }
486
+ function writeJson(res, status, body) {
487
+ const payload = JSON.stringify(body);
488
+ res.writeHead(status, {
489
+ "Content-Type": "application/json",
490
+ "Content-Length": Buffer.byteLength(payload)
491
+ });
492
+ res.end(payload);
493
+ }
494
+ function writeOpenAIError(res, status, kind, message) {
495
+ writeJson(res, status, { error: {
496
+ message,
497
+ type: kind,
498
+ code: kind
499
+ } });
500
+ }
501
+ /** Read a request body with a size cap; over-limit bodies fail the request. */
502
+ function readBody$1(req) {
503
+ return new Promise((resolve, reject) => {
504
+ const chunks = [];
505
+ let size = 0;
506
+ req.on("data", (chunk) => {
507
+ size += chunk.length;
508
+ if (size > REQUEST_BODY_LIMIT) {
509
+ reject(/* @__PURE__ */ new Error("request body too large"));
510
+ req.destroy();
511
+ return;
512
+ }
513
+ chunks.push(chunk);
514
+ });
515
+ req.on("end", () => resolve(Buffer.concat(chunks)));
516
+ req.on("error", reject);
517
+ });
518
+ }
519
+ /**
520
+ * Start the loopback endpoint. Requests carry any bearer; the loopback bind
521
+ * is the boundary, and the upstream credential comes from the store alone.
522
+ */
523
+ function createQoderShim(options) {
524
+ const { store, client, catalog } = options;
525
+ const logger = options.logger;
526
+ const providerId = options.providerId;
527
+ const SHARED_SECRET = randomBytes(32).toString("base64url");
528
+ /** Constant-time bearer check; absent or mismatched bearers are rejected. */
529
+ function bearerOk(req) {
530
+ const header = req.headers.authorization;
531
+ if (typeof header !== "string") return false;
532
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
533
+ if (match === null) return false;
534
+ const presented = match[1];
535
+ const expected = SHARED_SECRET;
536
+ const a = Buffer.from(presented);
537
+ const b = Buffer.from(expected);
538
+ if (a.length !== b.length) return false;
539
+ return timingSafeEqual(a, b);
540
+ }
541
+ const server = createServer((req, res) => {
542
+ handle(req, res);
543
+ });
544
+ const ready = new Promise((resolve, reject) => {
545
+ server.once("listening", () => resolve());
546
+ server.once("error", reject);
547
+ });
548
+ server.listen(0, "127.0.0.1");
549
+ const baseUrl = () => {
550
+ const address = server.address();
551
+ if (address === null || typeof address === "string") throw new Error(`${providerId} shim has no listening address`);
552
+ return `http://127.0.0.1:${address.port}`;
553
+ };
554
+ async function handle(req, res) {
555
+ try {
556
+ if (!hostIsLoopback(req.headers.host)) {
557
+ writeOpenAIError(res, 403, "host_not_allowed", "Host header must name the loopback interface");
558
+ return;
559
+ }
560
+ if (!originIsLoopback(req.headers.origin)) {
561
+ writeOpenAIError(res, 403, "origin_not_allowed", "Origin must be a loopback origin");
562
+ return;
563
+ }
564
+ if (!bearerOk(req)) {
565
+ writeOpenAIError(res, 401, "unauthorized", "missing or invalid Authorization bearer");
566
+ return;
567
+ }
568
+ const url = req.url ?? "/";
569
+ if (req.method === "GET" && (url === "/healthz" || url === "/healthz/")) {
570
+ writeJson(res, 200, { ok: true });
571
+ return;
572
+ }
573
+ if (req.method === "GET" && (url === "/v1/models" || url === "/v1/models/")) {
574
+ writeJson(res, 200, {
575
+ object: "list",
576
+ data: catalog.current().map((model) => ({
577
+ id: model.id,
578
+ object: "model",
579
+ created: 0,
580
+ owned_by: providerId
581
+ }))
582
+ });
583
+ return;
584
+ }
585
+ if (req.method === "POST" && (url === "/v1/chat/completions" || url === "/v1/chat/completions/")) {
586
+ await chatCompletions(req, res);
587
+ return;
588
+ }
589
+ writeOpenAIError(res, 404, "not_found", `no such route: ${req.method} ${url}`);
590
+ } catch (error) {
591
+ if (!res.headersSent) writeOpenAIError(res, 500, "internal", String(error));
592
+ else res.end();
593
+ }
594
+ }
595
+ async function chatCompletions(req, res) {
596
+ if (!isJsonContentType(req)) {
597
+ writeOpenAIError(res, 415, "unsupported_media_type", "Content-Type must be application/json");
598
+ return;
599
+ }
600
+ try {
601
+ await store.resolve();
602
+ } catch (error) {
603
+ writeOpenAIError(res, KIND_STATUS.missing_credential, "not_signed_in", String(error));
604
+ return;
605
+ }
606
+ const raw = (await readBody$1(req)).toString("utf8");
607
+ const controller = new AbortController();
608
+ req.on("close", () => controller.abort());
609
+ const result = await client.chatStream(raw, controller.signal);
610
+ if (!result.ok) {
611
+ writeOpenAIError(res, KIND_STATUS[result.kind], result.kind, `${providerId} upstream ${result.kind} (http ${result.status}): ${result.message.slice(0, 400)}`);
612
+ return;
613
+ }
614
+ res.writeHead(200, {
615
+ "Content-Type": "text/event-stream",
616
+ "Cache-Control": "no-cache",
617
+ "Connection": "keep-alive",
618
+ "X-Accel-Buffering": "no"
619
+ });
620
+ let sawDone = false;
621
+ const body = Readable.fromWeb(result.response.body);
622
+ body.on("data", (chunk) => {
623
+ if (chunk.includes("[DONE]")) sawDone = true;
624
+ });
625
+ body.on("error", (error) => {
626
+ logger?.warn("dsh-qoder-connect: upstream stream failed mid-flight", error);
627
+ if (!sawDone && res.writable) res.end("data: [DONE]\n\n");
628
+ });
629
+ body.pipe(res);
630
+ }
631
+ return {
632
+ ready,
633
+ baseUrl,
634
+ token: () => SHARED_SECRET,
635
+ close: () => new Promise((resolve, reject) => {
636
+ server.close(() => resolve());
637
+ server.closeAllConnections();
638
+ server.once("error", reject);
639
+ })
640
+ };
641
+ }
642
+ //#endregion
643
+ //#region src/probe-store.ts
644
+ /**
645
+ * Local record of reasoning-effort probes.
646
+ *
647
+ * What this stores is an *observation*, never a claim about the upstream: a
648
+ * model's row is only consulted when the catalog carries no explicit
649
+ * `supportedEfforts` set, and it always loses to a declared set. The plan this
650
+ * implements (`docs/reasoning-effort-probe-plan.md` §5) requires that a result
651
+ * is invalidated whenever the model's catalog row changes, so every record
652
+ * carries a fingerprint of the fields the probe depended on.
653
+ *
654
+ * The file lives in the plugin's own state directory, never beside the
655
+ * product's files, and carries no token, prompt, or response body — only
656
+ * model ids, effort spellings, and timestamps.
657
+ *
658
+ * @module dsh-qoder-connect/probe-store
659
+ */
660
+ /** Basename of the China variant's probe record inside the plugin's state dir. */
661
+ const QODER_PROBE_FILENAME = ".qoder-probe.json";
662
+ /** On-disk format this reader accepts; other versions are discarded. */
663
+ const PROBE_FORMAT_VERSION = 1;
664
+ /**
665
+ * How long an observation stays usable. Conservative on purpose: the plan's
666
+ * whole argument is that upstream metadata moves fast, so a result that has
667
+ * outlived its fingerprint's usefulness should not quietly keep granting a
668
+ * picker entry.
669
+ */
670
+ const DEFAULT_TTL_MS = 12096e5;
671
+ /**
672
+ * Plugin-owned probe record path inside the plugin's state directory.
673
+ *
674
+ * One file per variant. The two regions serve overlapping model ids with
675
+ * different entitlements, and {@link fingerprintModel} covers only
676
+ * `id`/`reasoning`/`supportsImages` — never the provider — so a single shared
677
+ * file would let one variant's observation answer for the other. The paths
678
+ * differ; the format does not.
679
+ */
680
+ function qoderProbePath(filename = QODER_PROBE_FILENAME) {
681
+ return join(qoderStateDir(), filename);
682
+ }
683
+ /**
684
+ * Fingerprint the catalog fields a probe depends on.
685
+ *
686
+ * Deliberately excludes display-only fields (`name`, `billing`, `contextWindow`)
687
+ * so a rename or a rate relabel does not throw away a valid observation, and
688
+ * deliberately includes the whole reasoning object so any change to the
689
+ * declared shape re-probes.
690
+ */
691
+ function fingerprintModel(info) {
692
+ const basis = JSON.stringify({
693
+ id: info.id,
694
+ reasoning: info.reasoning ?? null,
695
+ supportsImages: info.supportsImages ?? null
696
+ });
697
+ return createHash("sha256").update(basis).digest("hex").slice(0, 16);
698
+ }
699
+ /** Read-and-validate the documents on disk; anything malformed reads as empty. */
700
+ function readDocument(path) {
701
+ if (!existsSync(path)) return void 0;
702
+ let parsed;
703
+ try {
704
+ parsed = JSON.parse(readFileSync(path, "utf8"));
705
+ } catch {
706
+ return;
707
+ }
708
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
709
+ const wrapped = parsed;
710
+ if (wrapped["version"] !== PROBE_FORMAT_VERSION) return void 0;
711
+ const records = wrapped["records"];
712
+ if (typeof records !== "object" || records === null || Array.isArray(records)) return void 0;
713
+ return parsed;
714
+ }
715
+ /** One record's shape check; a bad row is dropped rather than trusted. */
716
+ function isRecord(value) {
717
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
718
+ const wrapped = value;
719
+ const validation = wrapped["validation"];
720
+ if (validation !== "validating" && validation !== "non-validating" && validation !== "unknown") return false;
721
+ if (typeof wrapped["fingerprint"] !== "string") return false;
722
+ if (typeof wrapped["probedAtMs"] !== "number" || !Number.isFinite(wrapped["probedAtMs"])) return false;
723
+ if (typeof wrapped["pluginVersion"] !== "string") return false;
724
+ const efforts = wrapped["efforts"];
725
+ if (!Array.isArray(efforts) || efforts.some((effort) => typeof effort !== "string")) return false;
726
+ return true;
727
+ }
728
+ /**
729
+ * The plugin's probe records: read once, written atomically, never trusted
730
+ * across a fingerprint change or past the TTL.
731
+ */
732
+ var QoderProbeStore = class {
733
+ path;
734
+ ttlMs;
735
+ pluginVersion;
736
+ now;
737
+ records;
738
+ constructor(options) {
739
+ const opts = typeof options === "string" ? {
740
+ path: options,
741
+ pluginVersion: "0.0.0"
742
+ } : options;
743
+ this.path = opts.path ?? qoderProbePath();
744
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
745
+ this.pluginVersion = opts.pluginVersion;
746
+ this.now = opts.now ?? (() => Date.now());
747
+ }
748
+ /** Resolved state-file path, for the CLI and tests. */
749
+ filePath() {
750
+ return this.path;
751
+ }
752
+ load() {
753
+ if (this.records === void 0) {
754
+ const document = readDocument(this.path);
755
+ const records = {};
756
+ for (const [id, record] of Object.entries(document?.records ?? {})) if (isRecord(record)) records[id] = record;
757
+ this.records = records;
758
+ }
759
+ return this.records;
760
+ }
761
+ /**
762
+ * The usable record for a model, or `undefined` when there is none, it is
763
+ * expired, it was taken against a different catalog row, or it belongs to a
764
+ * different account.
765
+ *
766
+ * @param account - the account in effect, as `uid:enterpriseId`. Records are
767
+ * only returned for the account that produced them.
768
+ */
769
+ get(modelId, fingerprint, account) {
770
+ const record = this.load()[modelId];
771
+ if (record === void 0) return void 0;
772
+ if (record.fingerprint !== fingerprint) return void 0;
773
+ if (record.account !== account) return void 0;
774
+ if (this.now() - record.probedAtMs > this.ttlMs) return void 0;
775
+ return record;
776
+ }
777
+ /**
778
+ * Store one observation. Only a decisive answer (`validating` /
779
+ * `non-validating`) replaces an existing decisive record: a transient
780
+ * `unknown` must not erase knowledge the user already paid for.
781
+ */
782
+ set(modelId, record) {
783
+ const records = this.load();
784
+ const existing = records[modelId];
785
+ if (record.validation === "unknown" && existing !== void 0 && existing.fingerprint === record.fingerprint && existing.validation !== "unknown") return;
786
+ records[modelId] = record;
787
+ this.persist();
788
+ }
789
+ /** Drop every record; used by the card's explicit "clear" action. */
790
+ clear() {
791
+ this.records = {};
792
+ this.persist();
793
+ }
794
+ /** Every record currently held, for status display. */
795
+ all() {
796
+ return { ...this.load() };
797
+ }
798
+ /** Build a record stamped with this store's clock, version, and account. */
799
+ record(fingerprint, validation, efforts, account) {
800
+ return {
801
+ fingerprint,
802
+ validation,
803
+ efforts: validation === "validating" ? [...efforts] : [],
804
+ probedAtMs: this.now(),
805
+ pluginVersion: this.pluginVersion,
806
+ account
807
+ };
808
+ }
809
+ /**
810
+ * Write through a temporary file and rename, so a crash mid-write cannot
811
+ * leave a half-parsed document that reads as "no records" and silently drops
812
+ * every observation.
813
+ */
814
+ persist() {
815
+ const directory = dirname(this.path);
816
+ try {
817
+ if (!existsSync(directory)) mkdirSync(directory, { recursive: true });
818
+ const document = {
819
+ version: PROBE_FORMAT_VERSION,
820
+ records: this.load()
821
+ };
822
+ const temporary = resolve(`${this.path}.tmp`);
823
+ writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 384 });
824
+ renameSync(temporary, this.path);
825
+ } catch {}
826
+ }
827
+ };
828
+ /**
829
+ * Order observations newest-first for display.
830
+ *
831
+ * The store keeps insertion order so the file reads chronologically, but the
832
+ * card wants the most recent detection at the top: a sweep the user just ran
833
+ * should not appear below every earlier one, which is what appending to an
834
+ * insertion-ordered list does.
835
+ */
836
+ function newestFirst(records) {
837
+ return [...records].sort((a, b) => b.probedAt - a.probedAt);
838
+ }
839
+ //#endregion
840
+ //#region src/probe-service.ts
841
+ /**
842
+ * Serial probe runner. One instance is shared by the manual API and any
843
+ * future automatic trigger, so the two can never overlap.
844
+ */
845
+ var QoderProbeService = class {
846
+ options;
847
+ queue = Promise.resolve();
848
+ pending = /* @__PURE__ */ new Map();
849
+ running = false;
850
+ constructor(options) {
851
+ this.options = options;
852
+ }
853
+ /** Whether a sweep is in flight right now. */
854
+ isRunning() {
855
+ return this.running;
856
+ }
857
+ /**
858
+ * The record the adapter may use for this model, or `undefined`.
859
+ *
860
+ * Applies the plan's precedence (§5): a declared set always wins, so a model
861
+ * that declares `supportedEfforts` is never answered from an observation.
862
+ */
863
+ recordFor(modelId) {
864
+ const info = this.options.catalog.current().find((model) => model.id === modelId);
865
+ if (info === void 0) return void 0;
866
+ if (info.reasoning?.supportedEfforts !== void 0 && info.reasoning.supportedEfforts.length > 0) return;
867
+ const account = this.options.account();
868
+ if (account === void 0) return void 0;
869
+ return this.options.store.get(modelId, fingerprintModel(info), account);
870
+ }
871
+ /**
872
+ * Probe one model, serially.
873
+ *
874
+ * The authenticated manual route supplies one-request consent after UI
875
+ * confirmation. Other callers must pass the configured consent gate.
876
+ * Manual consent never changes the automatic-probing configuration.
877
+ * Explicit requests bypass historical results, but share an ongoing run.
878
+ */
879
+ async probe(modelId, manualConsent = false) {
880
+ if (!manualConsent && !this.options.consent()) return {
881
+ state: "unavailable",
882
+ reason: "probing is not authorized"
883
+ };
884
+ if (this.options.catalog.current().find((model) => model.id === modelId) === void 0) return {
885
+ state: "unavailable",
886
+ reason: `unknown model: ${modelId}`
887
+ };
888
+ const account = this.options.account();
889
+ if (account === void 0) return {
890
+ state: "unavailable",
891
+ reason: "no Qoder credential"
892
+ };
893
+ const pendingKey = JSON.stringify([account, modelId]);
894
+ const pending = this.pending.get(pendingKey);
895
+ if (pending !== void 0) return pending;
896
+ const run = this.queue.then(async () => {
897
+ const current = this.options.catalog.current().find((model) => model.id === modelId);
898
+ if (current === void 0) return {
899
+ state: "unavailable",
900
+ reason: `unknown model: ${modelId}`
901
+ };
902
+ if (!manualConsent && !this.options.consent()) return {
903
+ state: "unavailable",
904
+ reason: "probing is not authorized"
905
+ };
906
+ if (current.reasoning?.supports !== true || (current.reasoning.supportedEfforts?.length ?? 0) > 0) return {
907
+ state: "unavailable",
908
+ reason: "model does not need detection"
909
+ };
910
+ const cached = this.recordFor(modelId);
911
+ if (!manualConsent && cached !== void 0 && cached.validation !== "unknown") return {
912
+ state: "ok",
913
+ validation: cached.validation,
914
+ efforts: cached.efforts,
915
+ requests: 0
916
+ };
917
+ if (this.options.account() !== account) return {
918
+ state: "unavailable",
919
+ reason: "account changed before detection"
920
+ };
921
+ if (await this.options.credentials.current() === void 0) return {
922
+ state: "unavailable",
923
+ reason: "no Qoder credential"
924
+ };
925
+ const send = this.options.send === void 0 ? (effort, signal) => this.options.client.probeEffort(modelId, effort, signal) : this.options.send(modelId);
926
+ this.running = true;
927
+ try {
928
+ const outcome = await probeModel({
929
+ send,
930
+ ...this.options.sentinel === void 0 ? {} : { sentinel: this.options.sentinel }
931
+ });
932
+ if (this.options.account() !== account) return {
933
+ state: "unavailable",
934
+ reason: "account changed during detection"
935
+ };
936
+ const record = this.options.store.record(fingerprintModel(current), outcome.validation, outcome.efforts, account);
937
+ this.options.store.set(modelId, record);
938
+ if (outcome.validation === "unknown") return {
939
+ state: "unavailable",
940
+ reason: outcome.reason
941
+ };
942
+ return {
943
+ state: "ok",
944
+ validation: outcome.validation,
945
+ efforts: record.efforts,
946
+ requests: outcome.requests
947
+ };
948
+ } finally {
949
+ this.running = false;
950
+ }
951
+ });
952
+ this.queue = run.catch(() => void 0);
953
+ this.pending.set(pendingKey, run);
954
+ try {
955
+ return await run;
956
+ } finally {
957
+ this.pending.delete(pendingKey);
958
+ }
959
+ }
960
+ };
961
+ //#endregion
962
+ //#region src/web-status.ts
963
+ /** Redact token-like content before it crosses to the browser. */
964
+ function safeMessage(error) {
965
+ return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted token]").replace(/(\b(?:code|token|pat)=)[^&\s]+/giu, "$1[redacted]").slice(0, 500);
966
+ }
967
+ function json$1(res, status, body) {
968
+ const payload = JSON.stringify(body);
969
+ res.writeHead(status, {
970
+ "Content-Type": "application/json",
971
+ "Content-Length": Buffer.byteLength(payload)
972
+ });
973
+ res.end(payload);
974
+ }
975
+ /**
976
+ * The request must be addressed to the loopback interface, and a
977
+ * browser-attached Origin must be loopback too. The Host check drops
978
+ * DNS-rebinding pages (their Host is the attacker's domain, not loopback);
979
+ * the card's same-origin fetches carry no Origin and pass on Host alone.
980
+ */
981
+ function loopbackRequest(req) {
982
+ return hostIsLoopback(req.headers.host) && originIsLoopback(req.headers.origin);
983
+ }
984
+ /** The `x<n>` rate label back into the number the snapshot carries, when honest. */
985
+ function priceFactorOf(info) {
986
+ if (info.billing.rateUnknown === true) return void 0;
987
+ const rate = normalizeCredits(info.billing.credits);
988
+ if (rate === void 0) return void 0;
989
+ const match = /^x([0-9]+(?:\.[0-9]+)?)$/u.exec(rate);
990
+ if (match === null) return void 0;
991
+ const value = Number(match[1]);
992
+ return Number.isFinite(value) ? value : void 0;
993
+ }
994
+ /** One catalog row as the status document snapshots it. */
995
+ function modelSnapshot(model) {
996
+ const supported = model.supportedContextWindows ?? [];
997
+ const maxContextWindow = supported.length > 0 ? Math.max(...supported) : void 0;
998
+ const defaultContextWindow = model.defaultContextWindow ?? model.contextWindow;
999
+ const priceFactor = priceFactorOf(model);
1000
+ return {
1001
+ id: model.id,
1002
+ name: model.name,
1003
+ ...model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {},
1004
+ ...defaultContextWindow > 0 && defaultContextWindow < model.contextWindow ? { defaultContextWindow } : {},
1005
+ ...maxContextWindow === void 0 || maxContextWindow <= defaultContextWindow ? {} : { supportedContextWindows: supported },
1006
+ ...model.reasoning?.supports === true ? { isReasoning: true } : {},
1007
+ ...model.reasoning?.supportedEfforts !== void 0 && model.reasoning.supportedEfforts.length > 0 ? { reasoningEfforts: model.reasoning.supportedEfforts } : {},
1008
+ ...model.reasoning?.defaultEffort === void 0 ? {} : { defaultReasoningEffort: model.reasoning.defaultEffort },
1009
+ ...priceFactor === void 0 ? {} : { priceFactor },
1010
+ supportsImages: model.supportsImages,
1011
+ ...model.source === void 0 ? {} : { source: model.source }
1012
+ };
1013
+ }
1014
+ /**
1015
+ * Assemble the card's status document. Credential state is read-only; credit
1016
+ * is a live billing answer whose failure degrades to `creditsError` rather
1017
+ * than failing the whole document.
1018
+ */
1019
+ async function qoderWebStatus(deps) {
1020
+ const authStatus = await deps.store.status();
1021
+ if (authStatus.state !== "configured") return {
1022
+ status: "signed-out",
1023
+ ...authStatus.reason === void 0 ? {} : { reason: authStatus.reason },
1024
+ ...deps.authKey === void 0 ? {} : { authKey: deps.authKey }
1025
+ };
1026
+ const status = {
1027
+ status: "signed-in",
1028
+ ...authStatus.region === void 0 ? {} : { region: authStatus.region },
1029
+ ...authStatus.pat === void 0 ? {} : { pat: authStatus.pat },
1030
+ ...deps.authKey === void 0 ? {} : { authKey: deps.authKey }
1031
+ };
1032
+ const modelsField = deps.models().map(modelSnapshot);
1033
+ const catalog = deps.catalog?.();
1034
+ const withCatalog = catalog === void 0 ? status : {
1035
+ ...status,
1036
+ catalog
1037
+ };
1038
+ const statusWithModels = modelsField.length > 0 ? {
1039
+ ...withCatalog,
1040
+ models: modelsField
1041
+ } : withCatalog;
1042
+ const probed = deps.probe === void 0 ? statusWithModels : {
1043
+ ...statusWithModels,
1044
+ probe: deps.probe(),
1045
+ ...deps.probeKey === void 0 ? {} : { probeKey: deps.probeKey },
1046
+ ...deps.useMaximumContextWindow === void 0 ? {} : { useMaximumContextWindow: deps.useMaximumContextWindow() }
1047
+ };
1048
+ try {
1049
+ const credits = await deps.client.fetchCredits();
1050
+ return {
1051
+ ...probed,
1052
+ credits
1053
+ };
1054
+ } catch (error) {
1055
+ return {
1056
+ ...probed,
1057
+ creditsError: safeMessage(error)
1058
+ };
1059
+ }
1060
+ }
1061
+ /** The status route's request handler, extracted so tests can mount it on a bare server. */
1062
+ function qoderStatusHandler(deps) {
1063
+ return async (req, res) => {
1064
+ if (req.method !== "GET") {
1065
+ json$1(res, 405, { error: "method not allowed" });
1066
+ return;
1067
+ }
1068
+ if (!loopbackRequest(req)) {
1069
+ json$1(res, 403, { error: "request-not-trusted" });
1070
+ return;
1071
+ }
1072
+ try {
1073
+ json$1(res, 200, await qoderWebStatus(deps));
1074
+ } catch (error) {
1075
+ json$1(res, 500, { error: safeMessage(error) });
1076
+ }
1077
+ };
1078
+ }
1079
+ /** Mount the GET status route on an optional webServer context. */
1080
+ function registerQoderStatusRoute(ctx, deps) {
1081
+ const path = deps.path ?? "/plugins/dsh-qoder-connect/status";
1082
+ ctx.effect(() => {
1083
+ const dispose = ctx.webServer.register({
1084
+ kind: "exact",
1085
+ path,
1086
+ handler: qoderStatusHandler(deps)
1087
+ });
1088
+ return () => {
1089
+ dispose();
1090
+ };
1091
+ }, "dsh-qoder-connect: Web status route");
1092
+ }
1093
+ //#endregion
1094
+ //#region src/probe-route.ts
1095
+ /**
1096
+ * Probe control route: the only state-changing endpoint the plugin exposes.
1097
+ *
1098
+ * Two guards, because they stop different things (see `docs/reasoning-effort-probe-plan.md`
1099
+ * §6.4 and the v0.3.1 note in AGENTS.md about their exact scope):
1100
+ *
1101
+ * 1. **Loopback Host + Origin**, shared with the status route. This drops
1102
+ * DNS-rebinding pages, whose requests arrive addressed to the attacker's
1103
+ * domain.
1104
+ * 2. **An in-process random key**, minted per process and handed only to the
1105
+ * same-origin card. Loopback alone is *not* authentication — any local
1106
+ * process can write `Host: 127.0.0.1` — so a route that spends the user's
1107
+ * credit must prove the caller was told the key.
1108
+ *
1109
+ * The route never accepts a prompt, a model id outside the live catalog, or a
1110
+ * sentinel from the browser: a probe request is assembled entirely host-side.
1111
+ *
1112
+ * @module dsh-qoder-connect/probe-route
1113
+ */
1114
+ /** Largest control body accepted; these payloads are a few dozen bytes. */
1115
+ const MAX_BODY_BYTES = 4096;
1116
+ /** Mint the per-process control key. */
1117
+ function createProbeKey() {
1118
+ return randomBytes(24).toString("hex");
1119
+ }
1120
+ /**
1121
+ * Constant-time key comparison; a length mismatch is a failure, not a crash.
1122
+ */
1123
+ function keyMatches(expected, presented) {
1124
+ if (presented === void 0 || presented.length !== expected.length) return false;
1125
+ const a = Buffer.from(expected);
1126
+ const b = Buffer.from(presented);
1127
+ return a.length === b.length && timingSafeEqual(a, b);
1128
+ }
1129
+ function json(res, status, body) {
1130
+ const payload = JSON.stringify(body);
1131
+ res.writeHead(status, {
1132
+ "Content-Type": "application/json",
1133
+ "Content-Length": Buffer.byteLength(payload)
1134
+ });
1135
+ res.end(payload);
1136
+ }
1137
+ /** Read the request body with a hard ceiling. */
1138
+ async function readBody(req) {
1139
+ const chunks = [];
1140
+ let total = 0;
1141
+ for await (const chunk of req) {
1142
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1143
+ total += buffer.length;
1144
+ if (total > MAX_BODY_BYTES) return void 0;
1145
+ chunks.push(buffer);
1146
+ }
1147
+ return Buffer.concat(chunks).toString("utf8");
1148
+ }
1149
+ /** Parse and shape-check an action; unknown fields are ignored, not trusted. */
1150
+ function parseAction(text) {
1151
+ let parsed;
1152
+ try {
1153
+ parsed = JSON.parse(text);
1154
+ } catch {
1155
+ return;
1156
+ }
1157
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
1158
+ const wrapped = parsed;
1159
+ const action = wrapped["action"];
1160
+ if (action === "clear") return { action: "clear" };
1161
+ if (action === "refresh") return { action: "refresh" };
1162
+ if (action === "set-maximum-context-window") return typeof wrapped["enabled"] === "boolean" ? {
1163
+ action: "set-maximum-context-window",
1164
+ enabled: wrapped["enabled"]
1165
+ } : void 0;
1166
+ if (action === "probe") {
1167
+ const model = wrapped["model"];
1168
+ if (typeof model !== "string" || model.trim() === "") return void 0;
1169
+ return {
1170
+ action: "probe",
1171
+ model: model.trim()
1172
+ };
1173
+ }
1174
+ }
1175
+ /**
1176
+ * The control route's handler, extracted so tests can mount it on a bare
1177
+ * server with a known key.
1178
+ */
1179
+ function qoderProbeHandler(deps, key) {
1180
+ return async (req, res) => {
1181
+ if (req.method !== "POST") {
1182
+ json(res, 405, { error: "method not allowed" });
1183
+ return;
1184
+ }
1185
+ if (!hostIsLoopback(req.headers.host) || !originIsLoopback(req.headers.origin)) {
1186
+ json(res, 403, { error: "request-not-trusted" });
1187
+ return;
1188
+ }
1189
+ if (!keyMatches(key, req.headers["x-qoder-probe-key"])) {
1190
+ json(res, 403, { error: "invalid-probe-key" });
1191
+ return;
1192
+ }
1193
+ const body = await readBody(req);
1194
+ if (body === void 0) {
1195
+ json(res, 413, { error: "body too large" });
1196
+ return;
1197
+ }
1198
+ const action = parseAction(body);
1199
+ if (action === void 0) {
1200
+ json(res, 400, { error: "invalid action" });
1201
+ return;
1202
+ }
1203
+ try {
1204
+ if (action.action === "clear") {
1205
+ deps.clear();
1206
+ json(res, 200, { state: "cleared" });
1207
+ return;
1208
+ }
1209
+ if (action.action === "refresh") {
1210
+ if (deps.refresh === void 0) {
1211
+ json(res, 404, { error: "refresh-not-supported" });
1212
+ return;
1213
+ }
1214
+ json(res, 200, await deps.refresh());
1215
+ return;
1216
+ }
1217
+ if (action.action === "set-maximum-context-window") {
1218
+ if (deps.setMaximumContextWindow === void 0) {
1219
+ json(res, 404, { error: "context-window-setting-not-supported" });
1220
+ return;
1221
+ }
1222
+ json(res, 200, await deps.setMaximumContextWindow(action.enabled === true));
1223
+ return;
1224
+ }
1225
+ json(res, 200, await deps.probe(action.model));
1226
+ } catch (error) {
1227
+ json(res, 500, { error: error instanceof Error ? error.message : String(error) });
1228
+ }
1229
+ };
1230
+ }
1231
+ /** Mount the POST probe-control route on an optional webServer context. */
1232
+ function registerQoderProbeRoute(ctx, deps, key) {
1233
+ const path = deps.path ?? "/plugins/dsh-qoder-connect/probe";
1234
+ ctx.effect(() => {
1235
+ const dispose = ctx.webServer.register({
1236
+ kind: "exact",
1237
+ path,
1238
+ handler: qoderProbeHandler(deps, key)
1239
+ });
1240
+ return () => {
1241
+ dispose();
1242
+ };
1243
+ }, "dsh-qoder-connect: probe control route");
1244
+ }
1245
+ //#endregion
1246
+ //#region src/index.ts
1247
+ /** Stable Cordis plugin name. */
1248
+ const name = "llm-qoder";
1249
+ /** The model registry required before the provider can register. */
1250
+ const inject = ["llm"];
1251
+ /**
1252
+ * Settings namespace owning the China card's section.
1253
+ *
1254
+ * DSH 0.1.2 dropped the `settingsNamespace()` branding function: a namespace is
1255
+ * now a nominal string, validated by the type system where it is used rather
1256
+ * than at runtime by a function call. The brand is compile-time only, so this
1257
+ * stays the plain string it always was — every comparison, descriptor lookup,
1258
+ * and `dsh` config file still sees `'qoder'`. It is cast once here so the
1259
+ * public constant carries the seam's type without pulling the brand helper
1260
+ * into this package (upstream DSH plugins, `dsh-llm-pi-ai` included, pass
1261
+ * their namespaces as plain string literals).
1262
+ */
1263
+ const QODER_SETTINGS_NS = "qoder";
1264
+ /**
1265
+ * Settings namespace owning the global card's section.
1266
+ *
1267
+ * One namespace per card, not one shared: the settings Plugins tab dispatches a
1268
+ * card by rendering `settings.plugin.item` with `entryKey = ns` for each
1269
+ * namespace the Host serves, and skips an entry whose key names no served
1270
+ * namespace. With a single installed section, the global card registers
1271
+ * into the slot but is never rendered — the card list is built from the Host's
1272
+ * sections, not from the slot's entries. Each card therefore needs its own
1273
+ * installed section whose namespace equals the card's slot key.
1274
+ */
1275
+ const QODER_GLOBAL_SETTINGS_NS = "qoder-global";
1276
+ /**
1277
+ * Settings namespace owning the shared quota-card section.
1278
+ *
1279
+ * One card above the two variant cards configures both sidebar quota widgets
1280
+ * (China and global) from a single place, so its toggles cannot live in
1281
+ * either variant's section — they are per-variant fields on a cross-variant
1282
+ * card. The Plugins tab dispatches by namespace, so this section is what makes
1283
+ * that card render (see {@link QODER_GLOBAL_SETTINGS_NS} for the mechanism).
1284
+ */
1285
+ const QODER_QUOTA_SETTINGS_NS = "qoder-quota";
1286
+ /**
1287
+ * How often the credential files are re-checked, in milliseconds.
1288
+ *
1289
+ * A startup-only catalog fetch cannot notice a token saved while DSH is
1290
+ * already running, so the model group would not appear until a restart. This
1291
+ * poll is a cheap existence/parse read of at most a few local files: it never
1292
+ * contacts the network and never runs a reasoning probe.
1293
+ *
1294
+ * `DSH_QODER_POLL_MS` overrides it. That exists so the sweep can be
1295
+ * exercised end to end in tests and shortened while diagnosing a slow
1296
+ * sign-in on a real machine; it is not a product setting and no UI exposes it.
1297
+ * The value is clamped to a sane range so a mistaken override cannot turn the
1298
+ * poll into a busy loop.
1299
+ */
1300
+ const CREDENTIAL_POLL_MS = 3e4;
1301
+ /** Floor and ceiling for the overridable poll interval. */
1302
+ const MIN_POLL_MS = 100;
1303
+ const MAX_POLL_MS = 864e5;
1304
+ /** Resolve the sweep interval, honoring the override when it is usable. */
1305
+ function credentialPollMs() {
1306
+ const override = Number(process.env["DSH_QODER_POLL_MS"]);
1307
+ if (!Number.isFinite(override) || override < MIN_POLL_MS) return CREDENTIAL_POLL_MS;
1308
+ return Math.min(override, MAX_POLL_MS);
1309
+ }
1310
+ /**
1311
+ * How long to wait before retrying a catalog fetch that failed.
1312
+ *
1313
+ * The credential sweep deliberately does not re-fetch a catalog it already has
1314
+ * (the same token carries no new model information). But a *failed* fetch must
1315
+ * not be treated the same way: without a retry, one transient network blip at
1316
+ * startup would leave the group on the built-in fallback roster until the user
1317
+ * noticed and pressed refresh. This bound keeps that recovery automatic while
1318
+ * still honoring the "not every round" rule — at most one attempt per
1319
+ * interval, and none at all once a live catalog lands.
1320
+ *
1321
+ * Expressed as a multiple of the sweep rather than a fixed duration so the two
1322
+ * stay in proportion under the `DSH_QODER_POLL_MS` override.
1323
+ */
1324
+ const CATALOG_RETRY_SWEEPS = 10;
1325
+ /** Probe authorization (shared by the plugin schema and the China section). */
1326
+ const PROBE_CONSENT_FIELD = z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)");
1327
+ const MAXIMUM_CONTEXT_WINDOW_FIELD = z.boolean().default(true).description("Use the largest context window declared by Qoder Global when alternatives are available (on by default)");
1328
+ /** The China variant's own maximum-window preference. */
1329
+ const MAXIMUM_CONTEXT_WINDOW_CN_FIELD = z.boolean().default(true).description("Use the largest context window declared by Qoder (China) when alternatives are available (on by default)");
1330
+ /**
1331
+ * Per-model window overrides (model id → tokens). The card writes one entry
1332
+ * per selector change; the field is optional so an absent map means "no
1333
+ * overrides" rather than a default object the host must keep in sync.
1334
+ */
1335
+ const MODEL_CONTEXT_WINDOWS_FIELD = z.dict(z.number().min(1), z.string()).description("Per-model context-window overrides for Qoder Global (model id → tokens)");
1336
+ /** Sidebar quota toggle (one per variant; both live on the shared quota card). */
1337
+ const QUOTA_TOGGLE_FIELD = z.boolean().default(false).description("Show this variant’s remaining-credit card in the sidebar footer (off by default)");
1338
+ /**
1339
+ * Quota poll interval: default 5 minutes, floor 1 minute. The status route
1340
+ * performs a live upstream billing call per request with no cache, so an
1341
+ * aggressively small interval translates directly into upstream load; the
1342
+ * floor is the smallest value the UI offers rather than a silent clamp —
1343
+ * smaller staged values fail Host validation and refuse to save.
1344
+ */
1345
+ const QUOTA_POLL_DEFAULT_MS = 3e5;
1346
+ const QUOTA_POLL_MIN_MS = 6e4;
1347
+ const QUOTA_POLL_FIELD = z.number().default(QUOTA_POLL_DEFAULT_MS).min(QUOTA_POLL_MIN_MS).description("Sidebar quota card refresh interval in milliseconds (default 300000, minimum 60000)");
1348
+ const Config = z.object({
1349
+ probeConsent: PROBE_CONSENT_FIELD,
1350
+ useMaximumContextWindow: MAXIMUM_CONTEXT_WINDOW_FIELD,
1351
+ useMaximumContextWindowCN: MAXIMUM_CONTEXT_WINDOW_CN_FIELD,
1352
+ modelContextWindows: MODEL_CONTEXT_WINDOWS_FIELD,
1353
+ modelContextWindowsCN: MODEL_CONTEXT_WINDOWS_FIELD,
1354
+ sidebarQuotaCN: QUOTA_TOGGLE_FIELD,
1355
+ sidebarQuotaGlobal: QUOTA_TOGGLE_FIELD,
1356
+ quotaPollMs: QUOTA_POLL_FIELD
1357
+ });
1358
+ /**
1359
+ * The China card's settings section: only the fields that card edits.
1360
+ *
1361
+ * A section is what makes its namespace "served", which is what the Plugins
1362
+ * tab dispatches a card by — so the schema and the card must stay split the
1363
+ * same way. `probeConsent` lives here because it predates the second variant;
1364
+ * it gates no current code path (only manual, per-click-confirmed probes run),
1365
+ * so it is left where existing users set it rather than moved and re-asked.
1366
+ */
1367
+ const CHINA_SECTION = z.object({
1368
+ probeConsent: PROBE_CONSENT_FIELD,
1369
+ useMaximumContextWindowCN: MAXIMUM_CONTEXT_WINDOW_CN_FIELD,
1370
+ modelContextWindowsCN: MODEL_CONTEXT_WINDOWS_FIELD
1371
+ });
1372
+ /** The global card's settings section and its context-window preferences. */
1373
+ const GLOBAL_SECTION = z.object({
1374
+ useMaximumContextWindow: MAXIMUM_CONTEXT_WINDOW_FIELD,
1375
+ modelContextWindows: MODEL_CONTEXT_WINDOWS_FIELD
1376
+ });
1377
+ /**
1378
+ * The shared quota card's section: both sidebar toggles and the poll interval.
1379
+ *
1380
+ * Only these fields — the card edits nothing else, and the Plugins tab pairs a
1381
+ * card with the section whose namespace it names, so a stray field here would
1382
+ * render as a control no other surface reads.
1383
+ */
1384
+ const QUOTA_SECTION = z.object({
1385
+ sidebarQuotaCN: QUOTA_TOGGLE_FIELD,
1386
+ sidebarQuotaGlobal: QUOTA_TOGGLE_FIELD,
1387
+ quotaPollMs: QUOTA_POLL_FIELD
1388
+ });
1389
+ /** Stable identity key used by credentials, probe records, and catalog entries. */
1390
+ const credentialIdentity = qoderCredentialIdentity;
1391
+ /** The settings namespace a variant's card and provider directory entry use. */
1392
+ function settingsNamespaceFor(variant) {
1393
+ return variant.id === CHINA_VARIANT.id ? QODER_SETTINGS_NS : QODER_GLOBAL_SETTINGS_NS;
1394
+ }
1395
+ /**
1396
+ * The static catalog a variant serves before its first successful fetch.
1397
+ *
1398
+ * Both regions share one roster transcribed from the transport's built-in
1399
+ * model defaults: unlike the WorkBuddy-era endpoints, the Qoder pools are
1400
+ * region-specific only in what discovery lists, and inventing a second roster
1401
+ * from nothing would misdescribe whichever variant it was not captured from.
1402
+ */
1403
+ function fallbackFor(_variant) {
1404
+ return FALLBACK_QODER_MODELS;
1405
+ }
1406
+ /** Build one variant's stores, transport, and probe state. */
1407
+ function createVariantRuntime(ctx, config, variant, current, identityOf) {
1408
+ const store = new QoderCredentialStore({
1409
+ variant,
1410
+ logger: ctx.logger
1411
+ });
1412
+ const transport = createQoderTransport({
1413
+ region: variant.region,
1414
+ resolvePat: () => store.patPromise(),
1415
+ resolveMachineId: () => getMachineId([qoderMachineIdPath()])
1416
+ });
1417
+ const client = new QoderUpstreamClient({
1418
+ region: variant.region,
1419
+ providerId: variant.id,
1420
+ getPat: () => store.patPromise(),
1421
+ transport,
1422
+ attachments: { saveImage: async (request) => {
1423
+ const service = ctx.get("attachments");
1424
+ if (service === void 0) throw new Error("dsh-qoder-connect: no attachment service is available to store images");
1425
+ return await service.saveImage(request);
1426
+ } }
1427
+ });
1428
+ const fallback = fallbackFor(variant);
1429
+ const catalog = new QoderCatalog(fallback);
1430
+ if (variant.id !== CHINA_VARIANT.id) catalog.setUseMaximumContextWindow(config.useMaximumContextWindow === true);
1431
+ else {
1432
+ catalog.setUseMaximumContextWindow(config.useMaximumContextWindowCN === true);
1433
+ if (config.modelContextWindowsCN !== void 0) catalog.setModelContextWindows(config.modelContextWindowsCN);
1434
+ }
1435
+ catalog.setVisible(false);
1436
+ const probeStore = new QoderProbeStore({
1437
+ pluginVersion: QODER_CONNECT_VERSION,
1438
+ path: qoderProbePath(variant.probeFilename)
1439
+ });
1440
+ const savedCatalogs = new QoderCatalogStore({ path: qoderCatalogPath(variant.catalogFilename) });
1441
+ return {
1442
+ variant,
1443
+ store,
1444
+ client,
1445
+ catalog,
1446
+ probeStore,
1447
+ probeService: new QoderProbeService({
1448
+ store: probeStore,
1449
+ catalog,
1450
+ credentials: store,
1451
+ client,
1452
+ consent: () => current().probeConsent === true,
1453
+ account: () => identityOf(variant.id)
1454
+ }),
1455
+ savedCatalogs,
1456
+ fallback,
1457
+ catalogSource: "fallback",
1458
+ catalogFetchedAtMs: void 0,
1459
+ catalogError: void 0,
1460
+ lastFetchAtMs: 0,
1461
+ catalogGeneration: 0,
1462
+ inflightFetch: void 0,
1463
+ invalidate: () => {},
1464
+ registered: false
1465
+ };
1466
+ }
1467
+ /** The catalog provenance the card displays. */
1468
+ function catalogSection(runtime) {
1469
+ return {
1470
+ source: runtime.catalogSource,
1471
+ ...runtime.catalogFetchedAtMs === void 0 ? {} : { fetchedAt: runtime.catalogFetchedAtMs },
1472
+ ...runtime.catalogError === void 0 ? {} : { error: runtime.catalogError }
1473
+ };
1474
+ }
1475
+ /**
1476
+ * Whether a model can be probed by hand: it reasons and the upstream declares
1477
+ * no effort set for it.
1478
+ *
1479
+ * Deliberately *not* filtered by whether a result already exists. Dropping a
1480
+ * model once it has been detected made the list shrink with use, so
1481
+ * re-detecting one model — after an upstream change, say — meant clearing every
1482
+ * other result first. The list stays stable and the card marks which entries
1483
+ * already have an answer.
1484
+ */
1485
+ function isProbeCandidate(info) {
1486
+ if (info.reasoning?.supports !== true) return false;
1487
+ return (info.reasoning.supportedEfforts?.length ?? 0) === 0;
1488
+ }
1489
+ /** Compact probe state for one card: consent, candidates, observations. */
1490
+ function probeSection(runtime, consent) {
1491
+ const models = runtime.catalog.current();
1492
+ const results = models.flatMap((info) => {
1493
+ const record = runtime.probeService.recordFor(info.id);
1494
+ if (record === void 0) return [];
1495
+ return [{
1496
+ id: info.id,
1497
+ name: info.name,
1498
+ validation: record.validation,
1499
+ efforts: record.efforts,
1500
+ probedAt: record.probedAtMs
1501
+ }];
1502
+ });
1503
+ return {
1504
+ consent,
1505
+ running: runtime.probeService.isRunning(),
1506
+ candidates: models.filter(isProbeCandidate).map((info) => info.id),
1507
+ results: newestFirst(results)
1508
+ };
1509
+ }
1510
+ /**
1511
+ * Start one variant: its loopback endpoint, provider registration, and
1512
+ * configuration-card wiring.
1513
+ *
1514
+ * Registration waits for the shim to hold a port, because the provider's
1515
+ * models read the shim origin at construction time. A failure here is
1516
+ * contained to this variant: the caller logs it and the other keeps working.
1517
+ *
1518
+ * @returns whether the provider registered.
1519
+ */
1520
+ async function startVariant(ctx, runtime) {
1521
+ const { variant, store, client, catalog, probeService } = runtime;
1522
+ const shim = createQoderShim({
1523
+ store,
1524
+ client,
1525
+ catalog,
1526
+ providerId: variant.id,
1527
+ logger: ctx.logger
1528
+ });
1529
+ try {
1530
+ await shim.ready;
1531
+ } catch (error) {
1532
+ ctx.logger.error(`dsh-qoder-connect: ${variant.displayName} loopback endpoint failed to start`, error);
1533
+ return false;
1534
+ }
1535
+ try {
1536
+ const qoder = createQoderAdapter({
1537
+ providerId: variant.id,
1538
+ displayName: variant.displayName,
1539
+ shim,
1540
+ store,
1541
+ catalog,
1542
+ resolveAttachments: () => ctx.get("attachments"),
1543
+ observe: (modelId) => probeService.recordFor(modelId)
1544
+ });
1545
+ runtime.invalidate = () => {
1546
+ qoder.invalidate();
1547
+ ctx.emit("llm/adapters-updated");
1548
+ };
1549
+ let releaseAdapter;
1550
+ let releaseDirectory;
1551
+ try {
1552
+ releaseAdapter = ctx.llm.registerAdapter([variant.id], qoder.adapter);
1553
+ releaseDirectory = ctx.llm.registerConfigurableProviders([{
1554
+ provider: variant.id,
1555
+ displayName: variant.displayName,
1556
+ settingsNs: settingsNamespaceFor(variant),
1557
+ settingsPath: [],
1558
+ declared: false
1559
+ }]);
1560
+ } finally {
1561
+ if (releaseAdapter === void 0 || releaseDirectory === void 0) {
1562
+ releaseAdapter?.();
1563
+ releaseDirectory?.();
1564
+ }
1565
+ }
1566
+ try {
1567
+ ctx.effect(() => () => {
1568
+ releaseAdapter?.();
1569
+ releaseDirectory?.();
1570
+ shim.close();
1571
+ });
1572
+ } catch {
1573
+ releaseAdapter?.();
1574
+ releaseDirectory?.();
1575
+ shim.close();
1576
+ }
1577
+ runtime.registered = true;
1578
+ return true;
1579
+ } catch (error) {
1580
+ ctx.logger.error(`dsh-qoder-connect: ${variant.displayName} provider registration failed`, error);
1581
+ shim.close();
1582
+ return false;
1583
+ }
1584
+ }
1585
+ /**
1586
+ * Start both variants: their loopback endpoints, the `qoder` and
1587
+ * `qoder-global` providers, their configuration cards, and their
1588
+ * credential-driven catalog lifecycles.
1589
+ *
1590
+ * Each variant registers unconditionally; what varies is whether its catalog is
1591
+ * *visible*. An empty catalog is how DSH hides a model group (the host filters
1592
+ * out groups with no models), which keeps a token saved after startup working
1593
+ * without re-registering the provider.
1594
+ */
1595
+ function apply(ctx, config) {
1596
+ let current = () => config;
1597
+ /** Timers and in-flight work belonging to this plugin instance. */
1598
+ let stopped = false;
1599
+ const timers = [];
1600
+ /**
1601
+ * The account identity each variant last published a catalog for. Keeps a
1602
+ * same-token sweep from re-fetching, and lets a late response from a
1603
+ * previous identity be discarded instead of overwriting a newer one.
1604
+ */
1605
+ const lastIdentities = /* @__PURE__ */ new Map();
1606
+ const runtimes = QODER_VARIANTS.map((variant) => createVariantRuntime(ctx, config, variant, () => current(), (id) => lastIdentities.get(id)));
1607
+ const probeKey = createProbeKey();
1608
+ /**
1609
+ * The in-process key authorizing PAT writes, minted separately from the
1610
+ * probe key.
1611
+ *
1612
+ * Separate keys rather than one shared secret because the two authorize
1613
+ * different powers: one spends credit on a probe, the other stores a
1614
+ * credential. A single key handed to both would let a defect in either card
1615
+ * reach the other's authority.
1616
+ */
1617
+ const authKey = createAuthKey();
1618
+ let setMaximumContextWindow;
1619
+ let setMaximumContextWindowCN;
1620
+ /**
1621
+ * Point a variant at an account identity, invalidating whatever the previous
1622
+ * one left behind.
1623
+ *
1624
+ * One helper for all four transitions (sweep sign-in, sweep sign-out, manual
1625
+ * refresh, card save) because each of them used to do its own partial
1626
+ * version, and the manual path forgot pieces the sweep did. Every transition
1627
+ * bumps {@link VariantRuntime.catalogGeneration}, which is what makes an
1628
+ * in-flight request from before the change refuse to write back.
1629
+ *
1630
+ * Probe observations are dropped whenever the account actually changes —
1631
+ * including sign-out, and including the "signed out, then in as someone else"
1632
+ * sequence that used to look like a first sighting and let the new account
1633
+ * inherit the old one's detected levels. They are deliberately NOT cleared on
1634
+ * a first sign-in: no previous account's data could leak there, and clearing
1635
+ * would delete records this very account owns (written before a restart, or
1636
+ * seeded while all of this is running).
1637
+ *
1638
+ * @param identity - the account now in effect, or `undefined` when signed out.
1639
+ */
1640
+ const adoptIdentity = (runtime, identity) => {
1641
+ const id = runtime.variant.id;
1642
+ const known = lastIdentities.get(id);
1643
+ if (known === identity) return;
1644
+ const hadCredential = known !== void 0;
1645
+ if (identity === void 0) lastIdentities.delete(id);
1646
+ else lastIdentities.set(id, identity);
1647
+ runtime.catalogGeneration += 1;
1648
+ runtime.inflightFetch?.controller.abort();
1649
+ runtime.inflightFetch = void 0;
1650
+ if (hadCredential && known !== identity) {
1651
+ runtime.probeStore.clear();
1652
+ runtime.invalidate();
1653
+ }
1654
+ if (identity === void 0) {
1655
+ if (known !== void 0) runtime.savedCatalogs.delete(known);
1656
+ runtime.catalog.set(runtime.fallback);
1657
+ runtime.catalogSource = "fallback";
1658
+ runtime.catalogFetchedAtMs = void 0;
1659
+ runtime.catalogError = void 0;
1660
+ if (runtime.catalog.setVisible(false)) runtime.invalidate();
1661
+ return;
1662
+ }
1663
+ const saved = runtime.savedCatalogs.get(identity);
1664
+ if (saved !== void 0) {
1665
+ runtime.catalog.set([...saved.models]);
1666
+ runtime.catalogSource = "saved";
1667
+ runtime.catalogFetchedAtMs = saved.fetchedAtMs;
1668
+ } else {
1669
+ runtime.catalog.set(runtime.fallback);
1670
+ runtime.catalogSource = "fallback";
1671
+ runtime.catalogFetchedAtMs = void 0;
1672
+ }
1673
+ runtime.catalogError = void 0;
1674
+ runtime.catalog.setVisible(true);
1675
+ runtime.invalidate();
1676
+ };
1677
+ ctx.inject(["webServer"], (webCtx) => {
1678
+ for (const runtime of runtimes) {
1679
+ registerQoderStatusRoute(webCtx, {
1680
+ path: runtime.variant.statusPath,
1681
+ store: runtime.store,
1682
+ client: runtime.client,
1683
+ models: () => runtime.catalog.current(),
1684
+ catalog: () => catalogSection(runtime),
1685
+ probe: () => probeSection(runtime, current().probeConsent === true),
1686
+ probeKey,
1687
+ authKey,
1688
+ ...runtime.variant.id === CHINA_VARIANT.id ? { useMaximumContextWindow: () => current().useMaximumContextWindowCN === true } : { useMaximumContextWindow: () => current().useMaximumContextWindow === true }
1689
+ });
1690
+ registerQoderAuthRoute(webCtx, {
1691
+ path: runtime.variant.authPath,
1692
+ save: async (pat) => {
1693
+ if (!await validateApiKey(pat, runtime.variant.region)) return {
1694
+ ok: false,
1695
+ error: "qoder_invalid_pat"
1696
+ };
1697
+ const credential = await runtime.store.save(pat);
1698
+ const identity = credentialIdentity(credential);
1699
+ adoptIdentity(runtime, identity);
1700
+ fetchCatalog(runtime, identity);
1701
+ return {
1702
+ ok: true,
1703
+ status: await runtime.store.status()
1704
+ };
1705
+ },
1706
+ clear: async () => {
1707
+ await runtime.store.clear();
1708
+ adoptIdentity(runtime, void 0);
1709
+ }
1710
+ }, authKey);
1711
+ registerQoderProbeRoute(webCtx, {
1712
+ path: runtime.variant.probePath,
1713
+ probe: async (modelId) => {
1714
+ const result = await runtime.probeService.probe(modelId, true);
1715
+ if (result.state === "ok") runtime.invalidate();
1716
+ return result;
1717
+ },
1718
+ clear: () => {
1719
+ runtime.probeStore.clear();
1720
+ runtime.invalidate();
1721
+ },
1722
+ refresh: async () => {
1723
+ if (stopped) return {
1724
+ state: "failed",
1725
+ reason: "plugin is stopping"
1726
+ };
1727
+ let credential;
1728
+ try {
1729
+ credential = await runtime.store.current();
1730
+ } catch (error) {
1731
+ return {
1732
+ state: "failed",
1733
+ reason: error instanceof Error ? error.message.slice(0, 300) : String(error)
1734
+ };
1735
+ }
1736
+ if (credential === void 0) {
1737
+ adoptIdentity(runtime, void 0);
1738
+ return { state: "signed-out" };
1739
+ }
1740
+ const identity = credentialIdentity(credential);
1741
+ adoptIdentity(runtime, identity);
1742
+ await fetchCatalog(runtime, identity);
1743
+ return runtime.catalogError === void 0 ? {
1744
+ state: "refreshed",
1745
+ reason: `${runtime.catalog.current().length} models`
1746
+ } : {
1747
+ state: "failed",
1748
+ reason: runtime.catalogError
1749
+ };
1750
+ },
1751
+ ...runtime.variant.id === CHINA_VARIANT.id ? { setMaximumContextWindow: async (enabled) => {
1752
+ if (setMaximumContextWindowCN === void 0) return {
1753
+ state: "failed",
1754
+ reason: "settings are unavailable"
1755
+ };
1756
+ return setMaximumContextWindowCN(enabled);
1757
+ } } : { setMaximumContextWindow: async (enabled) => {
1758
+ if (setMaximumContextWindow === void 0) return {
1759
+ state: "failed",
1760
+ reason: "settings are unavailable"
1761
+ };
1762
+ return setMaximumContextWindow(enabled);
1763
+ } }
1764
+ }, probeKey);
1765
+ }
1766
+ });
1767
+ ctx.inject(["settings"], (settingsCtx) => {
1768
+ /** Section sources; each falls back to its own slice when its side unloads. */
1769
+ const sources = {
1770
+ cn: () => config,
1771
+ global: () => config,
1772
+ quota: () => config
1773
+ };
1774
+ /** Merge all sections into the whole config the rest of the plugin reads. */
1775
+ const merged = () => ({
1776
+ ...sources.cn().probeConsent === void 0 ? {} : { probeConsent: sources.cn().probeConsent },
1777
+ ...sources.cn().useMaximumContextWindowCN === void 0 ? {} : { useMaximumContextWindowCN: sources.cn().useMaximumContextWindowCN },
1778
+ ...sources.cn().modelContextWindowsCN === void 0 ? {} : { modelContextWindowsCN: sources.cn().modelContextWindowsCN },
1779
+ ...sources.global().useMaximumContextWindow === void 0 ? {} : { useMaximumContextWindow: sources.global().useMaximumContextWindow },
1780
+ ...sources.global().modelContextWindows === void 0 ? {} : { modelContextWindows: sources.global().modelContextWindows },
1781
+ ...sources.quota().sidebarQuotaCN === void 0 ? {} : { sidebarQuotaCN: sources.quota().sidebarQuotaCN },
1782
+ ...sources.quota().sidebarQuotaGlobal === void 0 ? {} : { sidebarQuotaGlobal: sources.quota().sidebarQuotaGlobal },
1783
+ ...sources.quota().quotaPollMs === void 0 ? {} : { quotaPollMs: sources.quota().quotaPollMs }
1784
+ });
1785
+ const applyMaximumContextWindow = (next) => {
1786
+ let changed = false;
1787
+ for (const runtime of runtimes) {
1788
+ const isChina = runtime.variant.id === CHINA_VARIANT.id;
1789
+ const preference = isChina ? next.useMaximumContextWindowCN : next.useMaximumContextWindow;
1790
+ if (runtime.catalog.setUseMaximumContextWindow(preference === true)) changed = true;
1791
+ const overrides = isChina ? next.modelContextWindowsCN : next.modelContextWindows;
1792
+ if (overrides !== void 0 && runtime.catalog.setModelContextWindows(overrides)) changed = true;
1793
+ }
1794
+ if (changed) for (const runtime of runtimes) runtime.invalidate();
1795
+ };
1796
+ const repointStores = () => {
1797
+ applyMaximumContextWindow(merged());
1798
+ };
1799
+ settingsCtx.settings.installSection(ctx, QODER_SETTINGS_NS, CHINA_SECTION, config, {
1800
+ setSource(source) {
1801
+ sources.cn = source;
1802
+ current = merged;
1803
+ },
1804
+ onChange: repointStores
1805
+ });
1806
+ settingsCtx.settings.installSection(ctx, QODER_GLOBAL_SETTINGS_NS, GLOBAL_SECTION, config, {
1807
+ setSource(source) {
1808
+ sources.global = source;
1809
+ current = merged;
1810
+ },
1811
+ onChange: repointStores
1812
+ });
1813
+ settingsCtx.settings.installSection(ctx, QODER_QUOTA_SETTINGS_NS, QUOTA_SECTION, config, {
1814
+ setSource(source) {
1815
+ sources.quota = source;
1816
+ current = merged;
1817
+ },
1818
+ onChange: () => {}
1819
+ });
1820
+ setMaximumContextWindow = async (enabled) => {
1821
+ await settingsCtx.settings.update(QODER_GLOBAL_SETTINGS_NS, { useMaximumContextWindow: enabled });
1822
+ return { state: "updated" };
1823
+ };
1824
+ setMaximumContextWindowCN = async (enabled) => {
1825
+ await settingsCtx.settings.update(QODER_SETTINGS_NS, { useMaximumContextWindowCN: enabled });
1826
+ return { state: "updated" };
1827
+ };
1828
+ });
1829
+ ctx.effect(() => () => {
1830
+ stopped = true;
1831
+ for (const timer of timers) clearInterval(timer);
1832
+ timers.length = 0;
1833
+ clearHostHeartbeat();
1834
+ });
1835
+ /**
1836
+ * Fetch one variant's catalog for the current credential.
1837
+ *
1838
+ * Shared by the credential sweep and the card's manual refresh, and written
1839
+ * so that concurrent callers cost one request and cannot interleave badly:
1840
+ *
1841
+ * - **One request at a time.** A second caller joins the in-flight fetch
1842
+ * instead of starting its own (spec §5: one catalog request per variant at
1843
+ * a time).
1844
+ * - **Generation-checked write-back.** The request records the generation it
1845
+ * started under and writes nothing if the generation moved on — which is
1846
+ * what a slow answer from a superseded account must not do. Checking only
1847
+ * the *identity* was not enough: two refreshes for the same account can
1848
+ * still finish out of order, and the older one would win.
1849
+ * - **`resolve()`, not `current()`.** A catalog fetch demands a usable token;
1850
+ * the difference from `current()` is that its absence surfaces as the same
1851
+ * classified `MISSING_CREDENTIAL` failure every other request path reports,
1852
+ * rather than as a silent "no credential" that would hide a bad file on
1853
+ * disk behind an env override.
1854
+ */
1855
+ const fetchCatalog = async (runtime, identity) => {
1856
+ const inflight = runtime.inflightFetch;
1857
+ const generation = runtime.catalogGeneration;
1858
+ if (inflight !== void 0 && inflight.identity === identity && inflight.generation === generation) return inflight.promise;
1859
+ inflight?.controller.abort();
1860
+ const controller = new AbortController();
1861
+ let run;
1862
+ run = (async () => {
1863
+ let models;
1864
+ try {
1865
+ const credential = await runtime.store.resolve();
1866
+ const resolvedIdentity = credentialIdentity(credential);
1867
+ if (resolvedIdentity !== identity) {
1868
+ adoptIdentity(runtime, resolvedIdentity);
1869
+ await fetchCatalog(runtime, resolvedIdentity);
1870
+ return;
1871
+ }
1872
+ models = await runtime.client.fetchModels(controller.signal);
1873
+ const latest = await runtime.store.current();
1874
+ const latestIdentity = latest === void 0 ? void 0 : credentialIdentity(latest);
1875
+ if (latestIdentity !== identity) {
1876
+ adoptIdentity(runtime, latestIdentity);
1877
+ if (latestIdentity !== void 0) await fetchCatalog(runtime, latestIdentity);
1878
+ return;
1879
+ }
1880
+ } catch (error) {
1881
+ if (stopped || runtime.catalogGeneration !== generation) return;
1882
+ runtime.lastFetchAtMs = Date.now();
1883
+ runtime.catalogError = error instanceof Error ? error.message.slice(0, 300) : String(error);
1884
+ ctx.logger.warn(`dsh-qoder-connect: ${runtime.variant.displayName} catalog unavailable; serving the fallback list`, error);
1885
+ runtime.invalidate();
1886
+ return;
1887
+ }
1888
+ if (stopped || runtime.catalogGeneration !== generation) return;
1889
+ runtime.lastFetchAtMs = Date.now();
1890
+ runtime.catalog.set([...models]);
1891
+ runtime.catalogSource = "live";
1892
+ runtime.catalogFetchedAtMs = runtime.client.lastCatalog?.fetchedAtMs ?? Date.now();
1893
+ runtime.catalogError = void 0;
1894
+ if (lastIdentities.get(runtime.variant.id) === identity) runtime.savedCatalogs.set(identity, {
1895
+ source: runtime.client.lastCatalog?.source ?? "unknown",
1896
+ fetchedAtMs: runtime.client.lastCatalog?.fetchedAtMs ?? Date.now(),
1897
+ models: [...models]
1898
+ });
1899
+ runtime.invalidate();
1900
+ })().finally(() => {
1901
+ if (runtime.inflightFetch?.promise === run) runtime.inflightFetch = void 0;
1902
+ });
1903
+ runtime.inflightFetch = {
1904
+ identity,
1905
+ generation,
1906
+ controller,
1907
+ promise: run
1908
+ };
1909
+ return run;
1910
+ };
1911
+ /**
1912
+ * Reconcile one variant with its credentials.
1913
+ *
1914
+ * Four transitions matter, and each is a different action:
1915
+ *
1916
+ * - **none → some** (first sighting): reveal the group and fetch a catalog.
1917
+ * - **none → some, identity changed**: additionally drop the previous
1918
+ * account's observations, so another token's probe answers cannot be read
1919
+ * as the new one's.
1920
+ * - **some → none**: hide the group and stop serving its models.
1921
+ * - **same identity**: nothing to do — a PAT neither rotates nor expires, so
1922
+ * re-fetching on every sweep would hit the catalog endpoint for no new
1923
+ * information.
1924
+ */
1925
+ const syncVariant = async (runtime) => {
1926
+ if (stopped || !runtime.registered) return;
1927
+ const credential = await runtime.store.current().catch((error) => {
1928
+ ctx.logger.warn(`dsh-qoder-connect: ${runtime.variant.displayName} credential read failed`, error);
1929
+ });
1930
+ if (stopped) return;
1931
+ if (credential === void 0) {
1932
+ adoptIdentity(runtime, void 0);
1933
+ return;
1934
+ }
1935
+ const identity = credentialIdentity(credential);
1936
+ if (lastIdentities.get(runtime.variant.id) === identity && runtime.catalog.isVisible()) {
1937
+ const stale = runtime.catalogSource !== "live";
1938
+ const due = Date.now() - runtime.lastFetchAtMs >= credentialPollMs() * CATALOG_RETRY_SWEEPS;
1939
+ if (stale && due) await fetchCatalog(runtime, identity);
1940
+ return;
1941
+ }
1942
+ adoptIdentity(runtime, identity);
1943
+ await fetchCatalog(runtime, identity);
1944
+ };
1945
+ /** Run one reconcile sweep across both variants. */
1946
+ const syncAll = async () => {
1947
+ for (const runtime of runtimes) await syncVariant(runtime);
1948
+ };
1949
+ Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime))).then(() => {
1950
+ if (stopped) return;
1951
+ if (runtimes.some((runtime) => runtime.registered)) writeHostHeartbeat();
1952
+ syncAll();
1953
+ const timer = setInterval(() => {
1954
+ syncAll();
1955
+ }, credentialPollMs());
1956
+ timer.unref?.();
1957
+ timers.push(timer);
1958
+ });
1959
+ }
1960
+ //#endregion
1961
+ export { CHINA_VARIANT, Config, FALLBACK_QODER_MODELS, 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, 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 };