dsh-qoder-connect 0.1.5 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +7 -2
- package/README.md +7 -2
- package/lib/bin.js +1 -1
- package/lib/client.js +696 -30
- package/lib/index.d.ts +58 -4
- package/lib/index.js +1056 -188
- package/lib/{variants-BEiQve5M.js → variants-C8ezztjV.js} +46 -4
- package/package.json +120 -116
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { A as QODER_GLOBAL_AUTH_PATH, B as
|
|
1
|
+
import { A as QODER_GLOBAL_AUTH_PATH, B as patTail, C as probeModel, D as FALLBACK_QODER_MODELS, E as qoderCatalogPath, F as QODER_STATUS_PATH, G as qoderMachineIdPath, H as qoderOwnAuthPath, I as QODER_AUTH_FILENAME, K as qoderPluginDataDir, L as QODER_PAT_ENV_CN, M as QODER_GLOBAL_STATUS_PATH, N as QODER_PROBE_PATH, O as QoderCatalog, P as QODER_SETTINGS_FACE_PATH, R as QODER_PAT_ENV_GLOBAL, S as PROBE_EFFORT_CANDIDATES, T as QoderCatalogStore, U as QODER_DATA_DIR_ENV, V as qoderCredentialIdentity, W as QODER_DATA_DIR_NAME, _ 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, q as qoderStateDir, 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 QoderCredentialStore } from "./variants-C8ezztjV.js";
|
|
2
2
|
import z from "@deepseek-ai/schemastery";
|
|
3
3
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -7,8 +7,10 @@ import { createProvider } from "@earendil-works/pi-ai";
|
|
|
7
7
|
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
8
8
|
import { resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
|
|
9
9
|
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
10
|
+
import { homedir } from "node:os";
|
|
10
11
|
import { createServer } from "node:http";
|
|
11
12
|
import { Readable } from "node:stream";
|
|
13
|
+
import yamlModule from "js-yaml";
|
|
12
14
|
//#region src/loopback.ts
|
|
13
15
|
/**
|
|
14
16
|
* Shared loopback gates for the plugin's local HTTP surfaces: the loopback
|
|
@@ -265,6 +267,17 @@ const NO_COST = {
|
|
|
265
267
|
*/
|
|
266
268
|
const RATE_SEPARATOR = " · ";
|
|
267
269
|
/**
|
|
270
|
+
* What the model seat shows in place of a rate the plugin cannot stand behind.
|
|
271
|
+
*
|
|
272
|
+
* The seat has no locale service (the adapter is a host seam), so this is a
|
|
273
|
+
* literal; it matches the settings card's own wording for the same state.
|
|
274
|
+
* Rendering it as WORDS rather than an empty slot: every other model in the
|
|
275
|
+
* seat shows `· x0.3`, so a bare name where the rate should be reads as a
|
|
276
|
+
* rendering bug. This plugin's fallback roster carries no rate at all, so the
|
|
277
|
+
* case is not hypothetical.
|
|
278
|
+
*/
|
|
279
|
+
const RATE_UNAVAILABLE = "价格暂不可用";
|
|
280
|
+
/**
|
|
268
281
|
* Append the billing rate to one model's display name.
|
|
269
282
|
*
|
|
270
283
|
* The rate rides the *name* alone: since DSH 0.1.2 the composer's model seat
|
|
@@ -280,6 +293,7 @@ const RATE_SEPARATOR = " · ";
|
|
|
280
293
|
*/
|
|
281
294
|
/** The catalog display suffix: the normalized billing rate, when the row has one. */
|
|
282
295
|
function displaySuffix(info) {
|
|
296
|
+
if (info.billing?.rateUnknown === true) return RATE_UNAVAILABLE;
|
|
283
297
|
const rate = normalizeCredits(info.billing?.credits);
|
|
284
298
|
return rate === void 0 || rate === "" ? void 0 : rate;
|
|
285
299
|
}
|
|
@@ -1049,7 +1063,8 @@ async function qoderWebStatus(deps) {
|
|
|
1049
1063
|
...statusWithModels,
|
|
1050
1064
|
probe: deps.probe(),
|
|
1051
1065
|
...deps.probeKey === void 0 ? {} : { probeKey: deps.probeKey },
|
|
1052
|
-
...deps.useMaximumContextWindow === void 0 ? {} : { useMaximumContextWindow: deps.useMaximumContextWindow() }
|
|
1066
|
+
...deps.useMaximumContextWindow === void 0 ? {} : { useMaximumContextWindow: deps.useMaximumContextWindow() },
|
|
1067
|
+
...deps.disabledModels === void 0 ? {} : { disabledModels: deps.disabledModels() }
|
|
1053
1068
|
};
|
|
1054
1069
|
const refreshedAt = deps.jobTokenRefreshedAt?.();
|
|
1055
1070
|
const withRefreshNotice = refreshedAt === void 0 ? probed : {
|
|
@@ -1181,6 +1196,20 @@ function parseAction(text) {
|
|
|
1181
1196
|
action: "set-maximum-context-window",
|
|
1182
1197
|
enabled: wrapped["enabled"]
|
|
1183
1198
|
} : void 0;
|
|
1199
|
+
if (action === "set-models-enabled") {
|
|
1200
|
+
if (typeof wrapped["enabled"] !== "boolean") return void 0;
|
|
1201
|
+
const rawModels = wrapped["models"];
|
|
1202
|
+
const rawModel = wrapped["model"];
|
|
1203
|
+
let models = [];
|
|
1204
|
+
if (Array.isArray(rawModels)) models = rawModels.filter((m) => typeof m === "string" && m.trim() !== "").map((m) => m.trim());
|
|
1205
|
+
else if (typeof rawModel === "string" && rawModel.trim() !== "") models = [rawModel.trim()];
|
|
1206
|
+
if (models.length === 0) return void 0;
|
|
1207
|
+
return {
|
|
1208
|
+
action: "set-models-enabled",
|
|
1209
|
+
models,
|
|
1210
|
+
enabled: wrapped["enabled"]
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1184
1213
|
if (action === "probe") {
|
|
1185
1214
|
const model = wrapped["model"];
|
|
1186
1215
|
if (typeof model !== "string" || model.trim() === "") return void 0;
|
|
@@ -1253,6 +1282,17 @@ function qoderProbeHandler(deps, key) {
|
|
|
1253
1282
|
json(res, 200, await deps.setMaximumContextWindow(action.enabled === true));
|
|
1254
1283
|
return;
|
|
1255
1284
|
}
|
|
1285
|
+
if (action.action === "set-models-enabled") {
|
|
1286
|
+
if (deps.setModelsEnabled === void 0) {
|
|
1287
|
+
json(res, 404, { error: "models-enabled-setting-not-supported" });
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
json(res, 200, await deps.setModelsEnabled({
|
|
1291
|
+
models: action.models ?? (action.model ? [action.model] : []),
|
|
1292
|
+
enabled: action.enabled === true
|
|
1293
|
+
}));
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1256
1296
|
json(res, 200, await deps.probe(action.model));
|
|
1257
1297
|
} catch (error) {
|
|
1258
1298
|
json(res, 500, { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -1336,14 +1376,25 @@ function getUtc8DateString(nowMs = Date.now()) {
|
|
|
1336
1376
|
const utc8 = new Date(d.getTime() + (d.getTimezoneOffset() + 480) * 6e4);
|
|
1337
1377
|
return `${utc8.getFullYear()}-${String(utc8.getMonth() + 1).padStart(2, "0")}-${String(utc8.getDate()).padStart(2, "0")}`;
|
|
1338
1378
|
}
|
|
1379
|
+
/** Clamp any stored/typed value onto a real minute of the day. */
|
|
1380
|
+
function normalizeCheckInMinute(value) {
|
|
1381
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return 600;
|
|
1382
|
+
const whole = Math.trunc(value);
|
|
1383
|
+
if (whole < 0 || whole > 1439) return 600;
|
|
1384
|
+
return whole;
|
|
1385
|
+
}
|
|
1339
1386
|
/**
|
|
1340
|
-
* Calculates milliseconds until the next
|
|
1387
|
+
* Calculates milliseconds until the next occurrence of `minuteOfDay` (UTC+8).
|
|
1388
|
+
*
|
|
1389
|
+
* Five seconds past the configured minute are used so the request lands after
|
|
1390
|
+
* the upstream has flipped the day over rather than on the boundary itself.
|
|
1341
1391
|
*/
|
|
1342
|
-
function
|
|
1392
|
+
function msUntilNextCheckIn(minuteOfDay, nowMs = Date.now()) {
|
|
1393
|
+
const minute = normalizeCheckInMinute(minuteOfDay);
|
|
1343
1394
|
const d = new Date(nowMs);
|
|
1344
1395
|
const utc8Time = new Date(d.getTime() + (d.getTimezoneOffset() + 480) * 6e4);
|
|
1345
1396
|
const targetUtc8 = new Date(utc8Time.getTime());
|
|
1346
|
-
targetUtc8.setHours(
|
|
1397
|
+
targetUtc8.setHours(Math.floor(minute / 60), minute % 60, 5, 0);
|
|
1347
1398
|
let diff = targetUtc8.getTime() - utc8Time.getTime();
|
|
1348
1399
|
if (diff <= 0) {
|
|
1349
1400
|
targetUtc8.setDate(targetUtc8.getDate() + 1);
|
|
@@ -1352,13 +1403,13 @@ function msUntilNext10amUtc8(nowMs = Date.now()) {
|
|
|
1352
1403
|
return diff;
|
|
1353
1404
|
}
|
|
1354
1405
|
/**
|
|
1355
|
-
*
|
|
1356
|
-
* Current time is past today's 10:00:00 AM (UTC+8) and today has not yet settled a check-in.
|
|
1406
|
+
* Whether today's configured check-in moment (UTC+8) has already passed.
|
|
1357
1407
|
*/
|
|
1358
|
-
function
|
|
1359
|
-
|
|
1408
|
+
function isPastCheckInTime(minuteOfDay, nowMs = Date.now()) {
|
|
1409
|
+
const minute = normalizeCheckInMinute(minuteOfDay);
|
|
1360
1410
|
const d = new Date(nowMs);
|
|
1361
|
-
|
|
1411
|
+
const utc8 = new Date(d.getTime() + (d.getTimezoneOffset() + 480) * 6e4);
|
|
1412
|
+
return utc8.getHours() * 60 + utc8.getMinutes() >= minute;
|
|
1362
1413
|
}
|
|
1363
1414
|
var CheckInScheduler = class {
|
|
1364
1415
|
targets;
|
|
@@ -1366,7 +1417,21 @@ var CheckInScheduler = class {
|
|
|
1366
1417
|
store;
|
|
1367
1418
|
onResult;
|
|
1368
1419
|
now;
|
|
1369
|
-
|
|
1420
|
+
/**
|
|
1421
|
+
* One timer per enabled variant, keyed by variant id.
|
|
1422
|
+
*
|
|
1423
|
+
* Per-variant rather than one shared timer because the two products may be
|
|
1424
|
+
* configured to different moments; a single timer would have to wake for the
|
|
1425
|
+
* earliest and then decide who was due, which is the same bookkeeping with a
|
|
1426
|
+
* worse failure mode.
|
|
1427
|
+
*/
|
|
1428
|
+
timers = /* @__PURE__ */ new Map();
|
|
1429
|
+
/**
|
|
1430
|
+
* Variants with a sweep in progress, so concurrent callers cannot double-claim.
|
|
1431
|
+
*/
|
|
1432
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
1433
|
+
/** When each variant's timer is next due, epoch ms, for the card to show. */
|
|
1434
|
+
nextRuns = /* @__PURE__ */ new Map();
|
|
1370
1435
|
disposed = false;
|
|
1371
1436
|
constructor(options) {
|
|
1372
1437
|
this.targets = options.targets;
|
|
@@ -1378,56 +1443,113 @@ var CheckInScheduler = class {
|
|
|
1378
1443
|
start() {
|
|
1379
1444
|
if (this.disposed) return;
|
|
1380
1445
|
this.sweepAll(true);
|
|
1381
|
-
this.
|
|
1446
|
+
this.rearm();
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Re-run the startup catch-up once the toggles are actually readable.
|
|
1450
|
+
*
|
|
1451
|
+
* `start()` runs while the plugin is still assembling, before the settings
|
|
1452
|
+
* section that owns these toggles has handed over its stored values, so that
|
|
1453
|
+
* first sweep sees the raw plugin config and skips the day. The host calls
|
|
1454
|
+
* this again from the section's source callback; the sweep is idempotent, so
|
|
1455
|
+
* a day already handled costs nothing.
|
|
1456
|
+
*/
|
|
1457
|
+
catchUp() {
|
|
1458
|
+
if (this.disposed) return;
|
|
1459
|
+
this.sweepAll(true);
|
|
1382
1460
|
}
|
|
1383
1461
|
dispose() {
|
|
1384
1462
|
this.disposed = true;
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
this.timer = void 0;
|
|
1388
|
-
}
|
|
1463
|
+
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
1464
|
+
this.timers.clear();
|
|
1389
1465
|
}
|
|
1390
|
-
|
|
1466
|
+
/**
|
|
1467
|
+
* Re-place every variant's timer.
|
|
1468
|
+
*
|
|
1469
|
+
* Called after each fire and whenever configuration lands or changes, so a
|
|
1470
|
+
* user editing the time on the card does not have to restart DSH for it to
|
|
1471
|
+
* take effect.
|
|
1472
|
+
*
|
|
1473
|
+
* A timer is placed for every target, including variants whose toggle is
|
|
1474
|
+
* currently off. `start()` runs while the plugin is still assembling, when
|
|
1475
|
+
* the stored toggles are not readable yet, so gating placement on
|
|
1476
|
+
* `isEnabled` left a variant with NO timer at all — and nothing re-armed it
|
|
1477
|
+
* afterwards, which is precisely how a configured 12:13 check-in never
|
|
1478
|
+
* fired. Whether the work is due is decided inside the sweep, where the
|
|
1479
|
+
* configuration is current; the timer only decides when to look.
|
|
1480
|
+
*/
|
|
1481
|
+
rearm() {
|
|
1391
1482
|
if (this.disposed) return;
|
|
1392
|
-
const
|
|
1393
|
-
this.
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1483
|
+
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
1484
|
+
this.timers.clear();
|
|
1485
|
+
this.nextRuns.clear();
|
|
1486
|
+
const nowMs = this.now();
|
|
1487
|
+
for (const target of this.targets) {
|
|
1488
|
+
const delay = msUntilNextCheckIn(target.minuteOfDay(), nowMs);
|
|
1489
|
+
this.nextRuns.set(target.variantId, nowMs + delay);
|
|
1490
|
+
const timer = setTimeout(() => {
|
|
1491
|
+
this.timers.delete(target.variantId);
|
|
1492
|
+
this.sweepAll(false, target.variantId).finally(() => {
|
|
1493
|
+
this.rearm();
|
|
1494
|
+
});
|
|
1495
|
+
}, delay);
|
|
1496
|
+
timer.unref?.();
|
|
1497
|
+
this.timers.set(target.variantId, timer);
|
|
1498
|
+
}
|
|
1398
1499
|
}
|
|
1399
|
-
|
|
1500
|
+
/** When this variant's timer is next due, epoch ms; absent before first arm. */
|
|
1501
|
+
nextRunAt(variantId) {
|
|
1502
|
+
return this.nextRuns.get(variantId);
|
|
1503
|
+
}
|
|
1504
|
+
async sweepAll(isCatchUp, only) {
|
|
1400
1505
|
if (this.disposed) return;
|
|
1401
1506
|
const nowMs = this.now();
|
|
1402
1507
|
const today = getUtc8DateString(nowMs);
|
|
1403
1508
|
for (const target of this.targets) {
|
|
1509
|
+
if (only !== void 0 && target.variantId !== only) continue;
|
|
1404
1510
|
if (!this.isEnabled(target.variantId)) continue;
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
if (!isCatchUp && record?.lastDate === today && (record.status === "claimed" || record.status === "already-claimed")) continue;
|
|
1408
|
-
let pat;
|
|
1511
|
+
if (this.inFlight.has(target.variantId)) continue;
|
|
1512
|
+
this.inFlight.add(target.variantId);
|
|
1409
1513
|
try {
|
|
1410
|
-
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1514
|
+
await this.sweepOne(target, isCatchUp, nowMs, today);
|
|
1515
|
+
} finally {
|
|
1516
|
+
this.inFlight.delete(target.variantId);
|
|
1413
1517
|
}
|
|
1414
|
-
if (!pat) continue;
|
|
1415
|
-
try {
|
|
1416
|
-
const result = await target.service.checkIn(pat);
|
|
1417
|
-
if (result.status !== "error") {
|
|
1418
|
-
this.store.write(target.variantId, {
|
|
1419
|
-
lastDate: result.date,
|
|
1420
|
-
lastAt: result.timestamp,
|
|
1421
|
-
status: result.status,
|
|
1422
|
-
amount: result.amount,
|
|
1423
|
-
message: result.message
|
|
1424
|
-
});
|
|
1425
|
-
if (result.status === "claimed") target.onClaimed?.();
|
|
1426
|
-
}
|
|
1427
|
-
this.onResult?.(result);
|
|
1428
|
-
} catch {}
|
|
1429
1518
|
}
|
|
1430
1519
|
}
|
|
1520
|
+
async sweepOne(target, isCatchUp, nowMs, today) {
|
|
1521
|
+
const record = this.store.read(target.variantId);
|
|
1522
|
+
if (record?.lastDate === today && (record.status === "claimed" || record.status === "already-claimed")) {
|
|
1523
|
+
if (!isCatchUp) this.store.write(target.variantId, {
|
|
1524
|
+
lastDate: today,
|
|
1525
|
+
lastAt: nowMs,
|
|
1526
|
+
status: "already-claimed",
|
|
1527
|
+
...record.amount === void 0 ? {} : { amount: record.amount },
|
|
1528
|
+
message: "Scheduled check-in ran; today was already claimed"
|
|
1529
|
+
});
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1532
|
+
if (isCatchUp && !isPastCheckInTime(target.minuteOfDay(), nowMs)) return;
|
|
1533
|
+
let result;
|
|
1534
|
+
try {
|
|
1535
|
+
result = await target.checkIn();
|
|
1536
|
+
} catch {
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
try {
|
|
1540
|
+
if (result.status !== "error") {
|
|
1541
|
+
this.store.write(target.variantId, {
|
|
1542
|
+
lastDate: result.date,
|
|
1543
|
+
lastAt: result.timestamp,
|
|
1544
|
+
status: result.status,
|
|
1545
|
+
amount: result.amount,
|
|
1546
|
+
message: result.message
|
|
1547
|
+
});
|
|
1548
|
+
if (result.status === "claimed") target.onClaimed?.();
|
|
1549
|
+
}
|
|
1550
|
+
this.onResult?.(result);
|
|
1551
|
+
} catch {}
|
|
1552
|
+
}
|
|
1431
1553
|
};
|
|
1432
1554
|
//#endregion
|
|
1433
1555
|
//#region src/job-token-hint.ts
|
|
@@ -1507,6 +1629,175 @@ function appendHintRow(agent, kind, text) {
|
|
|
1507
1629
|
} catch {}
|
|
1508
1630
|
}
|
|
1509
1631
|
//#endregion
|
|
1632
|
+
//#region src/settings-store.ts
|
|
1633
|
+
/**
|
|
1634
|
+
* Plugin-owned settings store — the single source of truth for this plugin's
|
|
1635
|
+
* user configuration on every host line.
|
|
1636
|
+
*
|
|
1637
|
+
* WHY THIS EXISTS
|
|
1638
|
+
*
|
|
1639
|
+
* A settings write on DSH 0.1.7 goes through the profile patch
|
|
1640
|
+
* (`configEditor.edit`), which reconciles the whole loader tree and hot-reloads
|
|
1641
|
+
* the plugin's fiber (~1–1.5 s, plus a storm of client mirror refreshes) on
|
|
1642
|
+
* EVERY write — one per toggled switch. The plugin's own files (the credential,
|
|
1643
|
+
* the catalog cache) have always lived in `<profile>/.dsh-qoder-connect/`, so
|
|
1644
|
+
* the settings move there too: a write becomes a small atomic local file write
|
|
1645
|
+
* with an in-memory apply, no tree reconcile, no reload.
|
|
1646
|
+
*
|
|
1647
|
+
* FILE
|
|
1648
|
+
* <plugin data dir>/settings.json (same directory as the credential)
|
|
1649
|
+
*
|
|
1650
|
+
* The data directory is resolved by `qoderPluginDataDir()` — the ONE discovery
|
|
1651
|
+
* implementation this plugin already has (env override, then the declaring
|
|
1652
|
+
* profile, then the installing link). It is deliberately not re-implemented
|
|
1653
|
+
* here: a second copy of that logic is how a nested `.dsh-qoder-connect/
|
|
1654
|
+
* .dsh-qoder-connect/` path gets shipped.
|
|
1655
|
+
*
|
|
1656
|
+
* MIGRATION (one time)
|
|
1657
|
+
* the file is absent → seed it from the entry config's own fields → write the
|
|
1658
|
+
* file → delete ONLY this plugin's fields from the entry config (see
|
|
1659
|
+
* ./index.ts), so the profile row returns to its shipped state.
|
|
1660
|
+
*
|
|
1661
|
+
* @module dsh-qoder-connect/settings-store
|
|
1662
|
+
*/
|
|
1663
|
+
const yaml = yamlModule;
|
|
1664
|
+
const loadYaml = typeof yaml.load === "function" ? yaml.load : yaml.default?.load;
|
|
1665
|
+
/** Settings file name inside the plugin data directory. */
|
|
1666
|
+
const SETTINGS_FILE_NAME = "settings.json";
|
|
1667
|
+
/** This plugin's data directory (shared with the credential and caches). */
|
|
1668
|
+
function dataDir() {
|
|
1669
|
+
return qoderPluginDataDir();
|
|
1670
|
+
}
|
|
1671
|
+
/** Absolute path of the settings file. */
|
|
1672
|
+
function settingsFilePath() {
|
|
1673
|
+
return join(dataDir(), SETTINGS_FILE_NAME);
|
|
1674
|
+
}
|
|
1675
|
+
/** Read + parse the settings file; `undefined` when absent or unreadable. */
|
|
1676
|
+
function readFile() {
|
|
1677
|
+
const path = settingsFilePath();
|
|
1678
|
+
if (!existsSync(path)) return void 0;
|
|
1679
|
+
try {
|
|
1680
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
1681
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
|
|
1682
|
+
} catch {
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
/** Write the settings file atomically (tmp + rename), creating the directory. */
|
|
1687
|
+
function writeSettings(values) {
|
|
1688
|
+
const path = settingsFilePath();
|
|
1689
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1690
|
+
const tmp = `${path}.tmp`;
|
|
1691
|
+
writeFileSync(tmp, `${JSON.stringify(values, null, 2)}\n`, "utf8");
|
|
1692
|
+
renameSync(tmp, path);
|
|
1693
|
+
}
|
|
1694
|
+
/** Resolve `$DSH_HOME` the way the Host does, without importing a Host package. */
|
|
1695
|
+
function dshHome() {
|
|
1696
|
+
const fromEnv = process.env.DSH_HOME?.trim() || void 0;
|
|
1697
|
+
if (fromEnv !== void 0) return fromEnv;
|
|
1698
|
+
return join(homedir(), ".dsh");
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Read this plugin's sections out of the legacy settings document.
|
|
1702
|
+
*
|
|
1703
|
+
* The 0.1.5 host kept every plugin's settings in `$DSH_HOME/settings.yaml`;
|
|
1704
|
+
* 0.1.7 renames it to `settings.yaml.imported` once, on the first boot, and
|
|
1705
|
+
* imports only the sections whose names match a profile entry id. This
|
|
1706
|
+
* plugin's 0.1.5 sections were keyed by its SERVED NAMESPACES (`qoder`,
|
|
1707
|
+
* `qoder-global`, `qoder-quota`), which name no entry in the profile — the row
|
|
1708
|
+
* is `llm-qoder` — so the platform refuses them and their data survives only
|
|
1709
|
+
* in the renamed document. Reading it here recovers that data independently of
|
|
1710
|
+
* the platform's import, its section-name mapping, and its timing.
|
|
1711
|
+
*
|
|
1712
|
+
* @param namespaces - the section names the 0.1.5 host keyed this plugin's
|
|
1713
|
+
* settings under (one per served section).
|
|
1714
|
+
* @returns the union of those sections' fields, or undefined when the document
|
|
1715
|
+
* is absent, unreadable, or holds none of them.
|
|
1716
|
+
*/
|
|
1717
|
+
function readLegacySections(namespaces) {
|
|
1718
|
+
let path;
|
|
1719
|
+
for (const name of ["settings.yaml", "settings.yaml.imported"]) {
|
|
1720
|
+
const candidate = join(dshHome(), name);
|
|
1721
|
+
if (existsSync(candidate)) {
|
|
1722
|
+
path = candidate;
|
|
1723
|
+
break;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
if (path === void 0 || loadYaml === void 0) return void 0;
|
|
1727
|
+
let document;
|
|
1728
|
+
try {
|
|
1729
|
+
document = loadYaml(readFileSync(path, "utf8"));
|
|
1730
|
+
} catch {
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
if (document === null || typeof document !== "object") return void 0;
|
|
1734
|
+
const merged = {};
|
|
1735
|
+
let found = false;
|
|
1736
|
+
for (const ns of namespaces) {
|
|
1737
|
+
const section = document[ns];
|
|
1738
|
+
if (section === null || typeof section !== "object" || Array.isArray(section)) continue;
|
|
1739
|
+
Object.assign(merged, section);
|
|
1740
|
+
found = true;
|
|
1741
|
+
}
|
|
1742
|
+
return found ? merged : void 0;
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Reserved bookkeeping key: how many writes the settings card has made through
|
|
1746
|
+
* this store. Its presence separates "the file holds migration seed" from "the
|
|
1747
|
+
* file holds the user's live edits" — see the seed rule in ./index.ts. Field
|
|
1748
|
+
* readers address their fields by name and never collide with it.
|
|
1749
|
+
*/
|
|
1750
|
+
const WRITE_MARK = "__writes";
|
|
1751
|
+
/**
|
|
1752
|
+
* One store instance: the plugin's own settings file, with the entry config as
|
|
1753
|
+
* the fallback layer the caller overlays it on.
|
|
1754
|
+
*/
|
|
1755
|
+
var SettingsStore = class {
|
|
1756
|
+
/** The user layer exactly as stored (presence marks an override). */
|
|
1757
|
+
user;
|
|
1758
|
+
constructor() {
|
|
1759
|
+
this.user = readFile() ?? {};
|
|
1760
|
+
}
|
|
1761
|
+
/** Whether the store file exists. */
|
|
1762
|
+
exists() {
|
|
1763
|
+
return existsSync(settingsFilePath());
|
|
1764
|
+
}
|
|
1765
|
+
/**
|
|
1766
|
+
* Whether the settings card has ever written through this store.
|
|
1767
|
+
*
|
|
1768
|
+
* False during the migration window — the window where a 0.1.5 settings.yaml
|
|
1769
|
+
* import (which lands only after the Loader settles, i.e. AFTER this plugin's
|
|
1770
|
+
* first apply) can still deliver the user's real values, and where a first
|
|
1771
|
+
* seed may have been taken before that import. While false the entry config
|
|
1772
|
+
* stays authoritative; once the card writes, the file is — a runtime edit
|
|
1773
|
+
* must never be regressed by a stale profile row.
|
|
1774
|
+
*/
|
|
1775
|
+
get edited() {
|
|
1776
|
+
return typeof this.user[WRITE_MARK] === "number" && this.user[WRITE_MARK] > 0;
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Apply one patch in memory and persist it.
|
|
1780
|
+
* @param patch - field → value; a `null` value clears the field.
|
|
1781
|
+
* @param fromCard - true when the settings card made this write; marks the
|
|
1782
|
+
* file as holding live user edits from then on.
|
|
1783
|
+
* @returns the new user layer.
|
|
1784
|
+
*/
|
|
1785
|
+
patch(patch, fromCard = false) {
|
|
1786
|
+
const next = { ...this.user };
|
|
1787
|
+
for (const [field, value] of Object.entries(patch)) if (value === null) delete next[field];
|
|
1788
|
+
else next[field] = value;
|
|
1789
|
+
if (fromCard) next[WRITE_MARK] = (typeof next[WRITE_MARK] === "number" ? next[WRITE_MARK] : 0) + 1;
|
|
1790
|
+
writeSettings(next);
|
|
1791
|
+
for (const key of Object.keys(this.user)) delete this.user[key];
|
|
1792
|
+
Object.assign(this.user, next);
|
|
1793
|
+
return this.user;
|
|
1794
|
+
}
|
|
1795
|
+
/** The current user layer. */
|
|
1796
|
+
values() {
|
|
1797
|
+
return this.user;
|
|
1798
|
+
}
|
|
1799
|
+
};
|
|
1800
|
+
//#endregion
|
|
1510
1801
|
//#region src/index.ts
|
|
1511
1802
|
/** Stable Cordis plugin name. */
|
|
1512
1803
|
const name = "llm-qoder";
|
|
@@ -1586,21 +1877,99 @@ function credentialPollMs() {
|
|
|
1586
1877
|
* stay in proportion under the `DSH_QODER_POLL_MS` override.
|
|
1587
1878
|
*/
|
|
1588
1879
|
const CATALOG_RETRY_SWEEPS = 10;
|
|
1880
|
+
/**
|
|
1881
|
+
* Mark one configuration field as a live projected setting, when the host has
|
|
1882
|
+
* that concept at all.
|
|
1883
|
+
*
|
|
1884
|
+
* DSH 0.1.7 projects a `.volatile()` field into the profile's settings form and
|
|
1885
|
+
* hands the plugin a STABLE REFERENCE instead of the value; a field without the
|
|
1886
|
+
* mark never reaches the form — `dsh-settings`' `describe()` skips an entry
|
|
1887
|
+
* whose schema has no volatile field (`lib/index.js:411-421`), which is exactly
|
|
1888
|
+
* what makes the browser-side `configForms` namespace exist. DSH 0.1.5's
|
|
1889
|
+
* schemastery has no `volatile` at all, so calling it there is a `TypeError`
|
|
1890
|
+
* that would take the whole plugin down with it. The method is therefore
|
|
1891
|
+
* probed, never assumed.
|
|
1892
|
+
*
|
|
1893
|
+
* Only TOP-LEVEL fields are marked: a volatile field may not sit inside another
|
|
1894
|
+
* volatile field, and schemastery rejects a volatile node reached through a
|
|
1895
|
+
* collection (`validateVolatileSchema`), while the 0.1.7 client writes a single
|
|
1896
|
+
* path segment (`ConfigFormController.set` → `path: [field]`). Every field this
|
|
1897
|
+
* plugin exposes is already a top-level member of {@link Config}, so marking
|
|
1898
|
+
* the field itself is both legal and the only granularity the write path can
|
|
1899
|
+
* address.
|
|
1900
|
+
*
|
|
1901
|
+
* @param field - the field's schema.
|
|
1902
|
+
* @returns the projected schema on a host that supports projection, else the field itself.
|
|
1903
|
+
*/
|
|
1904
|
+
function volatileField(field) {
|
|
1905
|
+
const probe = field;
|
|
1906
|
+
if (typeof probe.volatile === "function") return probe.volatile();
|
|
1907
|
+
if (typeof probe.extra === "function") return probe.extra("volatile", true);
|
|
1908
|
+
if (probe && typeof probe === "object") {
|
|
1909
|
+
probe.meta = {
|
|
1910
|
+
...probe.meta,
|
|
1911
|
+
volatile: true
|
|
1912
|
+
};
|
|
1913
|
+
return probe;
|
|
1914
|
+
}
|
|
1915
|
+
return field;
|
|
1916
|
+
}
|
|
1917
|
+
/**
|
|
1918
|
+
* Read one configuration field through the volatile indirection.
|
|
1919
|
+
*
|
|
1920
|
+
* On DSH 0.1.7 a `.volatile()` field arrives as a reference object whose value
|
|
1921
|
+
* is read with `.get()` (`@deepseek-ai/dsh-agent-default-model`,
|
|
1922
|
+
* `lib/index.js:38-41`); on DSH 0.1.5 the plain value arrives directly. Every
|
|
1923
|
+
* read of a projected field goes through here, so both hosts yield the value
|
|
1924
|
+
* itself.
|
|
1925
|
+
*
|
|
1926
|
+
* @param config - the raw plugin configuration, in either host's shape.
|
|
1927
|
+
* @param field - field name inside the configuration object.
|
|
1928
|
+
* @returns the field's value, or undefined when the field is absent.
|
|
1929
|
+
*/
|
|
1930
|
+
function readField(config, field) {
|
|
1931
|
+
const value = config?.[field];
|
|
1932
|
+
return value !== null && typeof value === "object" && typeof value.get === "function" ? value.get() : value;
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Read one declared field with its declared type.
|
|
1936
|
+
*
|
|
1937
|
+
* The typed face of {@link readField}, for the few call sites that want a
|
|
1938
|
+
* single field rather than a whole {@link readConfig}.
|
|
1939
|
+
*
|
|
1940
|
+
* @param config - the raw plugin configuration, in either host's shape.
|
|
1941
|
+
* @param field - field name inside the configuration object.
|
|
1942
|
+
* @returns the field's plain value, or undefined when it is absent.
|
|
1943
|
+
*/
|
|
1944
|
+
function readConfigField(config, field) {
|
|
1945
|
+
return readField(config, field);
|
|
1946
|
+
}
|
|
1589
1947
|
/** Probe authorization (shared by the plugin schema and the China section). */
|
|
1590
|
-
const PROBE_CONSENT_FIELD = z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)");
|
|
1591
|
-
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)");
|
|
1948
|
+
const PROBE_CONSENT_FIELD = volatileField(z.boolean().default(false).description("Authorize reasoning-effort probes (each probe sends real requests that may consume credit)"));
|
|
1949
|
+
const MAXIMUM_CONTEXT_WINDOW_FIELD = volatileField(z.boolean().default(true).description("Use the largest context window declared by Qoder Global when alternatives are available (on by default)"));
|
|
1592
1950
|
/** The China variant's own maximum-window preference. */
|
|
1593
|
-
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)");
|
|
1951
|
+
const MAXIMUM_CONTEXT_WINDOW_CN_FIELD = volatileField(z.boolean().default(true).description("Use the largest context window declared by Qoder (China) when alternatives are available (on by default)"));
|
|
1594
1952
|
/**
|
|
1595
1953
|
* Per-model window overrides (model id → tokens). The card writes one entry
|
|
1596
1954
|
* per selector change; the field is optional so an absent map means "no
|
|
1597
1955
|
* overrides" rather than a default object the host must keep in sync.
|
|
1598
1956
|
*/
|
|
1599
|
-
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)");
|
|
1957
|
+
const MODEL_CONTEXT_WINDOWS_FIELD = volatileField(z.dict(z.number().min(1), z.string()).description("Per-model context-window overrides for Qoder Global (model id → tokens)"));
|
|
1958
|
+
/** Disabled models list (blacklist) */
|
|
1959
|
+
const DISABLED_MODELS_FIELD = volatileField(z.array(z.string()).default([]).description("Models hidden from the provider catalog and picker"));
|
|
1600
1960
|
/** Sidebar quota toggle (one per variant; both live on the shared quota card). */
|
|
1601
|
-
const QUOTA_TOGGLE_FIELD = z.boolean().default(false).description("Show this variant’s remaining-credit card in the sidebar footer (off by default)");
|
|
1961
|
+
const QUOTA_TOGGLE_FIELD = volatileField(z.boolean().default(false).description("Show this variant’s remaining-credit card in the sidebar footer (off by default)"));
|
|
1602
1962
|
/** Automatic check-in toggle. */
|
|
1603
|
-
const AUTO_CHECK_IN_FIELD = z.boolean().default(false).description("
|
|
1963
|
+
const AUTO_CHECK_IN_FIELD = volatileField(z.boolean().default(false).description("每天自动签到领取算力额度(默认关闭)"));
|
|
1964
|
+
/**
|
|
1965
|
+
* When a variant checks in, as minutes past midnight in UTC+8.
|
|
1966
|
+
*
|
|
1967
|
+
* Stored as a plain minute count rather than a "HH:mm" string so the schema
|
|
1968
|
+
* itself rejects an impossible time: a browser time input yields 0–1439, and
|
|
1969
|
+
* anything outside that range fails Host validation instead of silently
|
|
1970
|
+
* scheduling a request at a moment that never arrives.
|
|
1971
|
+
*/
|
|
1972
|
+
const CHECK_IN_MINUTE_FIELD = volatileField(z.number().default(600).min(0).max(1439).description("每日自动签到的时刻(自 UTC+8 午夜起的分钟数,600 = 10:00)"));
|
|
1604
1973
|
/**
|
|
1605
1974
|
* Quota poll interval: default 5 minutes, floor 1 minute. The status route
|
|
1606
1975
|
* performs a live upstream billing call per request with no cache, so an
|
|
@@ -1610,52 +1979,106 @@ const AUTO_CHECK_IN_FIELD = z.boolean().default(false).description("每天 10:00
|
|
|
1610
1979
|
*/
|
|
1611
1980
|
const QUOTA_POLL_DEFAULT_MS = 3e5;
|
|
1612
1981
|
const QUOTA_POLL_MIN_MS = 6e4;
|
|
1613
|
-
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)");
|
|
1982
|
+
const QUOTA_POLL_FIELD = volatileField(z.number().default(QUOTA_POLL_DEFAULT_MS).min(QUOTA_POLL_MIN_MS).description("Sidebar quota card refresh interval in milliseconds (default 300000, minimum 60000)"));
|
|
1614
1983
|
const Config = z.object({
|
|
1615
1984
|
probeConsent: PROBE_CONSENT_FIELD,
|
|
1616
1985
|
useMaximumContextWindow: MAXIMUM_CONTEXT_WINDOW_FIELD,
|
|
1617
1986
|
useMaximumContextWindowCN: MAXIMUM_CONTEXT_WINDOW_CN_FIELD,
|
|
1618
1987
|
modelContextWindows: MODEL_CONTEXT_WINDOWS_FIELD,
|
|
1619
1988
|
modelContextWindowsCN: MODEL_CONTEXT_WINDOWS_FIELD,
|
|
1989
|
+
disabledModels: DISABLED_MODELS_FIELD,
|
|
1990
|
+
disabledModelsCN: DISABLED_MODELS_FIELD,
|
|
1620
1991
|
sidebarQuotaCN: QUOTA_TOGGLE_FIELD,
|
|
1621
1992
|
sidebarQuotaGlobal: QUOTA_TOGGLE_FIELD,
|
|
1622
1993
|
autoCheckInCN: AUTO_CHECK_IN_FIELD,
|
|
1623
1994
|
autoCheckInGlobal: AUTO_CHECK_IN_FIELD,
|
|
1995
|
+
checkInMinuteCN: CHECK_IN_MINUTE_FIELD,
|
|
1996
|
+
checkInMinuteGlobal: CHECK_IN_MINUTE_FIELD,
|
|
1624
1997
|
quotaPollMs: QUOTA_POLL_FIELD
|
|
1625
1998
|
});
|
|
1626
|
-
|
|
1627
|
-
* The China card's settings section: only the fields that card edits.
|
|
1628
|
-
*
|
|
1629
|
-
* A section is what makes its namespace "served", which is what the Plugins
|
|
1630
|
-
* tab dispatches a card by — so the schema and the card must stay split the
|
|
1631
|
-
* same way. `probeConsent` lives here because it predates the second variant;
|
|
1632
|
-
* it gates no current code path (only manual, per-click-confirmed probes run),
|
|
1633
|
-
* so it is left where existing users set it rather than moved and re-asked.
|
|
1634
|
-
*/
|
|
1635
|
-
const CHINA_SECTION = z.object({
|
|
1999
|
+
z.object({
|
|
1636
2000
|
probeConsent: PROBE_CONSENT_FIELD,
|
|
1637
2001
|
useMaximumContextWindowCN: MAXIMUM_CONTEXT_WINDOW_CN_FIELD,
|
|
1638
|
-
modelContextWindowsCN: MODEL_CONTEXT_WINDOWS_FIELD
|
|
2002
|
+
modelContextWindowsCN: MODEL_CONTEXT_WINDOWS_FIELD,
|
|
2003
|
+
disabledModelsCN: DISABLED_MODELS_FIELD
|
|
1639
2004
|
});
|
|
1640
|
-
|
|
1641
|
-
const GLOBAL_SECTION = z.object({
|
|
2005
|
+
z.object({
|
|
1642
2006
|
useMaximumContextWindow: MAXIMUM_CONTEXT_WINDOW_FIELD,
|
|
1643
|
-
modelContextWindows: MODEL_CONTEXT_WINDOWS_FIELD
|
|
2007
|
+
modelContextWindows: MODEL_CONTEXT_WINDOWS_FIELD,
|
|
2008
|
+
disabledModels: DISABLED_MODELS_FIELD
|
|
1644
2009
|
});
|
|
1645
|
-
|
|
1646
|
-
* The shared quota card's section: both sidebar toggles and the poll interval.
|
|
1647
|
-
*
|
|
1648
|
-
* Only these fields — the card edits nothing else, and the Plugins tab pairs a
|
|
1649
|
-
* card with the section whose namespace it names, so a stray field here would
|
|
1650
|
-
* render as a control no other surface reads.
|
|
1651
|
-
*/
|
|
1652
|
-
const QUOTA_SECTION = z.object({
|
|
2010
|
+
z.object({
|
|
1653
2011
|
sidebarQuotaCN: QUOTA_TOGGLE_FIELD,
|
|
1654
2012
|
sidebarQuotaGlobal: QUOTA_TOGGLE_FIELD,
|
|
1655
2013
|
autoCheckInCN: AUTO_CHECK_IN_FIELD,
|
|
1656
2014
|
autoCheckInGlobal: AUTO_CHECK_IN_FIELD,
|
|
2015
|
+
checkInMinuteCN: CHECK_IN_MINUTE_FIELD,
|
|
2016
|
+
checkInMinuteGlobal: CHECK_IN_MINUTE_FIELD,
|
|
1657
2017
|
quotaPollMs: QUOTA_POLL_FIELD
|
|
1658
2018
|
});
|
|
2019
|
+
/**
|
|
2020
|
+
* Every field each settings section owns, and therefore every field the live
|
|
2021
|
+
* configuration has to carry through.
|
|
2022
|
+
*
|
|
2023
|
+
* One list per section, shared by the merge and by the test that pins it to
|
|
2024
|
+
* the schema. Writing the merge out by hand is what broke auto check-in: the
|
|
2025
|
+
* `qoder-quota` section grew four fields (`autoCheckInCN`, `autoCheckInGlobal`,
|
|
2026
|
+
* `checkInMinuteCN`, `checkInMinuteGlobal`) while the merge kept copying only
|
|
2027
|
+
* the three that predated them, so `current().autoCheckInCN` read `undefined`
|
|
2028
|
+
* forever and the scheduler saw the toggle as permanently off. The card saved
|
|
2029
|
+
* it, the file held it, and nothing ever acted on it.
|
|
2030
|
+
*/
|
|
2031
|
+
const CN_SECTION_KEYS = [
|
|
2032
|
+
"probeConsent",
|
|
2033
|
+
"useMaximumContextWindowCN",
|
|
2034
|
+
"modelContextWindowsCN",
|
|
2035
|
+
"disabledModelsCN"
|
|
2036
|
+
];
|
|
2037
|
+
const GLOBAL_SECTION_KEYS = [
|
|
2038
|
+
"useMaximumContextWindow",
|
|
2039
|
+
"modelContextWindows",
|
|
2040
|
+
"disabledModels"
|
|
2041
|
+
];
|
|
2042
|
+
const QUOTA_SECTION_KEYS = [
|
|
2043
|
+
"sidebarQuotaCN",
|
|
2044
|
+
"sidebarQuotaGlobal",
|
|
2045
|
+
"autoCheckInCN",
|
|
2046
|
+
"autoCheckInGlobal",
|
|
2047
|
+
"checkInMinuteCN",
|
|
2048
|
+
"checkInMinuteGlobal",
|
|
2049
|
+
"quotaPollMs"
|
|
2050
|
+
];
|
|
2051
|
+
/** Every declared configuration field, in the order the sections are merged. */
|
|
2052
|
+
const CONFIG_KEYS = [
|
|
2053
|
+
...CN_SECTION_KEYS,
|
|
2054
|
+
...GLOBAL_SECTION_KEYS,
|
|
2055
|
+
...QUOTA_SECTION_KEYS
|
|
2056
|
+
];
|
|
2057
|
+
/** Copy the declared fields off one section's source, skipping absent ones. */
|
|
2058
|
+
function pickFields(source, keys) {
|
|
2059
|
+
const value = source();
|
|
2060
|
+
const picked = {};
|
|
2061
|
+
for (const key of keys) {
|
|
2062
|
+
const field = readField(value, key);
|
|
2063
|
+
if (field !== void 0) picked[key] = field;
|
|
2064
|
+
}
|
|
2065
|
+
return picked;
|
|
2066
|
+
}
|
|
2067
|
+
/**
|
|
2068
|
+
* Unwrap a whole configuration object into plain values.
|
|
2069
|
+
*
|
|
2070
|
+
* The merged configuration is read through {@link pickFields}/{@link readField}
|
|
2071
|
+
* everywhere else, but the plugin also reads its initial config directly while
|
|
2072
|
+
* it assembles its runtimes — before any settings section has handed over a
|
|
2073
|
+
* source. On 0.1.7 those direct reads would otherwise compare a reference
|
|
2074
|
+
* object against `true`/`undefined` and silently see "not set" for every field.
|
|
2075
|
+
*
|
|
2076
|
+
* @param config - the raw plugin configuration, in either host's shape.
|
|
2077
|
+
* @returns the same configuration with every declared field read out.
|
|
2078
|
+
*/
|
|
2079
|
+
function readConfig(config) {
|
|
2080
|
+
return pickFields(() => config, CONFIG_KEYS);
|
|
2081
|
+
}
|
|
1659
2082
|
/** Stable identity key used by credentials, probe records, and catalog entries. */
|
|
1660
2083
|
const credentialIdentity = qoderCredentialIdentity;
|
|
1661
2084
|
/** The settings namespace a variant's card and provider directory entry use. */
|
|
@@ -1701,10 +2124,23 @@ function createVariantRuntime(ctx, config, variant, current, identityOf) {
|
|
|
1701
2124
|
logger: ctx.logger
|
|
1702
2125
|
});
|
|
1703
2126
|
let jobTokenRefreshedAt;
|
|
2127
|
+
const resolveAttachmentService = () => {
|
|
2128
|
+
const service = ctx.get("attachments");
|
|
2129
|
+
if (service === void 0) throw new Error("dsh-qoder-connect: no attachment service is available");
|
|
2130
|
+
return service;
|
|
2131
|
+
};
|
|
2132
|
+
const attachments = {
|
|
2133
|
+
get imageLimits() {
|
|
2134
|
+
return resolveAttachmentService().imageLimits;
|
|
2135
|
+
},
|
|
2136
|
+
readImageRequest: async (attachment, policy, signal) => await resolveAttachmentService().readImageRequest(attachment, policy, signal),
|
|
2137
|
+
saveImage: async (request) => await resolveAttachmentService().saveImage(request)
|
|
2138
|
+
};
|
|
1704
2139
|
const transport = createQoderTransport({
|
|
1705
2140
|
region: variant.region,
|
|
1706
2141
|
resolvePat: () => store.patPromise(),
|
|
1707
2142
|
resolveMachineId: () => getMachineId([qoderMachineIdPath()]),
|
|
2143
|
+
attachments,
|
|
1708
2144
|
onJobTokenRefreshed: (info) => {
|
|
1709
2145
|
jobTokenRefreshedAt = info.at;
|
|
1710
2146
|
ctx.logger.warn(`dsh-qoder-connect: ${variant.displayName} job token was auto-refreshed after an upstream rejection`);
|
|
@@ -1720,18 +2156,18 @@ function createVariantRuntime(ctx, config, variant, current, identityOf) {
|
|
|
1720
2156
|
providerId: variant.id,
|
|
1721
2157
|
getPat: () => store.patPromise(),
|
|
1722
2158
|
transport,
|
|
1723
|
-
attachments
|
|
1724
|
-
const service = ctx.get("attachments");
|
|
1725
|
-
if (service === void 0) throw new Error("dsh-qoder-connect: no attachment service is available to store images");
|
|
1726
|
-
return await service.saveImage(request);
|
|
1727
|
-
} }
|
|
2159
|
+
attachments
|
|
1728
2160
|
});
|
|
1729
2161
|
const fallback = fallbackFor(variant);
|
|
1730
2162
|
const catalog = new QoderCatalog(fallback);
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
catalog.setUseMaximumContextWindow(
|
|
1734
|
-
if (
|
|
2163
|
+
const initial = current();
|
|
2164
|
+
if (variant.id !== CHINA_VARIANT.id) {
|
|
2165
|
+
catalog.setUseMaximumContextWindow(initial.useMaximumContextWindow === true);
|
|
2166
|
+
if (initial.disabledModels !== void 0) catalog.setDisabledModels(initial.disabledModels);
|
|
2167
|
+
} else {
|
|
2168
|
+
catalog.setUseMaximumContextWindow(initial.useMaximumContextWindowCN === true);
|
|
2169
|
+
if (initial.modelContextWindowsCN !== void 0) catalog.setModelContextWindows(initial.modelContextWindowsCN);
|
|
2170
|
+
if (initial.disabledModelsCN !== void 0) catalog.setDisabledModels(initial.disabledModelsCN);
|
|
1735
2171
|
}
|
|
1736
2172
|
catalog.setVisible(false);
|
|
1737
2173
|
const probeStore = new QoderProbeStore({
|
|
@@ -1764,7 +2200,8 @@ function createVariantRuntime(ctx, config, variant, current, identityOf) {
|
|
|
1764
2200
|
inflightFetch: void 0,
|
|
1765
2201
|
invalidate: () => {},
|
|
1766
2202
|
registered: false,
|
|
1767
|
-
jobTokenRefreshedAt: () => jobTokenRefreshedAt
|
|
2203
|
+
jobTokenRefreshedAt: () => jobTokenRefreshedAt,
|
|
2204
|
+
checkIn: (signal) => transport.checkIn(signal)
|
|
1768
2205
|
};
|
|
1769
2206
|
}
|
|
1770
2207
|
/** The catalog provenance the card displays. */
|
|
@@ -1833,9 +2270,11 @@ function detach(ctx, work, what) {
|
|
|
1833
2270
|
* models read the shim origin at construction time. A failure here is
|
|
1834
2271
|
* contained to this variant: the caller logs it and the other keeps working.
|
|
1835
2272
|
*
|
|
2273
|
+
* @param seedCatalog - adopt the current credential into this runtime's
|
|
2274
|
+
* catalog immediately; supplied by `apply` (it owns `adoptIdentity`).
|
|
1836
2275
|
* @returns whether the provider registered.
|
|
1837
2276
|
*/
|
|
1838
|
-
async function startVariant(ctx, runtime) {
|
|
2277
|
+
async function startVariant(ctx, runtime, seedCatalog) {
|
|
1839
2278
|
const { variant, store, client, catalog, probeService } = runtime;
|
|
1840
2279
|
const shim = createQoderShim({
|
|
1841
2280
|
store,
|
|
@@ -1868,10 +2307,19 @@ async function startVariant(ctx, runtime) {
|
|
|
1868
2307
|
let releaseDirectory;
|
|
1869
2308
|
try {
|
|
1870
2309
|
releaseAdapter = ctx.llm.registerAdapter([variant.id], qoder.adapter);
|
|
2310
|
+
const host017 = (() => {
|
|
2311
|
+
try {
|
|
2312
|
+
return ctx.get?.("configEditor") !== void 0;
|
|
2313
|
+
} catch {
|
|
2314
|
+
return false;
|
|
2315
|
+
}
|
|
2316
|
+
})();
|
|
2317
|
+
const entryId = ctx.fiber?.entry?.options?.id;
|
|
2318
|
+
const settingsNs = host017 && entryId !== void 0 ? entryId : settingsNamespaceFor(variant);
|
|
1871
2319
|
releaseDirectory = ctx.llm.registerConfigurableProviders([{
|
|
1872
2320
|
provider: variant.id,
|
|
1873
2321
|
displayName: variant.displayName,
|
|
1874
|
-
settingsNs
|
|
2322
|
+
settingsNs,
|
|
1875
2323
|
settingsPath: [],
|
|
1876
2324
|
declared: false
|
|
1877
2325
|
}]);
|
|
@@ -1893,6 +2341,7 @@ async function startVariant(ctx, runtime) {
|
|
|
1893
2341
|
detach(ctx, shim.close(), "loopback endpoint close");
|
|
1894
2342
|
}
|
|
1895
2343
|
runtime.registered = true;
|
|
2344
|
+
detach(ctx, seedCatalog(), `${variant.id} catalog seed`);
|
|
1896
2345
|
return true;
|
|
1897
2346
|
} catch (error) {
|
|
1898
2347
|
ctx.logger.error(`dsh-qoder-connect: ${variant.displayName} provider registration failed`, error);
|
|
@@ -1901,6 +2350,305 @@ async function startVariant(ctx, runtime) {
|
|
|
1901
2350
|
}
|
|
1902
2351
|
}
|
|
1903
2352
|
/**
|
|
2353
|
+
* State that MUST survive a fiber reload, module-level on purpose.
|
|
2354
|
+
*
|
|
2355
|
+
* DSH 0.1.7's configuration write (`configEditor.edit`) reconciles the profile
|
|
2356
|
+
* tree, which hot-reloads the entry's fiber — `apply()` runs again with a fresh
|
|
2357
|
+
* closure. Anything re-minted per apply is therefore invalidated by every
|
|
2358
|
+
* settings write: the browser cards hold the keys the status document handed
|
|
2359
|
+
* them, so a per-apply key turns each write into a wave of 403s ("刷新失败"),
|
|
2360
|
+
* and a per-apply identity map makes the sweep re-fetch the catalog from
|
|
2361
|
+
* upstream on every write (the burst of requests and most of the latency).
|
|
2362
|
+
*
|
|
2363
|
+
* Keys and identity/fetch bookkeeping are per-PROCESS secrets and caches, not
|
|
2364
|
+
* per-instance state, so they live here once. A reload keeps the same keys (no
|
|
2365
|
+
* 403 storm) and a same-account, freshly-fetched sweep (no refetch burst).
|
|
2366
|
+
*/
|
|
2367
|
+
/** The in-process control keys, minted once per process. */
|
|
2368
|
+
let processKeys;
|
|
2369
|
+
function controlKeys() {
|
|
2370
|
+
processKeys ??= {
|
|
2371
|
+
probe: createProbeKey(),
|
|
2372
|
+
auth: createAuthKey()
|
|
2373
|
+
};
|
|
2374
|
+
return processKeys;
|
|
2375
|
+
}
|
|
2376
|
+
/** The loopback guard the probe and status routes use, restated for settings. */
|
|
2377
|
+
function trustedSettingsRequest(req) {
|
|
2378
|
+
const host = req.headers.host ?? "";
|
|
2379
|
+
const origin = req.headers.origin;
|
|
2380
|
+
if (!/^(localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/i.test(String(host))) return false;
|
|
2381
|
+
return origin === void 0 || /^https?:\/\/(localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?$/i.test(String(origin));
|
|
2382
|
+
}
|
|
2383
|
+
/**
|
|
2384
|
+
* The schema defaults, READ off each field's own `meta.default`.
|
|
2385
|
+
*
|
|
2386
|
+
* Two ways this was got wrong before, both silent and both destructive: a
|
|
2387
|
+
* hand-written table drifted from the schema (so the schema's own defaults
|
|
2388
|
+
* were classified as real overrides and the legacy settings document could
|
|
2389
|
+
* never win), and `validate({})` was then tried instead — which on this
|
|
2390
|
+
* schemastery answers with hollow `{}` per field instead of applying the
|
|
2391
|
+
* defaults, which is worse than nothing because it looks like it worked.
|
|
2392
|
+
* Walking `Config.dict` for `meta.default` is the only honest source: it is
|
|
2393
|
+
* the value schemastery itself substituted during validation.
|
|
2394
|
+
*/
|
|
2395
|
+
const DEFAULT_FOR_FIELD = (() => {
|
|
2396
|
+
const out = {};
|
|
2397
|
+
try {
|
|
2398
|
+
for (const [key, field] of Object.entries(Config.dict ?? {})) {
|
|
2399
|
+
const fallback = field?.meta?.default;
|
|
2400
|
+
if (fallback !== void 0) out[key] = fallback;
|
|
2401
|
+
}
|
|
2402
|
+
} catch {}
|
|
2403
|
+
if (Object.keys(out).length === 0) return {
|
|
2404
|
+
probeConsent: false,
|
|
2405
|
+
useMaximumContextWindow: true,
|
|
2406
|
+
useMaximumContextWindowCN: true,
|
|
2407
|
+
modelContextWindows: {},
|
|
2408
|
+
modelContextWindowsCN: {},
|
|
2409
|
+
disabledModels: [],
|
|
2410
|
+
disabledModelsCN: [],
|
|
2411
|
+
sidebarQuotaCN: false,
|
|
2412
|
+
sidebarQuotaGlobal: false,
|
|
2413
|
+
autoCheckInCN: false,
|
|
2414
|
+
autoCheckInGlobal: false,
|
|
2415
|
+
checkInMinuteCN: 600,
|
|
2416
|
+
checkInMinuteGlobal: 600,
|
|
2417
|
+
quotaPollMs: 3e5
|
|
2418
|
+
};
|
|
2419
|
+
return out;
|
|
2420
|
+
})();
|
|
2421
|
+
/**
|
|
2422
|
+
* Register the settings face (GET/POST) the browser cards read and write
|
|
2423
|
+
* through, answering the whole entry configuration as three layers.
|
|
2424
|
+
*
|
|
2425
|
+
* `value` carries every declared field, so a card rendering the quota section
|
|
2426
|
+
* sees the same merged view the host itself reads; `user` is the settings file
|
|
2427
|
+
* exactly as stored (presence marks an override), which is what the staged-edit
|
|
2428
|
+
* form's "written/reset" display needs. A POST validates the fields it is
|
|
2429
|
+
* allowed to touch, writes the file, applies the new view in memory, and
|
|
2430
|
+
* re-arms the check-in scheduler (the old 0.1.5 section `onChange` behaviour).
|
|
2431
|
+
*/
|
|
2432
|
+
function registerQoderSettingsFace(ctx, deps) {
|
|
2433
|
+
const { store, config, current, apply, rearm } = deps;
|
|
2434
|
+
const key = controlKeys().probe;
|
|
2435
|
+
/** The document both routes answer with. */
|
|
2436
|
+
const view = () => {
|
|
2437
|
+
const merged = current();
|
|
2438
|
+
const value = {};
|
|
2439
|
+
const base = {};
|
|
2440
|
+
for (const field of CONFIG_KEYS) {
|
|
2441
|
+
if (merged[field] !== void 0) value[field] = merged[field];
|
|
2442
|
+
base[field] = readConfig(config)[field] ?? DEFAULT_FOR_FIELD[field];
|
|
2443
|
+
}
|
|
2444
|
+
return {
|
|
2445
|
+
key,
|
|
2446
|
+
value,
|
|
2447
|
+
base,
|
|
2448
|
+
user: store.values()
|
|
2449
|
+
};
|
|
2450
|
+
};
|
|
2451
|
+
ctx.effect(() => {
|
|
2452
|
+
const dispose = ctx.webServer.register({
|
|
2453
|
+
kind: "exact",
|
|
2454
|
+
path: QODER_SETTINGS_FACE_PATH,
|
|
2455
|
+
handler: async (req, res) => {
|
|
2456
|
+
if (!trustedSettingsRequest(req)) {
|
|
2457
|
+
jsonFace(res, 403, { error: "request-not-trusted" });
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
if (req.method === "GET") {
|
|
2461
|
+
jsonFace(res, 200, view());
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
if (req.method !== "POST") {
|
|
2465
|
+
jsonFace(res, 405, { error: "method not allowed" });
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
if (req.headers["x-qoder-settings-key"] !== key) {
|
|
2469
|
+
jsonFace(res, 403, { error: "invalid-key" });
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
const body = await readFaceBody(req);
|
|
2473
|
+
if (body === void 0) {
|
|
2474
|
+
jsonFace(res, 413, { error: "body too large" });
|
|
2475
|
+
return;
|
|
2476
|
+
}
|
|
2477
|
+
let patch;
|
|
2478
|
+
try {
|
|
2479
|
+
patch = JSON.parse(body || "{}");
|
|
2480
|
+
} catch {
|
|
2481
|
+
jsonFace(res, 400, { error: "invalid json" });
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
if (patch === null || typeof patch !== "object" || Array.isArray(patch)) {
|
|
2485
|
+
jsonFace(res, 400, { error: "invalid patch" });
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
const invalid = validateSettingsPatch(patch);
|
|
2489
|
+
if (invalid !== void 0) {
|
|
2490
|
+
jsonFace(res, 400, { error: invalid });
|
|
2491
|
+
return;
|
|
2492
|
+
}
|
|
2493
|
+
store.patch(patch, true);
|
|
2494
|
+
apply(current());
|
|
2495
|
+
rearm();
|
|
2496
|
+
jsonFace(res, 200, view());
|
|
2497
|
+
}
|
|
2498
|
+
});
|
|
2499
|
+
return () => {
|
|
2500
|
+
dispose();
|
|
2501
|
+
};
|
|
2502
|
+
}, "dsh-qoder-connect: settings face");
|
|
2503
|
+
}
|
|
2504
|
+
/** JSON response helper for the settings face. */
|
|
2505
|
+
function jsonFace(res, status, body) {
|
|
2506
|
+
const payload = JSON.stringify(body);
|
|
2507
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
2508
|
+
res.end(payload);
|
|
2509
|
+
}
|
|
2510
|
+
/** Read the request body, or undefined when absent or oversized. */
|
|
2511
|
+
function readFaceBody(req) {
|
|
2512
|
+
return new Promise((resolve) => {
|
|
2513
|
+
let body = "";
|
|
2514
|
+
req.on("data", (chunk) => {
|
|
2515
|
+
body += String(chunk);
|
|
2516
|
+
if (body.length > 1e6) resolve(void 0);
|
|
2517
|
+
});
|
|
2518
|
+
req.on("end", () => resolve(body));
|
|
2519
|
+
req.on("error", () => resolve(void 0));
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
/** Schema-level validation of one settings patch; the reason, or undefined. */
|
|
2523
|
+
function validateSettingsPatch(patch) {
|
|
2524
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
2525
|
+
if (!CONFIG_KEYS.includes(field)) return `unknown field ${field}`;
|
|
2526
|
+
if (value === null) continue;
|
|
2527
|
+
switch (field) {
|
|
2528
|
+
case "probeConsent":
|
|
2529
|
+
case "useMaximumContextWindow":
|
|
2530
|
+
case "useMaximumContextWindowCN":
|
|
2531
|
+
case "sidebarQuotaCN":
|
|
2532
|
+
case "sidebarQuotaGlobal":
|
|
2533
|
+
case "autoCheckInCN":
|
|
2534
|
+
case "autoCheckInGlobal":
|
|
2535
|
+
if (typeof value !== "boolean") return `${field} must be a boolean`;
|
|
2536
|
+
break;
|
|
2537
|
+
case "disabledModels":
|
|
2538
|
+
case "disabledModelsCN":
|
|
2539
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) return `${field} must be an array of strings`;
|
|
2540
|
+
break;
|
|
2541
|
+
case "checkInMinuteCN":
|
|
2542
|
+
case "checkInMinuteGlobal":
|
|
2543
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > 1439) return `${field} must be an integer minute 0..1439`;
|
|
2544
|
+
break;
|
|
2545
|
+
case "quotaPollMs":
|
|
2546
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 6e4) return `${field} must be an integer of at least 60000 ms`;
|
|
2547
|
+
break;
|
|
2548
|
+
case "modelContextWindows":
|
|
2549
|
+
case "modelContextWindowsCN": if (typeof value !== "object" || value === null || Array.isArray(value)) return `${field} must be an object`;
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
/**
|
|
2554
|
+
* When the profile tree last recomposed, module-level so every apply sees it.
|
|
2555
|
+
*
|
|
2556
|
+
* The legacy settings.yaml import writes the profile patch one section at a
|
|
2557
|
+
* time for seconds after the Loader settles, emitting this event on every
|
|
2558
|
+
* write. Cleaning up our row in that window is futile — the import's next
|
|
2559
|
+
* section simply writes it back — so the cleanup waits for the tree to fall
|
|
2560
|
+
* quiet first.
|
|
2561
|
+
*/
|
|
2562
|
+
let lastConfigReloadAt = Date.now();
|
|
2563
|
+
/** Wait until the profile tree has been quiet (the legacy import finished). */
|
|
2564
|
+
async function waitForProfileQuiet() {
|
|
2565
|
+
const deadline = Date.now() + 12e4;
|
|
2566
|
+
for (;;) {
|
|
2567
|
+
if (Date.now() - lastConfigReloadAt >= 5e3) return;
|
|
2568
|
+
if (Date.now() >= deadline) return;
|
|
2569
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
/**
|
|
2573
|
+
* Delete this plugin's own fields from the profile entry config, leaving every
|
|
2574
|
+
* other key of the row untouched.
|
|
2575
|
+
*
|
|
2576
|
+
* One-time, right after the settings file has been seeded: the entry returns to
|
|
2577
|
+
* its shipped state, so no second source of truth remains. Both write APIs are
|
|
2578
|
+
* probed — `configEditor` (0.1.7) first, then the settings service's namespace
|
|
2579
|
+
* `replace` (0.1.5), which rebuilds each of this plugin's three sections from
|
|
2580
|
+
* the foreign keys alone.
|
|
2581
|
+
*
|
|
2582
|
+
* @param ctx - plugin context.
|
|
2583
|
+
* @param ownKeys - this plugin's declared config fields.
|
|
2584
|
+
* @param namespaces - the 0.1.5 section namespaces this plugin owns.
|
|
2585
|
+
*/
|
|
2586
|
+
async function cleanupEntryConfig(ctx, ownKeys, namespaces) {
|
|
2587
|
+
const attempts = 10;
|
|
2588
|
+
for (let attempt = 0; attempt < attempts; attempt++) try {
|
|
2589
|
+
if (attempt === 0) await waitForProfileQuiet();
|
|
2590
|
+
else await new Promise((resolve) => setTimeout(resolve, 2e3 + Math.random() * 1e3));
|
|
2591
|
+
const probe = ctx;
|
|
2592
|
+
const editor = (typeof probe.get === "function" ? (() => {
|
|
2593
|
+
try {
|
|
2594
|
+
return probe.get.call(ctx, "configEditor");
|
|
2595
|
+
} catch {
|
|
2596
|
+
return;
|
|
2597
|
+
}
|
|
2598
|
+
})() : void 0) ?? await new Promise((resolve) => {
|
|
2599
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
2600
|
+
const owner = settingsCtx.settings?.ownerContext;
|
|
2601
|
+
resolve(typeof owner?.get === "function" ? (() => {
|
|
2602
|
+
try {
|
|
2603
|
+
return owner.get.call(owner, "configEditor");
|
|
2604
|
+
} catch {
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
})() : void 0);
|
|
2608
|
+
});
|
|
2609
|
+
});
|
|
2610
|
+
const entry = probe.fiber?.entry;
|
|
2611
|
+
if (editor !== void 0 && entry !== void 0) {
|
|
2612
|
+
await editor.edit(entry, (raw, inherited) => {
|
|
2613
|
+
const next = { ...inherited ?? {} };
|
|
2614
|
+
for (const [key, value] of Object.entries(raw ?? {})) {
|
|
2615
|
+
if (ownKeys.includes(key)) continue;
|
|
2616
|
+
if (!Object.hasOwn(next, key)) next[key] = value;
|
|
2617
|
+
}
|
|
2618
|
+
return next;
|
|
2619
|
+
});
|
|
2620
|
+
return;
|
|
2621
|
+
}
|
|
2622
|
+
await new Promise((resolve) => {
|
|
2623
|
+
ctx.inject(["settings"], (settingsCtx) => {
|
|
2624
|
+
const settings = settingsCtx.settings;
|
|
2625
|
+
if (typeof settings?.replace !== "function" || typeof settings.installSection !== "function") {
|
|
2626
|
+
resolve(void 0);
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
Promise.all(namespaces.map((ns) => Promise.resolve(settings.replace(ns, {})).catch(() => void 0))).then(() => resolve(void 0));
|
|
2630
|
+
});
|
|
2631
|
+
});
|
|
2632
|
+
return;
|
|
2633
|
+
} catch (error) {
|
|
2634
|
+
if (attempt === 9) {
|
|
2635
|
+
ctx.logger?.warn?.("dsh-qoder-connect: entry config cleanup failed", error);
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1) + Math.random() * 500));
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
/** The account identity each variant last published a catalog for, across reloads. */
|
|
2642
|
+
const lastIdentities = /* @__PURE__ */ new Map();
|
|
2643
|
+
/**
|
|
2644
|
+
* When a variant's catalog was last fetched LIVE from upstream, per identity.
|
|
2645
|
+
*
|
|
2646
|
+
* A reload reseeds the catalog from the saved file; without this record the
|
|
2647
|
+
* sweep's retry rule (`stale && due`) sees `lastFetchAtMs = 0` and re-fetches
|
|
2648
|
+
* immediately — one upstream round trip per variant per settings write.
|
|
2649
|
+
*/
|
|
2650
|
+
const lastLiveFetchAt = /* @__PURE__ */ new Map();
|
|
2651
|
+
/**
|
|
1904
2652
|
* Start both variants: their loopback endpoints, the `qoder` and
|
|
1905
2653
|
* `qoder-global` providers, their configuration cards, and their
|
|
1906
2654
|
* credential-driven catalog lifecycles.
|
|
@@ -1911,23 +2659,82 @@ async function startVariant(ctx, runtime) {
|
|
|
1911
2659
|
* without re-registering the provider.
|
|
1912
2660
|
*/
|
|
1913
2661
|
function apply(ctx, config) {
|
|
1914
|
-
|
|
2662
|
+
const store = new SettingsStore();
|
|
2663
|
+
let current = () => ({
|
|
2664
|
+
...readConfig(config),
|
|
2665
|
+
...store.values()
|
|
2666
|
+
});
|
|
2667
|
+
/**
|
|
2668
|
+
* One-time migration: per field — the file never held it → take the entry;
|
|
2669
|
+
* the card HAS written this file → keep the file; the entry carries a
|
|
2670
|
+
* NON-DEFAULT value → take the entry (the legacy settings.yaml import lands
|
|
2671
|
+
* only after the Loader settles, i.e. after this plugin's first apply, and a
|
|
2672
|
+
* seed taken inside that window can hold a stale or mis-encoded value); the
|
|
2673
|
+
* entry only carries the schema default → keep the file.
|
|
2674
|
+
*
|
|
2675
|
+
* That last clause is load-bearing: a bundle layer's insert config does NOT
|
|
2676
|
+
* reach the composition (verified with `dsh --dump-config`), so once the
|
|
2677
|
+
* one-time cleanup has emptied the entry row the entry answers pure defaults —
|
|
2678
|
+
* treating those as authoritative would erase the user's values on the next
|
|
2679
|
+
* boot. Re-checked on every apply, which is also what closes the legacy
|
|
2680
|
+
* import's timing window.
|
|
2681
|
+
*
|
|
2682
|
+
* When the entry is authoritative, its own fields are then deleted from the
|
|
2683
|
+
* profile row, so no second source of truth remains.
|
|
2684
|
+
*/
|
|
2685
|
+
const migrateOwnSettings = () => {
|
|
2686
|
+
const legacy = readLegacySections([
|
|
2687
|
+
QODER_SETTINGS_NS,
|
|
2688
|
+
QODER_GLOBAL_SETTINGS_NS,
|
|
2689
|
+
QODER_QUOTA_SETTINGS_NS
|
|
2690
|
+
]);
|
|
2691
|
+
const seeded = {};
|
|
2692
|
+
for (const key of CONFIG_KEYS) {
|
|
2693
|
+
const entryValue = readField(config, key);
|
|
2694
|
+
const legacyValue = legacy === void 0 ? void 0 : legacy[key];
|
|
2695
|
+
if (!Object.hasOwn(store.user, key)) {
|
|
2696
|
+
if (entryValue !== void 0 && JSON.stringify(entryValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = entryValue;
|
|
2697
|
+
else if (legacyValue !== void 0 && JSON.stringify(legacyValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = legacyValue;
|
|
2698
|
+
else if (entryValue !== void 0) seeded[key] = entryValue;
|
|
2699
|
+
else if (legacyValue !== void 0) seeded[key] = legacyValue;
|
|
2700
|
+
continue;
|
|
2701
|
+
}
|
|
2702
|
+
if (store.edited) continue;
|
|
2703
|
+
if (entryValue !== void 0 && JSON.stringify(entryValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) {
|
|
2704
|
+
seeded[key] = entryValue;
|
|
2705
|
+
continue;
|
|
2706
|
+
}
|
|
2707
|
+
if (legacyValue !== void 0 && JSON.stringify(legacyValue) !== JSON.stringify(DEFAULT_FOR_FIELD[key])) seeded[key] = legacyValue;
|
|
2708
|
+
}
|
|
2709
|
+
if (Object.keys(seeded).length === 0) return;
|
|
2710
|
+
store.patch(seeded);
|
|
2711
|
+
cleanupEntryConfig(ctx, CONFIG_KEYS, [
|
|
2712
|
+
QODER_SETTINGS_NS,
|
|
2713
|
+
QODER_GLOBAL_SETTINGS_NS,
|
|
2714
|
+
QODER_QUOTA_SETTINGS_NS
|
|
2715
|
+
]);
|
|
2716
|
+
};
|
|
2717
|
+
migrateOwnSettings();
|
|
2718
|
+
const eventSink = ctx;
|
|
2719
|
+
eventSink.on("loader/volatile-update", () => {
|
|
2720
|
+
migrateOwnSettings();
|
|
2721
|
+
});
|
|
2722
|
+
eventSink.on("app-boot/config-reload", () => {
|
|
2723
|
+
lastConfigReloadAt = Date.now();
|
|
2724
|
+
});
|
|
1915
2725
|
/** Timers and in-flight work belonging to this plugin instance. */
|
|
1916
2726
|
let stopped = false;
|
|
1917
2727
|
const timers = [];
|
|
1918
|
-
/**
|
|
1919
|
-
* The account identity each variant last published a catalog for. Keeps a
|
|
1920
|
-
* same-token sweep from re-fetching, and lets a late response from a
|
|
1921
|
-
* previous identity be discarded instead of overwriting a newer one.
|
|
1922
|
-
*/
|
|
1923
|
-
const lastIdentities = /* @__PURE__ */ new Map();
|
|
1924
2728
|
const runtimes = QODER_VARIANTS.map((variant) => createVariantRuntime(ctx, config, variant, () => current(), (id) => lastIdentities.get(id)));
|
|
1925
2729
|
const checkInStore = new JsonFileCheckInStore();
|
|
1926
2730
|
const checkInScheduler = new CheckInScheduler({
|
|
1927
2731
|
targets: runtimes.map((runtime) => ({
|
|
1928
2732
|
variantId: runtime.variant.id,
|
|
1929
|
-
|
|
1930
|
-
|
|
2733
|
+
checkIn: (signal) => runtime.checkIn(signal),
|
|
2734
|
+
minuteOfDay: () => {
|
|
2735
|
+
const cfg = current();
|
|
2736
|
+
return normalizeCheckInMinute((runtime.variant.id === CHINA_VARIANT.id ? cfg.checkInMinuteCN : cfg.checkInMinuteGlobal) ?? 600);
|
|
2737
|
+
},
|
|
1931
2738
|
onClaimed: () => {
|
|
1932
2739
|
runtime.client.fetchCredits().catch(() => void 0);
|
|
1933
2740
|
}
|
|
@@ -1940,20 +2747,26 @@ function apply(ctx, config) {
|
|
|
1940
2747
|
store: checkInStore
|
|
1941
2748
|
});
|
|
1942
2749
|
checkInScheduler.start();
|
|
1943
|
-
installJobTokenHint(ctx);
|
|
1944
|
-
const probeKey = createProbeKey();
|
|
1945
2750
|
/**
|
|
1946
|
-
* The
|
|
1947
|
-
* probe key.
|
|
2751
|
+
* The startup catch-up, run once more from the settings source callback.
|
|
1948
2752
|
*
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
1951
|
-
*
|
|
1952
|
-
*
|
|
2753
|
+
* `start()` above runs while the plugin is still assembling: the section
|
|
2754
|
+
* that stores these toggles has not handed over its values yet, so that
|
|
2755
|
+
* sweep reads the raw plugin config, sees no toggle, and skips the day. The
|
|
2756
|
+
* retry is what makes "boot after 10:00" actually claim.
|
|
1953
2757
|
*/
|
|
1954
|
-
|
|
2758
|
+
let startupCatchUpDone = false;
|
|
2759
|
+
const runStartupCatchUpOnce = () => {
|
|
2760
|
+
if (startupCatchUpDone) return;
|
|
2761
|
+
startupCatchUpDone = true;
|
|
2762
|
+
checkInScheduler.catchUp();
|
|
2763
|
+
};
|
|
2764
|
+
installJobTokenHint(ctx);
|
|
2765
|
+
const { probe: probeKey, auth: authKey } = controlKeys();
|
|
1955
2766
|
let setMaximumContextWindow;
|
|
1956
2767
|
let setMaximumContextWindowCN;
|
|
2768
|
+
let setModelsEnabled;
|
|
2769
|
+
let setModelsEnabledCN;
|
|
1957
2770
|
/**
|
|
1958
2771
|
* Point a variant at an account identity, invalidating whatever the previous
|
|
1959
2772
|
* one left behind.
|
|
@@ -1977,7 +2790,7 @@ function apply(ctx, config) {
|
|
|
1977
2790
|
const adoptIdentity = (runtime, identity) => {
|
|
1978
2791
|
const id = runtime.variant.id;
|
|
1979
2792
|
const known = lastIdentities.get(id);
|
|
1980
|
-
if (known === identity) return;
|
|
2793
|
+
if (known === identity && runtime.catalog.isVisible()) return;
|
|
1981
2794
|
const hadCredential = known !== void 0;
|
|
1982
2795
|
if (identity === void 0) lastIdentities.delete(id);
|
|
1983
2796
|
else lastIdentities.set(id, identity);
|
|
@@ -2011,20 +2824,54 @@ function apply(ctx, config) {
|
|
|
2011
2824
|
runtime.catalog.setVisible(true);
|
|
2012
2825
|
runtime.invalidate();
|
|
2013
2826
|
};
|
|
2827
|
+
/**
|
|
2828
|
+
* Apply catalog preferences from a configuration view, in memory.
|
|
2829
|
+
*
|
|
2830
|
+
* Shared by the probe-route setters and the settings face's POST: both write
|
|
2831
|
+
* the plugin-owned settings file and then land the new view here, which is
|
|
2832
|
+
* what the cards see on their next status read.
|
|
2833
|
+
*/
|
|
2834
|
+
const applyCatalogSettings = (next) => {
|
|
2835
|
+
let changed = false;
|
|
2836
|
+
for (const runtime of runtimes) {
|
|
2837
|
+
const isChina = runtime.variant.id === CHINA_VARIANT.id;
|
|
2838
|
+
const preference = isChina ? next.useMaximumContextWindowCN : next.useMaximumContextWindow;
|
|
2839
|
+
if (runtime.catalog.setUseMaximumContextWindow(preference === true)) changed = true;
|
|
2840
|
+
const overrides = isChina ? next.modelContextWindowsCN : next.modelContextWindows;
|
|
2841
|
+
if (overrides !== void 0 && runtime.catalog.setModelContextWindows(overrides)) changed = true;
|
|
2842
|
+
const disabled = isChina ? next.disabledModelsCN : next.disabledModels;
|
|
2843
|
+
if (runtime.catalog.setDisabledModels(disabled ?? [])) changed = true;
|
|
2844
|
+
}
|
|
2845
|
+
if (changed) for (const runtime of runtimes) runtime.invalidate();
|
|
2846
|
+
};
|
|
2014
2847
|
ctx.inject(["webServer"], (webCtx) => {
|
|
2015
2848
|
for (const runtime of runtimes) {
|
|
2016
2849
|
registerQoderStatusRoute(webCtx, {
|
|
2017
2850
|
path: runtime.variant.statusPath,
|
|
2018
2851
|
store: runtime.store,
|
|
2019
2852
|
client: runtime.client,
|
|
2020
|
-
models: () => runtime.catalog.
|
|
2853
|
+
models: () => runtime.catalog.all(),
|
|
2021
2854
|
catalog: () => catalogSection(runtime),
|
|
2022
2855
|
probe: () => probeSection(runtime, current().probeConsent === true),
|
|
2023
2856
|
probeKey,
|
|
2024
2857
|
authKey,
|
|
2025
|
-
...runtime.variant.id === CHINA_VARIANT.id ? {
|
|
2858
|
+
...runtime.variant.id === CHINA_VARIANT.id ? {
|
|
2859
|
+
useMaximumContextWindow: () => current().useMaximumContextWindowCN === true,
|
|
2860
|
+
disabledModels: () => current().disabledModelsCN ?? []
|
|
2861
|
+
} : {
|
|
2862
|
+
useMaximumContextWindow: () => current().useMaximumContextWindow === true,
|
|
2863
|
+
disabledModels: () => current().disabledModels ?? []
|
|
2864
|
+
},
|
|
2026
2865
|
jobTokenRefreshedAt: runtime.jobTokenRefreshedAt,
|
|
2027
|
-
checkIn: () =>
|
|
2866
|
+
checkIn: () => {
|
|
2867
|
+
const record = checkInStore.read(runtime.variant.id);
|
|
2868
|
+
if (record === void 0) return void 0;
|
|
2869
|
+
const nextRunAt = checkInScheduler.nextRunAt(runtime.variant.id);
|
|
2870
|
+
return {
|
|
2871
|
+
...record,
|
|
2872
|
+
...nextRunAt === void 0 ? {} : { nextRunAt }
|
|
2873
|
+
};
|
|
2874
|
+
}
|
|
2028
2875
|
});
|
|
2029
2876
|
registerQoderAuthRoute(webCtx, {
|
|
2030
2877
|
path: runtime.variant.authPath,
|
|
@@ -2091,16 +2938,7 @@ function apply(ctx, config) {
|
|
|
2091
2938
|
checkInStore.clearLogs(runtime.variant.id);
|
|
2092
2939
|
},
|
|
2093
2940
|
checkIn: async () => {
|
|
2094
|
-
|
|
2095
|
-
try {
|
|
2096
|
-
pat = await runtime.store.patPromise();
|
|
2097
|
-
} catch {
|
|
2098
|
-
return {
|
|
2099
|
-
state: "failed",
|
|
2100
|
-
reason: "No PAT available"
|
|
2101
|
-
};
|
|
2102
|
-
}
|
|
2103
|
-
const result = await runtime.transport.checkInService.checkIn(pat);
|
|
2941
|
+
const result = await runtime.checkIn();
|
|
2104
2942
|
if (result.status !== "error") {
|
|
2105
2943
|
checkInStore.write(runtime.variant.id, {
|
|
2106
2944
|
lastDate: result.date,
|
|
@@ -2117,81 +2955,98 @@ function apply(ctx, config) {
|
|
|
2117
2955
|
...result.message === void 0 ? {} : { reason: result.message }
|
|
2118
2956
|
};
|
|
2119
2957
|
},
|
|
2120
|
-
...runtime.variant.id === CHINA_VARIANT.id ? {
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2958
|
+
...runtime.variant.id === CHINA_VARIANT.id ? {
|
|
2959
|
+
setMaximumContextWindow: async (enabled) => {
|
|
2960
|
+
if (setMaximumContextWindowCN === void 0) return {
|
|
2961
|
+
state: "failed",
|
|
2962
|
+
reason: "settings are unavailable"
|
|
2963
|
+
};
|
|
2964
|
+
return setMaximumContextWindowCN(enabled);
|
|
2965
|
+
},
|
|
2966
|
+
setModelsEnabled: async (opts) => {
|
|
2967
|
+
if (setModelsEnabledCN === void 0) return {
|
|
2968
|
+
state: "failed",
|
|
2969
|
+
reason: "settings are unavailable"
|
|
2970
|
+
};
|
|
2971
|
+
return setModelsEnabledCN(opts);
|
|
2972
|
+
}
|
|
2973
|
+
} : {
|
|
2974
|
+
setMaximumContextWindow: async (enabled) => {
|
|
2975
|
+
if (setMaximumContextWindow === void 0) return {
|
|
2976
|
+
state: "failed",
|
|
2977
|
+
reason: "settings are unavailable"
|
|
2978
|
+
};
|
|
2979
|
+
return setMaximumContextWindow(enabled);
|
|
2980
|
+
},
|
|
2981
|
+
setModelsEnabled: async (opts) => {
|
|
2982
|
+
if (setModelsEnabled === void 0) return {
|
|
2983
|
+
state: "failed",
|
|
2984
|
+
reason: "settings are unavailable"
|
|
2985
|
+
};
|
|
2986
|
+
return setModelsEnabled(opts);
|
|
2987
|
+
}
|
|
2988
|
+
}
|
|
2133
2989
|
}, probeKey);
|
|
2134
2990
|
}
|
|
2991
|
+
registerQoderSettingsFace(webCtx, {
|
|
2992
|
+
store,
|
|
2993
|
+
config,
|
|
2994
|
+
current,
|
|
2995
|
+
apply: applyCatalogSettings,
|
|
2996
|
+
rearm: () => {
|
|
2997
|
+
runStartupCatchUpOnce();
|
|
2998
|
+
checkInScheduler.rearm();
|
|
2999
|
+
}
|
|
3000
|
+
});
|
|
2135
3001
|
});
|
|
2136
3002
|
ctx.inject(["settings"], (settingsCtx) => {
|
|
2137
|
-
/**
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
const
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
if (changed) for (const runtime of runtimes) runtime.invalidate();
|
|
2164
|
-
};
|
|
2165
|
-
const repointStores = () => {
|
|
2166
|
-
applyMaximumContextWindow(merged());
|
|
3003
|
+
/**
|
|
3004
|
+
* `auto: false` turns OFF the host's generated form page for this entry —
|
|
3005
|
+
* the plugin ships its own card, which is now the only editor. It must be
|
|
3006
|
+
* called as a METHOD: on 0.1.7 `configure` is a SettingsForms class method
|
|
3007
|
+
* (it reads `this.presentations`), and extracting it as a free function
|
|
3008
|
+
* throws a TypeError that used to kill the whole settings callback.
|
|
3009
|
+
*/
|
|
3010
|
+
const settingsService = settingsCtx.settings;
|
|
3011
|
+
if (typeof settingsService.configure === "function") try {
|
|
3012
|
+
settingsCtx.effect(() => {
|
|
3013
|
+
const dispose = settingsService.configure({ auto: false }, ctx.fiber);
|
|
3014
|
+
return typeof dispose === "function" ? dispose : () => {};
|
|
3015
|
+
});
|
|
3016
|
+
} catch (error) {
|
|
3017
|
+
console.error("[dsh-qoder-connect] settings.configure failed (own settings file still serves):", error);
|
|
3018
|
+
}
|
|
3019
|
+
/**
|
|
3020
|
+
* The four setters, now one implementation on both hosts: write the
|
|
3021
|
+
* plugin-owned settings file, then apply the new view in memory. No
|
|
3022
|
+
* profile-patch write means no tree reconcile, no fiber reload, and no
|
|
3023
|
+
* client-mirror storm per toggle.
|
|
3024
|
+
*/
|
|
3025
|
+
const write = (patch) => {
|
|
3026
|
+
store.patch(patch, true);
|
|
3027
|
+
applyCatalogSettings(current());
|
|
3028
|
+
checkInScheduler.rearm();
|
|
2167
3029
|
};
|
|
2168
|
-
settingsCtx.settings.installSection(ctx, QODER_SETTINGS_NS, CHINA_SECTION, config, {
|
|
2169
|
-
setSource(source) {
|
|
2170
|
-
sources.cn = source;
|
|
2171
|
-
current = merged;
|
|
2172
|
-
},
|
|
2173
|
-
onChange: repointStores
|
|
2174
|
-
});
|
|
2175
|
-
settingsCtx.settings.installSection(ctx, QODER_GLOBAL_SETTINGS_NS, GLOBAL_SECTION, config, {
|
|
2176
|
-
setSource(source) {
|
|
2177
|
-
sources.global = source;
|
|
2178
|
-
current = merged;
|
|
2179
|
-
},
|
|
2180
|
-
onChange: repointStores
|
|
2181
|
-
});
|
|
2182
|
-
settingsCtx.settings.installSection(ctx, QODER_QUOTA_SETTINGS_NS, QUOTA_SECTION, config, {
|
|
2183
|
-
setSource(source) {
|
|
2184
|
-
sources.quota = source;
|
|
2185
|
-
current = merged;
|
|
2186
|
-
},
|
|
2187
|
-
onChange: () => {}
|
|
2188
|
-
});
|
|
2189
3030
|
setMaximumContextWindow = async (enabled) => {
|
|
2190
|
-
|
|
3031
|
+
write({ useMaximumContextWindow: enabled });
|
|
2191
3032
|
return { state: "updated" };
|
|
2192
3033
|
};
|
|
2193
3034
|
setMaximumContextWindowCN = async (enabled) => {
|
|
2194
|
-
|
|
3035
|
+
write({ useMaximumContextWindowCN: enabled });
|
|
3036
|
+
return { state: "updated" };
|
|
3037
|
+
};
|
|
3038
|
+
setModelsEnabled = async ({ models, enabled }) => {
|
|
3039
|
+
const currentDisabled = new Set(readConfigField(current(), "disabledModels") ?? []);
|
|
3040
|
+
for (const m of models) if (enabled) currentDisabled.delete(m);
|
|
3041
|
+
else currentDisabled.add(m);
|
|
3042
|
+
write({ disabledModels: Array.from(currentDisabled) });
|
|
3043
|
+
return { state: "updated" };
|
|
3044
|
+
};
|
|
3045
|
+
setModelsEnabledCN = async ({ models, enabled }) => {
|
|
3046
|
+
const currentDisabled = new Set(readConfigField(current(), "disabledModelsCN") ?? []);
|
|
3047
|
+
for (const m of models) if (enabled) currentDisabled.delete(m);
|
|
3048
|
+
else currentDisabled.add(m);
|
|
3049
|
+
write({ disabledModelsCN: Array.from(currentDisabled) });
|
|
2195
3050
|
return { state: "updated" };
|
|
2196
3051
|
};
|
|
2197
3052
|
});
|
|
@@ -2257,6 +3112,7 @@ function apply(ctx, config) {
|
|
|
2257
3112
|
}
|
|
2258
3113
|
if (stopped || runtime.catalogGeneration !== generation) return;
|
|
2259
3114
|
runtime.lastFetchAtMs = Date.now();
|
|
3115
|
+
lastLiveFetchAt.set(identity, runtime.lastFetchAtMs);
|
|
2260
3116
|
runtime.catalog.set([...models]);
|
|
2261
3117
|
runtime.catalogSource = "live";
|
|
2262
3118
|
runtime.catalogFetchedAtMs = runtime.client.lastCatalog?.fetchedAtMs ?? Date.now();
|
|
@@ -2309,7 +3165,8 @@ function apply(ctx, config) {
|
|
|
2309
3165
|
const identity = credentialIdentity(credential);
|
|
2310
3166
|
if (lastIdentities.get(runtime.variant.id) === identity && runtime.catalog.isVisible()) {
|
|
2311
3167
|
const stale = runtime.catalogSource !== "live";
|
|
2312
|
-
const
|
|
3168
|
+
const fetchedAt = lastLiveFetchAt.get(identity) ?? 0;
|
|
3169
|
+
const due = Date.now() - fetchedAt >= credentialPollMs() * CATALOG_RETRY_SWEEPS;
|
|
2313
3170
|
if (stale && due) await fetchCatalog(runtime, identity);
|
|
2314
3171
|
return;
|
|
2315
3172
|
}
|
|
@@ -2327,7 +3184,18 @@ function apply(ctx, config) {
|
|
|
2327
3184
|
* would be an unhandled rejection — which Node turns into process
|
|
2328
3185
|
* termination, taking the whole Harness down over one plugin's startup.
|
|
2329
3186
|
*/
|
|
2330
|
-
detach(ctx, Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime
|
|
3187
|
+
detach(ctx, Promise.all(runtimes.map(async (runtime) => startVariant(ctx, runtime, async () => {
|
|
3188
|
+
if (stopped) return;
|
|
3189
|
+
let credential;
|
|
3190
|
+
try {
|
|
3191
|
+
credential = await runtime.store.current();
|
|
3192
|
+
} catch {
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
if (credential === void 0) return;
|
|
3196
|
+
const identity = credentialIdentity(credential);
|
|
3197
|
+
if (lastIdentities.get(runtime.variant.id) !== identity || !runtime.catalog.isVisible()) adoptIdentity(runtime, identity);
|
|
3198
|
+
}))).then(() => {
|
|
2331
3199
|
if (stopped) return;
|
|
2332
3200
|
if (runtimes.some((runtime) => runtime.registered)) detach(ctx, writeHostHeartbeat(), "host heartbeat write");
|
|
2333
3201
|
detach(ctx, syncAll(), "credential sweep");
|
|
@@ -2339,4 +3207,4 @@ function apply(ctx, config) {
|
|
|
2339
3207
|
}), "variant startup");
|
|
2340
3208
|
}
|
|
2341
3209
|
//#endregion
|
|
2342
|
-
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 };
|
|
3210
|
+
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 };
|