dsh-router-core 0.4.0 → 0.4.2
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.md +174 -38
- package/cordis.patch.yml +22 -0
- package/docs/ext.md +29 -6
- package/docs/suppliers.md +28 -5
- package/lib/client-registry.js +939 -132
- package/lib/client.js +939 -132
- package/lib/ext-test/index.js +47 -0
- package/lib/ext-test/locale/en.json +6 -0
- package/lib/ext-test/locale/zh.json +6 -0
- package/lib/ext-test/package.json +5 -0
- package/lib/http-state-C1vb3gW2.js +48 -0
- package/lib/index.js +838 -89
- package/lib/suppliers/{nvidia.js → nvidia/index.js} +14 -6
- package/lib/suppliers/nvidia/locale/en.json +6 -0
- package/lib/suppliers/nvidia/locale/zh.json +6 -0
- package/lib/suppliers/nvidia/package.json +5 -0
- package/lib/suppliers/{opencode.js → opencode/index.js} +14 -6
- package/lib/suppliers/opencode/locale/en.json +6 -0
- package/lib/suppliers/opencode/locale/zh.json +6 -0
- package/lib/suppliers/opencode/package.json +5 -0
- package/lib/suppliers/{openrouter.js → openrouter/index.js} +14 -6
- package/lib/suppliers/openrouter/locale/en.json +6 -0
- package/lib/suppliers/openrouter/locale/zh.json +6 -0
- package/lib/suppliers/openrouter/package.json +5 -0
- package/lib/types/ext/contract.d.ts +9 -0
- package/lib/types/ext-test/index.d.ts +21 -0
- package/lib/types/ext-test/plugin.d.ts +23 -0
- package/lib/types/host-base-url.d.ts +31 -0
- package/lib/types/llm/adapter.d.ts +40 -4
- package/lib/types/router/index.d.ts +61 -8
- package/lib/types/shared.d.ts +12 -0
- package/lib/types/supplier-config.d.ts +15 -0
- package/lib/types/suppliers/contract.d.ts +35 -2
- package/lib/types/suppliers/http-state.d.ts +23 -0
- package/lib/types/suppliers/loader.d.ts +6 -4
- package/lib/types/suppliers/nvidia/index.d.ts +18 -0
- package/lib/types/suppliers/nvidia/plugin.d.ts +13 -0
- package/lib/types/suppliers/opencode/index.d.ts +18 -0
- package/lib/types/suppliers/opencode/plugin.d.ts +7 -0
- package/lib/types/suppliers/openrouter/index.d.ts +18 -0
- package/lib/types/suppliers/openrouter/plugin.d.ts +11 -0
- package/lib/types/suppliers/probe.d.ts +160 -0
- package/lib/types/suppliers/registry.d.ts +3 -1
- package/package.json +39 -4
- package/src/client/ExtDetail.tsx +21 -22
- package/src/client/ExtTab.tsx +25 -42
- package/src/client/ExtTestPanel.tsx +392 -0
- package/src/client/RouterComponentsSection.tsx +241 -0
- package/src/client/RouterView.tsx +12 -5
- package/src/client/comp-button-scope.test.ts +58 -0
- package/src/client/comp-panel-layout.test.ts +58 -0
- package/src/client/env.d.ts +38 -1
- package/src/client/ext-panels.test.ts +44 -0
- package/src/client/ext-panels.ts +49 -0
- package/src/client/index.tsx +41 -0
- package/src/client/model-choice.test.ts +41 -0
- package/src/client/model-choice.ts +37 -0
- package/src/client/router-components.test.ts +114 -0
- package/src/client/router-components.ts +104 -0
- package/src/client/router.css +490 -9
- package/src/client/supplier-visibility.test.ts +57 -0
- package/src/client/supplier-visibility.ts +63 -0
- package/src/ext/contract.ts +9 -0
- package/src/ext-test/ext-test.test.ts +60 -0
- package/src/ext-test/index.ts +35 -0
- package/src/ext-test/locale/en.json +6 -0
- package/src/ext-test/locale/zh.json +6 -0
- package/src/ext-test/plugin.ts +34 -0
- package/src/ext-test/row.json +5 -0
- package/src/host-base-url.test.ts +129 -0
- package/src/host-base-url.ts +43 -0
- package/src/index.ts +46 -11
- package/src/llm/adapter.test.ts +101 -4
- package/src/llm/adapter.ts +89 -20
- package/src/router/disconnect-leak.test.ts +7 -12
- package/src/router/first-byte-budget.test.ts +160 -0
- package/src/router/index.ts +255 -35
- package/src/router/models-cache.test.ts +45 -0
- package/src/router/supplier-switch.test.ts +220 -0
- package/src/shared.ts +12 -0
- package/src/supplier-config.ts +32 -1
- package/src/suppliers/contract.ts +54 -2
- package/src/suppliers/loader.test.ts +36 -2
- package/src/suppliers/loader.ts +33 -18
- package/src/suppliers/nvidia/index.ts +27 -0
- package/src/suppliers/nvidia/locale/en.json +6 -0
- package/src/suppliers/nvidia/locale/zh.json +6 -0
- package/src/suppliers/nvidia/row.json +5 -0
- package/src/suppliers/opencode/index.ts +27 -0
- package/src/suppliers/opencode/locale/en.json +6 -0
- package/src/suppliers/opencode/locale/zh.json +6 -0
- package/src/suppliers/opencode/row.json +5 -0
- package/src/suppliers/openrouter/index.ts +27 -0
- package/src/suppliers/openrouter/locale/en.json +6 -0
- package/src/suppliers/openrouter/locale/zh.json +6 -0
- package/src/suppliers/openrouter/row.json +5 -0
- package/src/suppliers/probe.test.ts +266 -0
- package/src/suppliers/probe.ts +475 -0
- package/src/suppliers/registry.ts +102 -3
package/lib/index.js
CHANGED
|
@@ -561,6 +561,7 @@ var SupplierConfigStore = class {
|
|
|
561
561
|
let cfg = this.bySupplier.get(supplierId);
|
|
562
562
|
if (!cfg) {
|
|
563
563
|
cfg = {
|
|
564
|
+
enabled: true,
|
|
564
565
|
alias: "",
|
|
565
566
|
disabled: [],
|
|
566
567
|
custom: [],
|
|
@@ -588,6 +589,22 @@ var SupplierConfigStore = class {
|
|
|
588
589
|
this.saveLocked();
|
|
589
590
|
return { ok: true };
|
|
590
591
|
}
|
|
592
|
+
/** 这个供应商是否参与路由(未记录过 = 开)。 */
|
|
593
|
+
isEnabled(supplierId) {
|
|
594
|
+
return this.get(supplierId).enabled;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* 置供应商开关。**不写盘**时值不变也要返回,让调用方知道是否需要重连路由。
|
|
598
|
+
* @returns 值确实变了
|
|
599
|
+
*/
|
|
600
|
+
setEnabled(supplierId, enabled) {
|
|
601
|
+
if (supplierId === "") return false;
|
|
602
|
+
const cfg = this.get(supplierId);
|
|
603
|
+
if (cfg.enabled === enabled) return false;
|
|
604
|
+
cfg.enabled = enabled;
|
|
605
|
+
this.saveLocked();
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
591
608
|
setModelEnabled(supplierId, modelId, enabled) {
|
|
592
609
|
const cfg = this.get(supplierId);
|
|
593
610
|
cfg.disabled = enabled ? cfg.disabled.filter((m) => m !== modelId) : [.../* @__PURE__ */ new Set([...cfg.disabled, modelId])];
|
|
@@ -666,6 +683,7 @@ var SupplierConfigStore = class {
|
|
|
666
683
|
return;
|
|
667
684
|
}
|
|
668
685
|
for (const [id, c] of Object.entries(f.suppliers ?? {})) this.bySupplier.set(id, {
|
|
686
|
+
enabled: typeof c.enabled === "boolean" ? c.enabled : true,
|
|
669
687
|
alias: typeof c.alias === "string" ? c.alias : "",
|
|
670
688
|
disabled: Array.isArray(c.disabled) ? c.disabled.filter((m) => typeof m === "string") : [],
|
|
671
689
|
custom: Array.isArray(c.custom) ? c.custom.filter((m) => typeof m === "string") : [],
|
|
@@ -678,6 +696,7 @@ var SupplierConfigStore = class {
|
|
|
678
696
|
if (this.fp === "") return;
|
|
679
697
|
const f = { suppliers: {} };
|
|
680
698
|
for (const [id, cfg] of this.bySupplier) f.suppliers[id] = {
|
|
699
|
+
enabled: cfg.enabled,
|
|
681
700
|
alias: cfg.alias,
|
|
682
701
|
disabled: [...cfg.disabled],
|
|
683
702
|
custom: [...cfg.custom],
|
|
@@ -1453,6 +1472,99 @@ function isTransient(state) {
|
|
|
1453
1472
|
return state === "transport" || state === "unavailable";
|
|
1454
1473
|
}
|
|
1455
1474
|
/**
|
|
1475
|
+
* **每条降级腿的首字节预算**:从进入这条腿起,多久内上游必须吐出第一个字节。
|
|
1476
|
+
*
|
|
1477
|
+
* 为什么必须有(2026-09-24 夜里 money 组合实测):上游(codebuddy 系)额度用完 /
|
|
1478
|
+
* 过载时,连接照常建立、HTTP 200 响应头照常回来,**body 却一个字节都不吐**。
|
|
1479
|
+
* 这既不是成功也不是失败,于是
|
|
1480
|
+
* - `chatOnce` 的 120s 只守「连接+响应头」,`fetch` 一返回就撤表 → 守不到 body
|
|
1481
|
+
* - 核心写响应的路径也没有任何计时器 → 组合链卡在这一条腿上不动
|
|
1482
|
+
* - 客户端(dsh-router 自己的 adapter)有 300s 流空闲超时,但那是「整条请求」
|
|
1483
|
+
* 的兜底,与降级无关
|
|
1484
|
+
* 实测用户干等 200 多秒手动暂停:ttfb 84s / 122s / 151s / 244s / 246s / 250s /
|
|
1485
|
+
* 254s / 255s,8 次全部 `ok: true`——**全都最终成功了**,只是晚了四分钟。同一批
|
|
1486
|
+
* 账号因为「没失败」也不冷却,下一次请求继续挑它(cb-8 连吃 248s + 155s)。
|
|
1487
|
+
*
|
|
1488
|
+
* 60s 的来历(本机 500 条真实请求的 ttfb 分布):p50 4.1s / p95 14.9s,
|
|
1489
|
+
* 最大的**非停滞**值 47.7s,停滞簇从 84.5s 起 —— 60s 正落在两簇之间的空档,
|
|
1490
|
+
* 砍得动停滞、砍不到真实慢请求。首个字节之后**不再计时**:长生成可以跑很久。
|
|
1491
|
+
*
|
|
1492
|
+
* 为什么按「腿」而不是按「账号」计:停滞是上游级的(同一供应商的所有号一起
|
|
1493
|
+
* 挂)。按账号计的话,codebuddy 有 11 个号,一次请求能 11×60s = 11 分钟才轮到
|
|
1494
|
+
* 下一个模型;按腿计,最坏 3 条腿 × 60s ≈ 3 分钟,且期间一定会走到下一个模型。
|
|
1495
|
+
*/
|
|
1496
|
+
const FIRST_BYTE_BUDGET_MS = 6e4;
|
|
1497
|
+
/**
|
|
1498
|
+
* 给上游流套一层**首字节截止**:截止前一个字节都没来,就把这条流判失败
|
|
1499
|
+
* (error 出去,调用方看到「一个字节都没写」→ 换号/换模型)。
|
|
1500
|
+
* 第一个字节到手后立刻撤表,之后多久都不管。
|
|
1501
|
+
*
|
|
1502
|
+
* 为什么套在核心而不是插件:首字节迟迟不来时,**只有核心**能做「还没提交响应
|
|
1503
|
+
* → 换一条腿」的决定(插件在 `fetch` 拿到响应头那一刻就把流交出去了,
|
|
1504
|
+
* 谁也没法再改结果)。核心也是所有供应商唯一的收口点,套一次全都受益。
|
|
1505
|
+
*/
|
|
1506
|
+
function withFirstByteDeadline(stream, ms) {
|
|
1507
|
+
const reader = stream.getReader();
|
|
1508
|
+
let first = true;
|
|
1509
|
+
/** 消费方已取消:这之后任何 ctrl.* 都会抛(流已死),必须全部跳过。 */
|
|
1510
|
+
let cancelled = false;
|
|
1511
|
+
/**
|
|
1512
|
+
* 上游 reader 只解绑一次。wrapper 替核心持有了这个 reader,所以**必须由
|
|
1513
|
+
* wrapper 负责收尾**:上游正常读完 / 上游自己 error / 被取消时都解锁,
|
|
1514
|
+
* 否则流永久锁着,上层想 cancel 会抛 `Invalid state: ReadableStream is locked`
|
|
1515
|
+
* (disconnect-leak 那条判据守的就是这个;历史上由 aggregateSSE 的 finally 解锁,
|
|
1516
|
+
* 现在读的是 wrapper,解绑责任跟着搬过来)。
|
|
1517
|
+
*/
|
|
1518
|
+
let released = false;
|
|
1519
|
+
const release = () => {
|
|
1520
|
+
if (released) return;
|
|
1521
|
+
released = true;
|
|
1522
|
+
try {
|
|
1523
|
+
reader.releaseLock();
|
|
1524
|
+
} catch {}
|
|
1525
|
+
};
|
|
1526
|
+
/** 取消要传到底下的上游(cancel 本身会顺带解锁)。 */
|
|
1527
|
+
const cancelUpstream = (reason) => {
|
|
1528
|
+
if (released) return Promise.resolve();
|
|
1529
|
+
released = true;
|
|
1530
|
+
return reader.cancel(reason).then(() => {}, () => {});
|
|
1531
|
+
};
|
|
1532
|
+
return new ReadableStream({
|
|
1533
|
+
async pull(ctrl) {
|
|
1534
|
+
let expired = false;
|
|
1535
|
+
const timer = first ? setTimeout(() => {
|
|
1536
|
+
expired = true;
|
|
1537
|
+
cancelUpstream(/* @__PURE__ */ new Error("first byte timeout"));
|
|
1538
|
+
try {
|
|
1539
|
+
ctrl.error(/* @__PURE__ */ new Error(`no first byte within ${ms}ms`));
|
|
1540
|
+
} catch {}
|
|
1541
|
+
}, ms) : void 0;
|
|
1542
|
+
try {
|
|
1543
|
+
const { done, value } = await reader.read();
|
|
1544
|
+
if (expired || cancelled) return;
|
|
1545
|
+
if (done) {
|
|
1546
|
+
release();
|
|
1547
|
+
ctrl.close();
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
first = false;
|
|
1551
|
+
ctrl.enqueue(value);
|
|
1552
|
+
} catch (e) {
|
|
1553
|
+
release();
|
|
1554
|
+
if (!expired && !cancelled) try {
|
|
1555
|
+
ctrl.error(e);
|
|
1556
|
+
} catch {}
|
|
1557
|
+
} finally {
|
|
1558
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
1559
|
+
}
|
|
1560
|
+
},
|
|
1561
|
+
cancel: (reason) => {
|
|
1562
|
+
cancelled = true;
|
|
1563
|
+
return cancelUpstream(reason);
|
|
1564
|
+
}
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1456
1568
|
* 从请求体里取 `messages`(前缀亲和的指纹输入)。解析失败/字段缺失返回
|
|
1457
1569
|
* undefined —— 亲和拿不到输入就自动回退到块轮询,不影响路由本身。
|
|
1458
1570
|
*/
|
|
@@ -1723,8 +1835,11 @@ function fixFrame(frame) {
|
|
|
1723
1835
|
*
|
|
1724
1836
|
* 天花板:一旦写出第一个字节就绑死(HTTP 语义),中途断流只能截断,
|
|
1725
1837
|
* 不能回退。要彻底解决得在写头前缓冲判定,代价是首字节延迟——未做。
|
|
1838
|
+
*
|
|
1839
|
+
* @param firstByteTimeoutMs 上游多久内必须吐出第一个字节(见 FIRST_BYTE_BUDGET_MS)。
|
|
1840
|
+
* 超时按「一个字节都没写」返回 false,调用方照常换号/换模型。
|
|
1726
1841
|
*/
|
|
1727
|
-
async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
|
|
1842
|
+
async function writeChatResult(res, r, wantsStream, probe, startedAt = 0, firstByteTimeoutMs = FIRST_BYTE_BUDGET_MS) {
|
|
1728
1843
|
if (!r.ok) return false;
|
|
1729
1844
|
if (!("stream" in r)) {
|
|
1730
1845
|
if (probe !== void 0) probe.tokens = usageFromJsonBody(r.body);
|
|
@@ -1735,12 +1850,13 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
|
|
|
1735
1850
|
res.end(r.body);
|
|
1736
1851
|
return true;
|
|
1737
1852
|
}
|
|
1853
|
+
const src = withFirstByteDeadline(r.stream, firstByteTimeoutMs);
|
|
1738
1854
|
if (!wantsStream) {
|
|
1739
1855
|
let body;
|
|
1740
1856
|
try {
|
|
1741
|
-
body = await aggregateSSE(
|
|
1857
|
+
body = await aggregateSSE(src);
|
|
1742
1858
|
} catch {
|
|
1743
|
-
await
|
|
1859
|
+
await src.cancel().catch(() => {});
|
|
1744
1860
|
return false;
|
|
1745
1861
|
}
|
|
1746
1862
|
if (probe !== void 0) probe.tokens = usageFromJsonBody(body);
|
|
@@ -1751,7 +1867,7 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
|
|
|
1751
1867
|
res.end(body);
|
|
1752
1868
|
return true;
|
|
1753
1869
|
}
|
|
1754
|
-
const tapped = probe === void 0 ? void 0 : tapStreamUsage(
|
|
1870
|
+
const tapped = probe === void 0 ? void 0 : tapStreamUsage(src, startedAt);
|
|
1755
1871
|
let wroteAny = false;
|
|
1756
1872
|
/** 响应已死(客户端断开/res 已销毁):等 drain 的写入要立刻放弃,别吊死。 */
|
|
1757
1873
|
let dead = false;
|
|
@@ -1815,7 +1931,7 @@ async function writeChatResult(res, r, wantsStream, probe, startedAt = 0) {
|
|
|
1815
1931
|
abort.abort();
|
|
1816
1932
|
};
|
|
1817
1933
|
res.once("close", onClose);
|
|
1818
|
-
const norm = normalizeSSEStream(tapped?.stream ??
|
|
1934
|
+
const norm = normalizeSSEStream(tapped?.stream ?? src);
|
|
1819
1935
|
try {
|
|
1820
1936
|
await norm.stream.pipeTo(new WritableStream({ write: writeChunk }), { signal: abort.signal });
|
|
1821
1937
|
} catch {} finally {
|
|
@@ -1846,6 +1962,12 @@ function extractUpstreamError(body) {
|
|
|
1846
1962
|
}
|
|
1847
1963
|
/** 路由器。 */
|
|
1848
1964
|
var Router = class {
|
|
1965
|
+
/**
|
|
1966
|
+
* **已装载**的全部供应商(含被开关关掉的)。关掉的仍留在列表里 —— 否则用户关掉
|
|
1967
|
+
* 之后它在 `/health`、面板、插件页里一起消失,开关就没有可指的对象、也没法再开。
|
|
1968
|
+
*
|
|
1969
|
+
* 真正参与路由的只有 `active` 那一份。
|
|
1970
|
+
*/
|
|
1849
1971
|
suppliers = [];
|
|
1850
1972
|
combosFp = "";
|
|
1851
1973
|
customCombos = [];
|
|
@@ -1860,12 +1982,15 @@ var Router = class {
|
|
|
1860
1982
|
modelsInflight = /* @__PURE__ */ new Map();
|
|
1861
1983
|
/** 请求日志出口(面板/宿主 logger)。 */
|
|
1862
1984
|
log;
|
|
1985
|
+
/** 每条降级腿的首字节预算(ms)。只给测试一个入口,生产恒为 FIRST_BYTE_BUDGET_MS。 */
|
|
1986
|
+
firstByteBudgetMs;
|
|
1863
1987
|
/** 用量统计(概览看板)。 */
|
|
1864
1988
|
usage;
|
|
1865
|
-
constructor(stateFile = "", store, log) {
|
|
1989
|
+
constructor(stateFile = "", store, log, opts) {
|
|
1866
1990
|
this.combosFp = stateFile ? join(dirname(stateFile), "combos.json") : "";
|
|
1867
1991
|
this.store = store ?? new SupplierConfigStore(stateFile);
|
|
1868
1992
|
this.log = log ?? (() => {});
|
|
1993
|
+
this.firstByteBudgetMs = opts?.firstByteBudgetMs ?? FIRST_BYTE_BUDGET_MS;
|
|
1869
1994
|
this.usage = new UsageStore(stateFile);
|
|
1870
1995
|
this.loadCombos();
|
|
1871
1996
|
}
|
|
@@ -1889,7 +2014,7 @@ var Router = class {
|
|
|
1889
2014
|
if (slash <= 0) return m;
|
|
1890
2015
|
const alias = m.slice(0, slash);
|
|
1891
2016
|
const modelId = m.slice(slash + 1);
|
|
1892
|
-
const s = this.
|
|
2017
|
+
const s = this.active.find((x) => x.getAlias() === alias);
|
|
1893
2018
|
return s === void 0 ? modelId : `${s.id},${modelId}`;
|
|
1894
2019
|
});
|
|
1895
2020
|
return {
|
|
@@ -1916,6 +2041,20 @@ var Router = class {
|
|
|
1916
2041
|
this.suppliers.sort((a, b) => a.priority - b.priority);
|
|
1917
2042
|
this.store.sync(this.suppliers.map((s) => s.id));
|
|
1918
2043
|
}
|
|
2044
|
+
/**
|
|
2045
|
+
* **参与路由的供应商** —— 装载清单里开关为开的那一份,按 priority 排好。
|
|
2046
|
+
*
|
|
2047
|
+
* 这是「供应商开关」唯一的收口点:请求分发、别名反查、模型列表、组合、面板状态
|
|
2048
|
+
* 全部读它。关掉一个供应商 = 它从这里消失,而不是在每个调用点各写一遍判断 ——
|
|
2049
|
+
* 后者一定会漏(本次实测路由有 15 处读 `this.suppliers`)。
|
|
2050
|
+
*/
|
|
2051
|
+
get active() {
|
|
2052
|
+
return this.suppliers.filter((s) => this.store.isEnabled(s.id));
|
|
2053
|
+
}
|
|
2054
|
+
/** 某供应商当前是否参与路由(面板与 API 用)。 */
|
|
2055
|
+
isEnabled(supplierId) {
|
|
2056
|
+
return this.store.isEnabled(supplierId);
|
|
2057
|
+
}
|
|
1919
2058
|
/** 移除供应商(外部插件卸载时注销)。 */
|
|
1920
2059
|
removeSupplier(id) {
|
|
1921
2060
|
const i = this.suppliers.findIndex((s) => s.id === id);
|
|
@@ -1925,13 +2064,20 @@ var Router = class {
|
|
|
1925
2064
|
s?.dispose();
|
|
1926
2065
|
return true;
|
|
1927
2066
|
}
|
|
1928
|
-
/**
|
|
2067
|
+
/**
|
|
2068
|
+
* 返回全部供应商状态(面板用)。**含被开关关掉的** —— 面板要能显示它们
|
|
2069
|
+
* (「已关闭」),否则用户在面板上就再也看不到自己装过的供应商。
|
|
2070
|
+
* 开关状态由 `enabled` 字段带出,调用方自己决定要不要只留开着的。
|
|
2071
|
+
*/
|
|
1929
2072
|
status() {
|
|
1930
|
-
return { suppliers: this.suppliers.map((s) =>
|
|
2073
|
+
return { suppliers: this.suppliers.map((s) => ({
|
|
2074
|
+
...s.status(),
|
|
2075
|
+
enabled: this.store.isEnabled(s.id)
|
|
2076
|
+
})) };
|
|
1931
2077
|
}
|
|
1932
|
-
/** 供应商前缀信息(组合模型全名 = alias/id
|
|
2078
|
+
/** 供应商前缀信息(组合模型全名 = alias/id,展示时动态拼接)。只含开着的。 */
|
|
1933
2079
|
aliases() {
|
|
1934
|
-
return this.
|
|
2080
|
+
return this.active.map((s) => ({
|
|
1935
2081
|
id: s.id,
|
|
1936
2082
|
name: s.name,
|
|
1937
2083
|
alias: s.getAlias()
|
|
@@ -1949,7 +2095,7 @@ var Router = class {
|
|
|
1949
2095
|
seen.add(c.name);
|
|
1950
2096
|
out.push({ id: c.name });
|
|
1951
2097
|
}
|
|
1952
|
-
for (const s of this.
|
|
2098
|
+
for (const s of this.active) try {
|
|
1953
2099
|
const ids = s.customModelIds?.();
|
|
1954
2100
|
for (const id of ids ?? []) if (!seen.has(id)) {
|
|
1955
2101
|
seen.add(id);
|
|
@@ -2008,7 +2154,7 @@ var Router = class {
|
|
|
2008
2154
|
return min;
|
|
2009
2155
|
}
|
|
2010
2156
|
async modelsOf(supplierId, force = false) {
|
|
2011
|
-
const s = this.
|
|
2157
|
+
const s = this.active.find((x) => x.id === supplierId);
|
|
2012
2158
|
if (s === void 0) return [];
|
|
2013
2159
|
const hit = this.modelsCache.get(supplierId);
|
|
2014
2160
|
const fresh = hit !== void 0 && Date.now() - hit.fetchedAt < MODELS_TTL_MS;
|
|
@@ -2072,7 +2218,7 @@ var Router = class {
|
|
|
2072
2218
|
/** 可用模型(按供应商分组,仅启用),面板加模型用。
|
|
2073
2219
|
* 各供应商**并行**拉取——串行会把每个上游的延迟累加起来。 */
|
|
2074
2220
|
async supplierModels() {
|
|
2075
|
-
return (await Promise.all(this.
|
|
2221
|
+
return (await Promise.all(this.active.map(async (s) => {
|
|
2076
2222
|
try {
|
|
2077
2223
|
const models = (await this.modelsOf(s.id)).filter((m) => m.enabled);
|
|
2078
2224
|
return {
|
|
@@ -2107,7 +2253,7 @@ var Router = class {
|
|
|
2107
2253
|
if (slash <= 0) return m;
|
|
2108
2254
|
const alias = m.slice(0, slash);
|
|
2109
2255
|
const modelId = m.slice(slash + 1);
|
|
2110
|
-
const s = this.
|
|
2256
|
+
const s = this.active.find((x) => x.getAlias() === alias);
|
|
2111
2257
|
return s === void 0 ? modelId : `${s.id},${modelId}`;
|
|
2112
2258
|
});
|
|
2113
2259
|
}
|
|
@@ -2233,7 +2379,7 @@ var Router = class {
|
|
|
2233
2379
|
const model = combo.models[i];
|
|
2234
2380
|
if (model === void 0) continue;
|
|
2235
2381
|
const trace = { attempts: 0 };
|
|
2236
|
-
if (await this.chatWithModel(req, res, model, probe, trace)) {
|
|
2382
|
+
if (await this.chatWithModel(req, res, model, probe, trace, Date.now() + this.firstByteBudgetMs)) {
|
|
2237
2383
|
settle(true);
|
|
2238
2384
|
return;
|
|
2239
2385
|
}
|
|
@@ -2279,8 +2425,11 @@ var Router = class {
|
|
|
2279
2425
|
* @param outTrace 可选出参:把这次调用的追踪信息带回去(组合据此判断
|
|
2280
2426
|
* 要不要「喘口气」再降级)。裸 id 遍历路径不填(多供应商混在一起,
|
|
2281
2427
|
* 失败状态没有单一归属)。
|
|
2428
|
+
* @param firstByteDeadlineAt 这条腿的首字节截止时刻(绝对 ms)。同一条腿内的
|
|
2429
|
+
* 多个账号共享它——停滞是上游级的,按号各给一份会 11×60s 才轮到下一个模型。
|
|
2430
|
+
* 缺省 = 此刻起一份全新预算(直接调用路径只有这一条腿)。
|
|
2282
2431
|
*/
|
|
2283
|
-
async chatWithModel(req, res, model, probe, outTrace) {
|
|
2432
|
+
async chatWithModel(req, res, model, probe, outTrace, firstByteDeadlineAt = Date.now() + this.firstByteBudgetMs) {
|
|
2284
2433
|
const comma = model.indexOf(",");
|
|
2285
2434
|
const supplierId = comma > 0 ? model.slice(0, comma) : void 0;
|
|
2286
2435
|
const modelId = comma > 0 ? model.slice(comma + 1) : model;
|
|
@@ -2300,14 +2449,15 @@ var Router = class {
|
|
|
2300
2449
|
this.logChat(req.model, msg, 0);
|
|
2301
2450
|
return false;
|
|
2302
2451
|
}
|
|
2303
|
-
const s = this.
|
|
2452
|
+
const s = this.active.find((x) => x.id === supplierId);
|
|
2304
2453
|
if (s === void 0) {
|
|
2305
|
-
this.
|
|
2454
|
+
const reason = this.suppliers.some((x) => x.id === supplierId) ? `supplier ${JSON.stringify(supplierId)} 已关闭` : `supplier ${JSON.stringify(supplierId)} 不存在`;
|
|
2455
|
+
this.logChat(req.model, reason, 0);
|
|
2306
2456
|
return false;
|
|
2307
2457
|
}
|
|
2308
2458
|
const t0 = Date.now();
|
|
2309
2459
|
const trace = { attempts: 0 };
|
|
2310
|
-
const served = await this.chatWithSupplier(s, clone, res, trace, probe);
|
|
2460
|
+
const served = await this.chatWithSupplier(s, clone, res, trace, probe, firstByteDeadlineAt);
|
|
2311
2461
|
if (outTrace !== void 0) {
|
|
2312
2462
|
outTrace.attempts = trace.attempts;
|
|
2313
2463
|
outTrace.lastError = trace.lastError;
|
|
@@ -2318,7 +2468,7 @@ var Router = class {
|
|
|
2318
2468
|
}
|
|
2319
2469
|
/** 按别名找供应商(对外模型全名 = alias/model)。别名唯一,故最多命中一个。 */
|
|
2320
2470
|
supplierByAlias(alias) {
|
|
2321
|
-
return this.
|
|
2471
|
+
return this.active.find((s) => s.getAlias() === alias);
|
|
2322
2472
|
}
|
|
2323
2473
|
/**
|
|
2324
2474
|
* 单个供应商内遍历账号(核心策略):选号 → 调 chatOnce → 按返回的
|
|
@@ -2328,8 +2478,16 @@ var Router = class {
|
|
|
2328
2478
|
* 还能换号重试;一旦写出第一个字节就绑死(HTTP 语义,9router 同样如此),
|
|
2329
2479
|
* 之后出错不再换号——要彻底能回退就得在写头前缓冲判定,代价是首字节
|
|
2330
2480
|
* 延迟,目前未做。
|
|
2481
|
+
*
|
|
2482
|
+
* @param firstByteDeadlineAt 这条腿的首字节截止时刻(见 chatWithModel)。
|
|
2483
|
+
* 预算耗尽后不再调任何号:剩下的号大概率同样停滞(上游级故障),
|
|
2484
|
+
* 挨个等下去只会把 60s 乘以号数,组合永远轮不到下一个模型。
|
|
2485
|
+
*/
|
|
2486
|
+
/**
|
|
2487
|
+
* @param onlyUid - 只试这一个连接,**不做账号池回退**。给面板「指定连接」那档用
|
|
2488
|
+
* (诊断单个链接到底通不通);正常请求与普通模型测试都不传,走账号池。
|
|
2331
2489
|
*/
|
|
2332
|
-
async chatWithSupplier(s, req, res, trace, probe) {
|
|
2490
|
+
async chatWithSupplier(s, req, res, trace, probe, firstByteDeadlineAt = Date.now() + this.firstByteBudgetMs, onlyUid) {
|
|
2333
2491
|
const pool = s.pool;
|
|
2334
2492
|
const cfg = this.store.get(s.id);
|
|
2335
2493
|
if (s.accounts !== void 0 && s.accounts().length === 0) {
|
|
@@ -2346,14 +2504,27 @@ var Router = class {
|
|
|
2346
2504
|
probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
|
|
2347
2505
|
probe.uid = "";
|
|
2348
2506
|
}
|
|
2349
|
-
return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0);
|
|
2507
|
+
return await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0, firstByteDeadlineAt - Date.now());
|
|
2350
2508
|
}
|
|
2351
2509
|
const messages = reqMessages(req.rawBody);
|
|
2352
2510
|
const tried = /* @__PURE__ */ new Set();
|
|
2353
2511
|
let pickWhy = "";
|
|
2354
2512
|
for (;;) {
|
|
2355
|
-
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, req.model, messages, req.session);
|
|
2356
|
-
if (uid === void 0)
|
|
2513
|
+
const uid = onlyUid !== void 0 ? s.accounts().some((a) => a.uid === onlyUid) && !tried.has(onlyUid) ? onlyUid : void 0 : pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, req.model, messages, req.session);
|
|
2514
|
+
if (uid === void 0) {
|
|
2515
|
+
if (onlyUid !== void 0 && trace !== void 0) {
|
|
2516
|
+
trace.lastState = "unknown";
|
|
2517
|
+
trace.lastError = `连接 ${JSON.stringify(onlyUid)} 不存在(可能已被删除)`;
|
|
2518
|
+
}
|
|
2519
|
+
return false;
|
|
2520
|
+
}
|
|
2521
|
+
if (firstByteDeadlineAt - Date.now() <= 0) {
|
|
2522
|
+
if (trace !== void 0) {
|
|
2523
|
+
trace.lastState = "transport";
|
|
2524
|
+
trace.lastError = "first-byte budget exhausted";
|
|
2525
|
+
}
|
|
2526
|
+
return false;
|
|
2527
|
+
}
|
|
2357
2528
|
pickWhy = pool.lastWhy;
|
|
2358
2529
|
tried.add(uid);
|
|
2359
2530
|
const r = await s.chatOnce(uid, req.lv ?? "auto", req);
|
|
@@ -2384,7 +2555,7 @@ var Router = class {
|
|
|
2384
2555
|
probe.model = `${s.getAlias()}/${stripAlias(req.model, s.getAlias())}`;
|
|
2385
2556
|
probe.uid = uid;
|
|
2386
2557
|
}
|
|
2387
|
-
if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0)) {
|
|
2558
|
+
if (await writeChatResult(res, r, req.stream, probe, probe?.startedAt ?? 0, firstByteDeadlineAt - Date.now())) {
|
|
2388
2559
|
pool.noteCache(uid, req.model, probe?.tokens?.cachedTokens ?? 0, req.session);
|
|
2389
2560
|
return true;
|
|
2390
2561
|
}
|
|
@@ -2396,11 +2567,17 @@ var Router = class {
|
|
|
2396
2567
|
}
|
|
2397
2568
|
}
|
|
2398
2569
|
}
|
|
2399
|
-
/**
|
|
2400
|
-
*
|
|
2401
|
-
*
|
|
2402
|
-
|
|
2403
|
-
|
|
2570
|
+
/**
|
|
2571
|
+
* 测试某供应商的某模型是否可用。
|
|
2572
|
+
*
|
|
2573
|
+
* 走真实的账号遍历 + chatOnce 路径:账号池回退/冷却由核心实现,自动生效;
|
|
2574
|
+
* 响应丢弃到 sink,限定单一供应商(不跨供应商回退)。
|
|
2575
|
+
*
|
|
2576
|
+
* @param onlyUid - 限定只测这一个连接(诊断单个链接),**不做账号池回退**。
|
|
2577
|
+
* 不传 = 与面板「测试」按钮完全同一条路径(池内任选可用号,失败自动换号)。
|
|
2578
|
+
*/
|
|
2579
|
+
async testModel(supplierId, model, onlyUid) {
|
|
2580
|
+
const s = this.active.find((x) => x.id === supplierId);
|
|
2404
2581
|
if (s === void 0) return {
|
|
2405
2582
|
ok: false,
|
|
2406
2583
|
error: `unknown supplier ${JSON.stringify(supplierId)}`
|
|
@@ -2422,7 +2599,7 @@ var Router = class {
|
|
|
2422
2599
|
let served = false;
|
|
2423
2600
|
const trace = { attempts: 0 };
|
|
2424
2601
|
try {
|
|
2425
|
-
served = await this.chatWithSupplier(s, req, sink, trace);
|
|
2602
|
+
served = await this.chatWithSupplier(s, req, sink, trace, void 0, Date.now() + this.firstByteBudgetMs, onlyUid);
|
|
2426
2603
|
} catch (err) {
|
|
2427
2604
|
return {
|
|
2428
2605
|
ok: false,
|
|
@@ -2455,8 +2632,43 @@ var Router = class {
|
|
|
2455
2632
|
* `image` 块序列化成 OpenAI 标准的 `image_url` base64 part,经 /v1 原样透传给
|
|
2456
2633
|
* 命中的上游供应商。最终能否看图取决于**命中的那个上游模型**——组合里是异构
|
|
2457
2634
|
* 供应商,可能在网关端声明了图片能力、某条上游却只收文本(此时由上游自行拒收)。
|
|
2458
|
-
* 图片字节从 `ctx.attachments`
|
|
2635
|
+
* 图片字节从 `ctx.attachments` 读取。读不到时按稳定占位文本降级(不让整条消息失败),
|
|
2636
|
+
* 但**必须留痕**:降级原因走 adapter 的 `log` 出口打到宿主 logger。曾经这里是
|
|
2637
|
+
* 一个空 `catch {}`,字段改名导致的读图失败静默了跨两个内测版本(issue #8)。
|
|
2459
2638
|
*/
|
|
2639
|
+
/** 送上游的请求图的像素预算(宽 × 高),沿用 0.1.5 时代 `maxPixels` 的意图。 */
|
|
2640
|
+
const MAX_REQUEST_PIXELS = 64e4;
|
|
2641
|
+
/** 送上游的请求图的字节上限。 */
|
|
2642
|
+
const MAX_REQUEST_BYTES = 1048576;
|
|
2643
|
+
/**
|
|
2644
|
+
* 把源图尺寸投影到「总像素 ≤ maxPixels」的请求目标(等比、只缩不放)。
|
|
2645
|
+
*
|
|
2646
|
+
* 为什么需要它:宿主 0.1.6 起要的是 `ImageRequestTarget`(width/height/maxBytes),
|
|
2647
|
+
* 而「别把一张 12MP 手机照原样塞进对话」这个意图原本靠 `maxPixels` 表达。
|
|
2648
|
+
* 宿主自己**只按 target 的长边**缩放、短边由它推,所以我们要做的只是给出一组
|
|
2649
|
+
* 落在预算内的正整数;短边取 floor 即可(它只参与校验与缓存键,不决定出图尺寸)。
|
|
2650
|
+
*
|
|
2651
|
+
* 没有直接复用官方的 `requestImageDimensions`(`@deepseek-ai/dsh-attachment` 有导出):
|
|
2652
|
+
* 本文件刻意不依赖那个包(见 `RouterAttachmentStore` 的说明 —— 它是宿主注入的
|
|
2653
|
+
* devDependency,引入它等于给插件加一条编译期/运行期依赖)。这 8 行是纯函数、
|
|
2654
|
+
* 有测试钉住;真要换成官方实现时,语义等价。
|
|
2655
|
+
*/
|
|
2656
|
+
function projectImageDimensions(width, height, maxPixels) {
|
|
2657
|
+
const pixels = width * height;
|
|
2658
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) return {
|
|
2659
|
+
width,
|
|
2660
|
+
height
|
|
2661
|
+
};
|
|
2662
|
+
if (pixels <= maxPixels) return {
|
|
2663
|
+
width,
|
|
2664
|
+
height
|
|
2665
|
+
};
|
|
2666
|
+
const scale = Math.sqrt(maxPixels / pixels);
|
|
2667
|
+
return {
|
|
2668
|
+
width: Math.max(1, Math.floor(width * scale)),
|
|
2669
|
+
height: Math.max(1, Math.floor(height * scale))
|
|
2670
|
+
};
|
|
2671
|
+
}
|
|
2460
2672
|
/**
|
|
2461
2673
|
* 把 DSH 消息序列化成 openai 兼容 wire 消息(文本 + 图片 + tool)。
|
|
2462
2674
|
*
|
|
@@ -2491,7 +2703,7 @@ const TOOL_RESULT_IMAGE_TEXT = "Tool result images";
|
|
|
2491
2703
|
* 只守空闲、不封总时长:长生成只要持续吐块就不该被打断。
|
|
2492
2704
|
*/
|
|
2493
2705
|
const STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
2494
|
-
async function wireMessages(options, system, attachments) {
|
|
2706
|
+
async function wireMessages(options, system, attachments, log) {
|
|
2495
2707
|
const out = [];
|
|
2496
2708
|
/**
|
|
2497
2709
|
* 攒着「tool-result 里带出来的图片」,等这一串 tool 消息**发完**再合并成一条
|
|
@@ -2551,7 +2763,7 @@ async function wireMessages(options, system, attachments) {
|
|
|
2551
2763
|
}
|
|
2552
2764
|
if (message.role === "tool") {
|
|
2553
2765
|
const toolCallId = message.toolCallId;
|
|
2554
|
-
const parts = await contentParts(message.content, attachments, options.signal);
|
|
2766
|
+
const parts = await contentParts(message.content, attachments, options.signal, log);
|
|
2555
2767
|
const imageParts = parts.filter((p) => p.type !== "text");
|
|
2556
2768
|
const text = parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
|
|
2557
2769
|
out.push({
|
|
@@ -2563,7 +2775,7 @@ async function wireMessages(options, system, attachments) {
|
|
|
2563
2775
|
continue;
|
|
2564
2776
|
}
|
|
2565
2777
|
const toolResults = message.content.filter((b) => b.type === "tool-result");
|
|
2566
|
-
const ownParts = await contentParts(message.content.filter((b) => b.type !== "tool-result"), attachments, options.signal);
|
|
2778
|
+
const ownParts = await contentParts(message.content.filter((b) => b.type !== "tool-result"), attachments, options.signal, log);
|
|
2567
2779
|
if (ownParts.length > 0 || toolResults.length === 0) {
|
|
2568
2780
|
flushToolImages();
|
|
2569
2781
|
out.push({
|
|
@@ -2572,7 +2784,7 @@ async function wireMessages(options, system, attachments) {
|
|
|
2572
2784
|
});
|
|
2573
2785
|
}
|
|
2574
2786
|
for (const result of toolResults) {
|
|
2575
|
-
const parts = await contentParts(result.content, attachments, options.signal);
|
|
2787
|
+
const parts = await contentParts(result.content, attachments, options.signal, log);
|
|
2576
2788
|
const imageParts = parts.filter((p) => p.type !== "text");
|
|
2577
2789
|
const text = parts.filter((p) => p.type === "text").map((p) => p.text ?? "").join("");
|
|
2578
2790
|
out.push({
|
|
@@ -2603,7 +2815,7 @@ function compactUserContent(parts) {
|
|
|
2603
2815
|
* 纯文本时返回空数组(上层走紧凑字符串路径)。图片转 base64 data URI;
|
|
2604
2816
|
* attachments 缺失或读取失败时回退稳定占位文本(绝不静默丢图)。
|
|
2605
2817
|
*/
|
|
2606
|
-
async function contentParts(blocks, attachments, signal) {
|
|
2818
|
+
async function contentParts(blocks, attachments, signal, log) {
|
|
2607
2819
|
const parts = [];
|
|
2608
2820
|
for (const block of blocks) {
|
|
2609
2821
|
if (block.type === "text") {
|
|
@@ -2616,35 +2828,41 @@ async function contentParts(blocks, attachments, signal) {
|
|
|
2616
2828
|
}
|
|
2617
2829
|
if (block.type === "image") {
|
|
2618
2830
|
const ref = block.attachment;
|
|
2619
|
-
parts.push(...await imageParts(ref, attachments, signal));
|
|
2831
|
+
parts.push(...await imageParts(ref, attachments, signal, log));
|
|
2620
2832
|
continue;
|
|
2621
2833
|
}
|
|
2622
|
-
if (block.type === "tool-result") parts.push(...await contentParts(block.content, attachments, signal));
|
|
2834
|
+
if (block.type === "tool-result") parts.push(...await contentParts(block.content, attachments, signal, log));
|
|
2623
2835
|
}
|
|
2624
2836
|
return parts;
|
|
2625
2837
|
}
|
|
2626
2838
|
/** 一张图片 → 一个 OpenAI image_url part(或读不到附件时的占位文本)。 */
|
|
2627
|
-
async function imageParts(ref, attachments, signal) {
|
|
2628
|
-
if (attachments === void 0)
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2839
|
+
async function imageParts(ref, attachments, signal, log) {
|
|
2840
|
+
if (attachments === void 0) {
|
|
2841
|
+
log?.("图片附件 store 不可用(ctx.attachments 未注入),该图降级为占位文本");
|
|
2842
|
+
return [{
|
|
2843
|
+
type: "text",
|
|
2844
|
+
text: imagePlaceholder(ref.attachmentId)
|
|
2845
|
+
}];
|
|
2846
|
+
}
|
|
2847
|
+
const ref0 = {
|
|
2848
|
+
attachmentId: ref.attachmentId,
|
|
2849
|
+
mediaType: ref.mediaType,
|
|
2850
|
+
bytes: ref.bytes,
|
|
2851
|
+
width: ref.width,
|
|
2852
|
+
height: ref.height
|
|
2853
|
+
};
|
|
2854
|
+
const target = {
|
|
2855
|
+
...projectImageDimensions(ref.width, ref.height, MAX_REQUEST_PIXELS),
|
|
2856
|
+
maxBytes: MAX_REQUEST_BYTES
|
|
2857
|
+
};
|
|
2632
2858
|
try {
|
|
2633
|
-
const img = await attachments.readImageRequest(
|
|
2634
|
-
attachmentId: ref.attachmentId,
|
|
2635
|
-
mediaType: ref.mediaType,
|
|
2636
|
-
bytes: ref.bytes,
|
|
2637
|
-
width: ref.width,
|
|
2638
|
-
height: ref.height
|
|
2639
|
-
}, {
|
|
2640
|
-
maxPixels: 64e4,
|
|
2641
|
-
maxBytes: 1048576
|
|
2642
|
-
}, signal);
|
|
2859
|
+
const img = await attachments.readImageRequest(ref0, target, signal);
|
|
2643
2860
|
return [{
|
|
2644
2861
|
type: "image_url",
|
|
2645
2862
|
image_url: { url: `data:${img.mediaType};base64,${bytesToBase64(img.data)}` }
|
|
2646
2863
|
}];
|
|
2647
|
-
} catch {
|
|
2864
|
+
} catch (err) {
|
|
2865
|
+
log?.(`读取图片 ${ref.attachmentId} 失败,已降级为占位文本:${err.message}`);
|
|
2648
2866
|
return [{
|
|
2649
2867
|
type: "text",
|
|
2650
2868
|
text: imagePlaceholder(ref.attachmentId)
|
|
@@ -2663,8 +2881,8 @@ function imagePlaceholder(attachmentId) {
|
|
|
2663
2881
|
return `[image omitted because the attached bytes could not be read; attachment ${attachmentId}]`;
|
|
2664
2882
|
}
|
|
2665
2883
|
/** 组装 wire 请求体。图片序列化需要读 attachments,故为异步。 */
|
|
2666
|
-
async function wireRequest(options, attachments) {
|
|
2667
|
-
const messages = await wireMessages(options, options.system, attachments);
|
|
2884
|
+
async function wireRequest(options, attachments, log) {
|
|
2885
|
+
const messages = await wireMessages(options, options.system, attachments, log);
|
|
2668
2886
|
const body = {
|
|
2669
2887
|
model: options.model,
|
|
2670
2888
|
messages,
|
|
@@ -2942,21 +3160,31 @@ const ROUTER_REASONING_EFFORTS = [
|
|
|
2942
3160
|
}
|
|
2943
3161
|
];
|
|
2944
3162
|
var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
3163
|
+
/**
|
|
3164
|
+
* 网关端点。**函数形态 = 每次请求现算**:端口要等宿主 listen 完才有值的场景
|
|
3165
|
+
* 必须传函数(写死 3080 在桌面端等非 3080 宿主上全线 `fetch failed`,
|
|
3166
|
+
* issue #9),见 `host-base-url.ts`。
|
|
3167
|
+
*/
|
|
2945
3168
|
baseURL;
|
|
2946
3169
|
source;
|
|
2947
3170
|
resolveAttachments;
|
|
2948
3171
|
/** 流空闲超时(ms):上游这么久不吐字节就判 TIMEOUT。 */
|
|
2949
3172
|
idleTimeoutMs;
|
|
3173
|
+
/** 降级/失败诊断的出口(宿主接 ctx.logger.warn)。缺省丢弃 —— 仅为测试可注入。 */
|
|
3174
|
+
log;
|
|
2950
3175
|
/**
|
|
2951
3176
|
* @param idleTimeoutMs 流空闲超时;缺省用生产值(300s,对齐 pi-ai)。
|
|
2952
3177
|
* 可注入只为测试能压到几百毫秒,生产不传。
|
|
3178
|
+
* @param log 降级诊断出口。图片读不到时会降级成占位文本,原因走这里 ——
|
|
3179
|
+
* 曾经那条路径是个空 `catch`,出问题跨版本都没人发现(issue #8)。
|
|
2953
3180
|
*/
|
|
2954
|
-
constructor(baseURL, source, resolveAttachments, idleTimeoutMs = STREAM_IDLE_TIMEOUT_MS) {
|
|
3181
|
+
constructor(baseURL, source, resolveAttachments, idleTimeoutMs = STREAM_IDLE_TIMEOUT_MS, log) {
|
|
2955
3182
|
super();
|
|
2956
3183
|
this.baseURL = baseURL;
|
|
2957
3184
|
this.source = source;
|
|
2958
3185
|
this.resolveAttachments = resolveAttachments ?? (() => void 0);
|
|
2959
3186
|
this.idleTimeoutMs = idleTimeoutMs;
|
|
3187
|
+
this.log = log;
|
|
2960
3188
|
}
|
|
2961
3189
|
providerInfo(provider) {
|
|
2962
3190
|
return {
|
|
@@ -3003,13 +3231,14 @@ var RouterAdapter = class RouterAdapter extends LlmAdapter {
|
|
|
3003
3231
|
};
|
|
3004
3232
|
}
|
|
3005
3233
|
async *stream(options) {
|
|
3006
|
-
const body = await wireRequest(options, this.resolveAttachments());
|
|
3234
|
+
const body = await wireRequest(options, this.resolveAttachments(), this.log);
|
|
3007
3235
|
const controller = new AbortController();
|
|
3008
3236
|
const onAbort = () => controller.abort();
|
|
3009
3237
|
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
3010
3238
|
let resp;
|
|
3011
3239
|
try {
|
|
3012
|
-
|
|
3240
|
+
const base = typeof this.baseURL === "function" ? this.baseURL() : this.baseURL;
|
|
3241
|
+
resp = await fetch(`${base}/chat/completions`, {
|
|
3013
3242
|
method: "POST",
|
|
3014
3243
|
headers: {
|
|
3015
3244
|
...attributionHeaders(),
|
|
@@ -3243,12 +3472,18 @@ var KeysStore = class {
|
|
|
3243
3472
|
//#endregion
|
|
3244
3473
|
//#region src/suppliers/loader.ts
|
|
3245
3474
|
/**
|
|
3246
|
-
* 供应商 loader —— 扫描
|
|
3475
|
+
* 供应商 loader —— 扫描 **用户目录**下的 *.js,动态 import,按契约包装成
|
|
3247
3476
|
* 内部 Supplier,并暴露"能力检测"(capabilities)供通用路由/前端按能力渲染。
|
|
3248
3477
|
*
|
|
3249
|
-
*
|
|
3250
|
-
* 1.
|
|
3251
|
-
* 2.
|
|
3478
|
+
* 加载来源(两处):
|
|
3479
|
+
* 1. 用户:`~/.dsh/profiles/<profile>/suppliers/*.js`(自定义供应商)
|
|
3480
|
+
* 2. 内置:**插件页「包含的组件」里的行** —— core 自己的 cordis.patch.yml
|
|
3481
|
+
* insert 了三个子路径模块,经 `router.suppliers` 表进来,与独立安装的
|
|
3482
|
+
* 供应商插件同一条通道,核心不区分内外,只看工厂上的 `source` 标签。
|
|
3483
|
+
*
|
|
3484
|
+
* 内置曾经也是「扫 `<plugin>/lib/suppliers/*.js`」(本文件早先的 builtinDir)。
|
|
3485
|
+
* 改成行之后那条路径已删:扫目录拿不到行的身份(插件页的开关/显示名/说明),
|
|
3486
|
+
* 而且关一个内置供应商要手改配置才生效 —— 行才有宿主管的开关。
|
|
3252
3487
|
*/
|
|
3253
3488
|
function isFn(v) {
|
|
3254
3489
|
return typeof v === "function";
|
|
@@ -3384,7 +3619,27 @@ function scanDir(dir) {
|
|
|
3384
3619
|
return [];
|
|
3385
3620
|
}
|
|
3386
3621
|
}
|
|
3387
|
-
/**
|
|
3622
|
+
/**
|
|
3623
|
+
* 同 id 冲突时的优先级:**用户目录 js > 内置行 > 外部插件供应商**。
|
|
3624
|
+
*
|
|
3625
|
+
* 为什么用户目录最高:用户放一个同 id 的 js 就是想替换它,这是「扫目录时代」
|
|
3626
|
+
* 就有的语义(见本文件早先的 `loadSuppliers`:内置被用户版覆盖),内置改成行
|
|
3627
|
+
* 之后不能悄悄反过来。
|
|
3628
|
+
*
|
|
3629
|
+
* 纯函数 + 有测试:这条规则错了不会抛异常,只会让用户的覆盖悄悄失效。
|
|
3630
|
+
*/
|
|
3631
|
+
const SUPPLIER_RANK = {
|
|
3632
|
+
user: 2,
|
|
3633
|
+
builtin: 1,
|
|
3634
|
+
external: 0
|
|
3635
|
+
};
|
|
3636
|
+
function supplierWins(incoming, existing) {
|
|
3637
|
+
return SUPPLIER_RANK[incoming] > SUPPLIER_RANK[existing];
|
|
3638
|
+
}
|
|
3639
|
+
/**
|
|
3640
|
+
* 加载用户目录下的供应商。同一个 id 出现多个文件时**后者覆盖前者**(按文件名序),
|
|
3641
|
+
* 与内置共存的裁决交给核心(`registerLoaded` 的优先级:用户目录 js > 行)。
|
|
3642
|
+
*/
|
|
3388
3643
|
async function loadSuppliers(opts) {
|
|
3389
3644
|
const env = {
|
|
3390
3645
|
dataDir: opts.dataDir,
|
|
@@ -3394,18 +3649,10 @@ async function loadSuppliers(opts) {
|
|
|
3394
3649
|
};
|
|
3395
3650
|
const byId = /* @__PURE__ */ new Map();
|
|
3396
3651
|
const errors = [];
|
|
3397
|
-
const
|
|
3398
|
-
f,
|
|
3399
|
-
builtin: true
|
|
3400
|
-
})), ...scanDir(opts.userDir).map((f) => ({
|
|
3401
|
-
f,
|
|
3402
|
-
builtin: false
|
|
3403
|
-
}))];
|
|
3404
|
-
for (const { f, builtin } of files) try {
|
|
3405
|
-
const loaded = await loadOne(f, env, builtin ? "builtin" : "user");
|
|
3406
|
-
if (byId.get(loaded.supplier.id) && builtin) continue;
|
|
3652
|
+
for (const f of scanDir(opts.userDir).sort()) try {
|
|
3653
|
+
const loaded = await loadOne(f, env, "user");
|
|
3407
3654
|
byId.set(loaded.supplier.id, loaded);
|
|
3408
|
-
opts.log(`supplier ${loaded.supplier.id} loaded (
|
|
3655
|
+
opts.log(`supplier ${loaded.supplier.id} loaded (user) from ${basename(f)}`);
|
|
3409
3656
|
} catch (err) {
|
|
3410
3657
|
errors.push({
|
|
3411
3658
|
id: basename(f, ".js"),
|
|
@@ -3420,6 +3667,355 @@ async function loadSuppliers(opts) {
|
|
|
3420
3667
|
};
|
|
3421
3668
|
}
|
|
3422
3669
|
//#endregion
|
|
3670
|
+
//#region src/suppliers/probe.ts
|
|
3671
|
+
/**
|
|
3672
|
+
* 契约成员清单(体检的唯一事实来源)。
|
|
3673
|
+
*
|
|
3674
|
+
* `required: false` = 差异化能力,按存在性暴露端点/UI;`probe: 'skip'` 的理由都写在这里,
|
|
3675
|
+
* 面板会把 `skipReason` 原样显示 —— 用户能知道「有这个能力,只是体检不替你调」。
|
|
3676
|
+
*/
|
|
3677
|
+
const SUPPLIER_CONTRACT_MEMBERS = [
|
|
3678
|
+
{
|
|
3679
|
+
key: "id",
|
|
3680
|
+
label: "供应商 id",
|
|
3681
|
+
required: true,
|
|
3682
|
+
probe: "safe"
|
|
3683
|
+
},
|
|
3684
|
+
{
|
|
3685
|
+
key: "name",
|
|
3686
|
+
label: "显示名",
|
|
3687
|
+
required: true,
|
|
3688
|
+
probe: "safe"
|
|
3689
|
+
},
|
|
3690
|
+
{
|
|
3691
|
+
key: "priority",
|
|
3692
|
+
label: "优先级",
|
|
3693
|
+
required: false,
|
|
3694
|
+
probe: "safe"
|
|
3695
|
+
},
|
|
3696
|
+
{
|
|
3697
|
+
key: "icon",
|
|
3698
|
+
label: "图标",
|
|
3699
|
+
required: false,
|
|
3700
|
+
probe: "safe"
|
|
3701
|
+
},
|
|
3702
|
+
{
|
|
3703
|
+
key: "apiKeyHint",
|
|
3704
|
+
label: "API key 提示文案",
|
|
3705
|
+
required: false,
|
|
3706
|
+
probe: "safe"
|
|
3707
|
+
},
|
|
3708
|
+
{
|
|
3709
|
+
key: "status",
|
|
3710
|
+
label: "账号状态",
|
|
3711
|
+
required: true,
|
|
3712
|
+
probe: "safe"
|
|
3713
|
+
},
|
|
3714
|
+
{
|
|
3715
|
+
key: "listModels",
|
|
3716
|
+
label: "模型列表",
|
|
3717
|
+
required: true,
|
|
3718
|
+
probe: "safe"
|
|
3719
|
+
},
|
|
3720
|
+
{
|
|
3721
|
+
key: "chatOnce",
|
|
3722
|
+
label: "调用上游",
|
|
3723
|
+
required: true,
|
|
3724
|
+
probe: "skip",
|
|
3725
|
+
skipReason: "会真的发一次请求(消耗额度)。用上面的「跑一次访问测试」单独测,或在供应商详情里测模型。"
|
|
3726
|
+
},
|
|
3727
|
+
{
|
|
3728
|
+
key: "checkinNow",
|
|
3729
|
+
label: "签到",
|
|
3730
|
+
required: false,
|
|
3731
|
+
probe: "skip",
|
|
3732
|
+
skipReason: "会替这个连接真的去签到。"
|
|
3733
|
+
}
|
|
3734
|
+
];
|
|
3735
|
+
/** 读原始模块实例(`wrapModule` 挂在 wrapper 上)。 */
|
|
3736
|
+
function rawModule(loaded) {
|
|
3737
|
+
return loaded.supplier.__module ?? {};
|
|
3738
|
+
}
|
|
3739
|
+
/** 读出账号状态列表(只读,不改任何东西)。 */
|
|
3740
|
+
function accountStates(status) {
|
|
3741
|
+
if (typeof status !== "object" || status === null) return [];
|
|
3742
|
+
const accounts = status.accounts;
|
|
3743
|
+
if (!Array.isArray(accounts)) return [];
|
|
3744
|
+
return accounts.flatMap((a) => {
|
|
3745
|
+
const uid = a?.uid;
|
|
3746
|
+
if (typeof uid !== "string") return [];
|
|
3747
|
+
return [{
|
|
3748
|
+
uid,
|
|
3749
|
+
state: typeof a.state === "string" ? a.state : "ok",
|
|
3750
|
+
cooling: a.cooling === true
|
|
3751
|
+
}];
|
|
3752
|
+
});
|
|
3753
|
+
}
|
|
3754
|
+
/** 第一个连接 uid(`checkinNow` 之类要真实连接时用)。 */
|
|
3755
|
+
function firstAccountUid(m) {
|
|
3756
|
+
return accountStates(m.status())[0]?.uid;
|
|
3757
|
+
}
|
|
3758
|
+
/** 账号状态分布:`正常 2、冷却 1`、`session_dead 1` 这样。 */
|
|
3759
|
+
function accountTally(accounts) {
|
|
3760
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3761
|
+
for (const a of accounts) {
|
|
3762
|
+
const key = a.cooling ? "冷却" : a.state === "ok" ? "正常" : a.state;
|
|
3763
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
3764
|
+
}
|
|
3765
|
+
return [...counts].map(([k, n]) => `${k} ${n}`).join("、");
|
|
3766
|
+
}
|
|
3767
|
+
/**
|
|
3768
|
+
* 摘要:把返回值压成一行**对排查有用**的文本。
|
|
3769
|
+
*
|
|
3770
|
+
* 原先只说「返回 number」「返回 data:image/svg+xml,%3Csvg…」——等于没说话:
|
|
3771
|
+
* 体检的意义就是让人一眼看出「priority 是几」「图标给了没有」。
|
|
3772
|
+
*/
|
|
3773
|
+
function describe(value) {
|
|
3774
|
+
if (value === void 0) return "未提供(可选)";
|
|
3775
|
+
if (value === null) return "返回 null";
|
|
3776
|
+
if (Array.isArray(value)) return `${value.length} 项`;
|
|
3777
|
+
if (typeof value === "string") {
|
|
3778
|
+
if (value.startsWith("data:")) return `已提供(内联 ${value.slice(5, value.indexOf("/") || 12)})`;
|
|
3779
|
+
if (value === "") return "返回空串";
|
|
3780
|
+
return value.length > 48 ? `返回 ${value.slice(0, 48)}…` : `返回 ${value}`;
|
|
3781
|
+
}
|
|
3782
|
+
if (typeof value === "number" || typeof value === "boolean") return `返回 ${value}`;
|
|
3783
|
+
if (typeof value === "object") {
|
|
3784
|
+
const o = value;
|
|
3785
|
+
if (typeof o.ok === "boolean") return o.ok === false ? `ok:false ${String(o.error ?? o.message ?? "")}`.trim() : "ok:true";
|
|
3786
|
+
return Object.keys(o).slice(0, 4).join(", ") || "空对象";
|
|
3787
|
+
}
|
|
3788
|
+
return `返回 ${typeof value}`;
|
|
3789
|
+
}
|
|
3790
|
+
/** 异步取一个值并压成摘要(同步抛错也要接住)。 */
|
|
3791
|
+
async function probeValue(call) {
|
|
3792
|
+
try {
|
|
3793
|
+
return {
|
|
3794
|
+
ok: true,
|
|
3795
|
+
detail: describe(await call())
|
|
3796
|
+
};
|
|
3797
|
+
} catch (err) {
|
|
3798
|
+
return {
|
|
3799
|
+
ok: false,
|
|
3800
|
+
detail: err.message
|
|
3801
|
+
};
|
|
3802
|
+
}
|
|
3803
|
+
}
|
|
3804
|
+
/**
|
|
3805
|
+
*(这里原本有一份「永不实跑」名单:`dispose` / `addApiKey`。
|
|
3806
|
+
* 两者都已移出体检清单,名单随之作废 —— 留着空机制比删掉更糟:
|
|
3807
|
+
* 它看起来像个可配置的开关,其实没有任何成员会命中。
|
|
3808
|
+
* 「为什么要移」见 PROBE_EXCLUDED_MEMBERS。)
|
|
3809
|
+
*/
|
|
3810
|
+
/** 深度档用来「不碰真实状态就跑通代码路径」的探针输入。 */
|
|
3811
|
+
const PROBE_UID = "__probe_no_such_uid__";
|
|
3812
|
+
const PROBE_CALLBACK = "https://invalid.example/__probe__";
|
|
3813
|
+
/**
|
|
3814
|
+
* 体检一个已装载的供应商。
|
|
3815
|
+
*
|
|
3816
|
+
* 只跑 `probe: 'safe'` 的成员;`skip` 的只报存在性。绝不代替用户调有副作用的成员。
|
|
3817
|
+
*
|
|
3818
|
+
* @param models - 该供应商当前的模型启用状态(由调用方用 `router.modelsOf` 取,
|
|
3819
|
+
* 走核心缓存不额外打上游)。取不到就传 undefined —— 报告里会如实写「拿不到」,
|
|
3820
|
+
* 不能拿 0 冒充「一个模型都没有」。
|
|
3821
|
+
*/
|
|
3822
|
+
async function probeSupplier(loaded, input = {}) {
|
|
3823
|
+
const m = rawModule(loaded);
|
|
3824
|
+
const members = [];
|
|
3825
|
+
for (const member of SUPPLIER_CONTRACT_MEMBERS) {
|
|
3826
|
+
const present = member.key in m ? m[member.key] !== void 0 : false;
|
|
3827
|
+
const base = {
|
|
3828
|
+
key: member.key,
|
|
3829
|
+
label: member.label,
|
|
3830
|
+
required: member.required,
|
|
3831
|
+
present,
|
|
3832
|
+
executed: present ? "ran" : "no"
|
|
3833
|
+
};
|
|
3834
|
+
if (!present) {
|
|
3835
|
+
members.push({
|
|
3836
|
+
...base,
|
|
3837
|
+
state: member.required ? "fail" : "absent",
|
|
3838
|
+
...member.required ? { detail: "必填成员缺失,插件不完整" } : {}
|
|
3839
|
+
});
|
|
3840
|
+
continue;
|
|
3841
|
+
}
|
|
3842
|
+
const outcome = member.probe === "safe" ? await runSafeProbe(member.key, m, input) : await runProbedMember(member.key, m, input);
|
|
3843
|
+
members.push({
|
|
3844
|
+
...base,
|
|
3845
|
+
state: outcome.ok ? "ok" : outcome.detail.startsWith("无法验证") ? "unverified" : "fail",
|
|
3846
|
+
detail: outcome.detail
|
|
3847
|
+
});
|
|
3848
|
+
}
|
|
3849
|
+
const bulkResult = await runBulkRoundTrip(input);
|
|
3850
|
+
input.models;
|
|
3851
|
+
const summary = {
|
|
3852
|
+
total: members.length,
|
|
3853
|
+
implemented: members.filter((x) => x.present).length,
|
|
3854
|
+
ok: members.filter((x) => x.state === "ok").length,
|
|
3855
|
+
fail: members.filter((x) => x.state === "fail").length,
|
|
3856
|
+
absent: members.filter((x) => x.state === "absent").length,
|
|
3857
|
+
unverified: members.filter((x) => x.state === "unverified").length
|
|
3858
|
+
};
|
|
3859
|
+
const listModels = members.find((x) => x.key === "listModels");
|
|
3860
|
+
return {
|
|
3861
|
+
supplier: loaded.supplier.id,
|
|
3862
|
+
name: loaded.supplier.name,
|
|
3863
|
+
members,
|
|
3864
|
+
core: await coreReport(input, listModels?.state === "ok", bulkResult),
|
|
3865
|
+
summary: {
|
|
3866
|
+
...summary,
|
|
3867
|
+
ran: members.filter((x) => x.executed === "ran" && x.present).length,
|
|
3868
|
+
bulk: bulkResult.ran ? bulkResult.ok === true ? "ok" : "fail" : "unverified"
|
|
3869
|
+
}
|
|
3870
|
+
};
|
|
3871
|
+
}
|
|
3872
|
+
/**
|
|
3873
|
+
* 实跑「有副作用但可以安全试」的成员:要么用**无害探针输入**跑通代码路径,要么用
|
|
3874
|
+
* **连接池里的真实连接**真跑一次(前提由使用者保证:池里已放好可用 token)。
|
|
3875
|
+
*/
|
|
3876
|
+
async function runProbedMember(key, m, input) {
|
|
3877
|
+
try {
|
|
3878
|
+
switch (key) {
|
|
3879
|
+
case "chatOnce": {
|
|
3880
|
+
if (input.runChatOnce === void 0) return {
|
|
3881
|
+
ok: false,
|
|
3882
|
+
detail: "无法验证:没有发起真实调用的入口"
|
|
3883
|
+
};
|
|
3884
|
+
const model = input.models?.find((x) => x.enabled)?.id ?? input.models?.[0]?.id;
|
|
3885
|
+
if (model === void 0) return {
|
|
3886
|
+
ok: false,
|
|
3887
|
+
detail: "无法验证:没有已启用的模型(先启用至少一个)"
|
|
3888
|
+
};
|
|
3889
|
+
const r = await input.runChatOnce(model);
|
|
3890
|
+
return {
|
|
3891
|
+
ok: r.ok,
|
|
3892
|
+
detail: `真实调用 ${model}:${r.ok ? "通了" : `失败(${r.detail})`}`
|
|
3893
|
+
};
|
|
3894
|
+
}
|
|
3895
|
+
case "checkinNow": {
|
|
3896
|
+
const uid = firstAccountUid(m);
|
|
3897
|
+
if (uid === void 0) return {
|
|
3898
|
+
ok: false,
|
|
3899
|
+
detail: "无法验证:连接池里没有可用连接(先加一个 token)"
|
|
3900
|
+
};
|
|
3901
|
+
const v = await m.checkinNow?.(uid);
|
|
3902
|
+
return {
|
|
3903
|
+
ok: v?.ok !== false,
|
|
3904
|
+
detail: `对连接 ${uid} 真实签到:${describe(v)}`
|
|
3905
|
+
};
|
|
3906
|
+
}
|
|
3907
|
+
case "generateLoginUrl": return {
|
|
3908
|
+
ok: true,
|
|
3909
|
+
detail: describe(await m.generateLoginUrl?.())
|
|
3910
|
+
};
|
|
3911
|
+
case "completeLogin": return {
|
|
3912
|
+
ok: true,
|
|
3913
|
+
detail: `无效回调实跑:${describe(await m.completeLogin?.(PROBE_CALLBACK))}(没抛错即通过)`
|
|
3914
|
+
};
|
|
3915
|
+
case "removeLink": {
|
|
3916
|
+
const v = await m.removeLink?.(PROBE_UID);
|
|
3917
|
+
return {
|
|
3918
|
+
ok: v !== false,
|
|
3919
|
+
detail: `不存在的连接实跑:${describe(v)}`
|
|
3920
|
+
};
|
|
3921
|
+
}
|
|
3922
|
+
default: return {
|
|
3923
|
+
ok: true,
|
|
3924
|
+
detail: "未纳入自动实跑"
|
|
3925
|
+
};
|
|
3926
|
+
}
|
|
3927
|
+
} catch (err) {
|
|
3928
|
+
return {
|
|
3929
|
+
ok: false,
|
|
3930
|
+
detail: `实跑抛错(这本身就是一条问题):${err.message}`
|
|
3931
|
+
};
|
|
3932
|
+
}
|
|
3933
|
+
}
|
|
3934
|
+
/** 核心代劳的能力区:模型启用/禁用状态 + 那几个改配置的操作(只报可用性)。 */
|
|
3935
|
+
/** 核心区:模型启用状态 + 「全部启用/禁用」实跑结果。 */
|
|
3936
|
+
async function coreReport(input, modelsProbed, bulk) {
|
|
3937
|
+
const models = input.models;
|
|
3938
|
+
const enabled = models?.filter((m) => m.enabled).length;
|
|
3939
|
+
const disabled = models === void 0 ? void 0 : models.length - (enabled ?? 0);
|
|
3940
|
+
return {
|
|
3941
|
+
models: {
|
|
3942
|
+
total: models?.length ?? null,
|
|
3943
|
+
enabled: enabled ?? 0,
|
|
3944
|
+
disabled: disabled ?? 0,
|
|
3945
|
+
...models === void 0 ? { note: modelsProbed ? "拿不到模型列表" : "listModels 不可用,无法读启用状态" } : {}
|
|
3946
|
+
},
|
|
3947
|
+
operations: [{
|
|
3948
|
+
key: "models.bulk",
|
|
3949
|
+
label: "模型全部启用 / 全部禁用",
|
|
3950
|
+
ran: bulk.ran,
|
|
3951
|
+
...bulk.ok === void 0 ? {} : { ok: bulk.ok },
|
|
3952
|
+
detail: bulk.detail
|
|
3953
|
+
}]
|
|
3954
|
+
};
|
|
3955
|
+
}
|
|
3956
|
+
/**
|
|
3957
|
+
* 实跑「全部禁用 → 看列表 → 全部启用」。
|
|
3958
|
+
*
|
|
3959
|
+
* 违规的插件会在这里露馅:它把已禁用的模型从 `listModels` 里过滤掉了,于是全部禁用
|
|
3960
|
+
* 之后核心**看不到任何一个模型** —— 用户点「全部启用」也就再也点不回来了
|
|
3961
|
+
* (路由里没有可用的模型 id 可传)。
|
|
3962
|
+
*/
|
|
3963
|
+
async function runBulkRoundTrip(input) {
|
|
3964
|
+
if (input.runBulkToggleRoundTrip === void 0) return {
|
|
3965
|
+
ran: false,
|
|
3966
|
+
detail: "没有模型可禁用"
|
|
3967
|
+
};
|
|
3968
|
+
try {
|
|
3969
|
+
const r = await input.runBulkToggleRoundTrip();
|
|
3970
|
+
return {
|
|
3971
|
+
ran: true,
|
|
3972
|
+
ok: r.ok,
|
|
3973
|
+
detail: r.detail
|
|
3974
|
+
};
|
|
3975
|
+
} catch (err) {
|
|
3976
|
+
return {
|
|
3977
|
+
ran: true,
|
|
3978
|
+
ok: false,
|
|
3979
|
+
detail: `实跑抛错:${err.message}`
|
|
3980
|
+
};
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
/** 无副作用成员的实跑。**每个 case 都必须没有副作用** —— 新增成员时照此办理。 */
|
|
3984
|
+
async function runSafeProbe(key, m, input) {
|
|
3985
|
+
switch (key) {
|
|
3986
|
+
case "id":
|
|
3987
|
+
case "name":
|
|
3988
|
+
case "priority":
|
|
3989
|
+
case "icon":
|
|
3990
|
+
case "apiKeyHint": {
|
|
3991
|
+
const value = m[key];
|
|
3992
|
+
if (value === void 0) return {
|
|
3993
|
+
ok: true,
|
|
3994
|
+
detail: "未提供(可选)"
|
|
3995
|
+
};
|
|
3996
|
+
return {
|
|
3997
|
+
ok: true,
|
|
3998
|
+
detail: describe(value)
|
|
3999
|
+
};
|
|
4000
|
+
}
|
|
4001
|
+
case "status": {
|
|
4002
|
+
const r = await probeValue(() => m.status());
|
|
4003
|
+
if (!r.ok) return r;
|
|
4004
|
+
const accounts = accountStates(m.status());
|
|
4005
|
+
return {
|
|
4006
|
+
ok: true,
|
|
4007
|
+
detail: accounts.length === 0 ? "无账号(无账号直连型供应商)" : `${accounts.length} 个账号:${accountTally(accounts)}`
|
|
4008
|
+
};
|
|
4009
|
+
}
|
|
4010
|
+
case "listModels": return await probeValue(() => m.listModels());
|
|
4011
|
+
case "pollLogin": return await probeValue(() => m.pollLogin?.());
|
|
4012
|
+
default: return {
|
|
4013
|
+
ok: true,
|
|
4014
|
+
detail: "未纳入自动探测"
|
|
4015
|
+
};
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
4018
|
+
//#endregion
|
|
3423
4019
|
//#region src/suppliers/registry.ts
|
|
3424
4020
|
function writeJson$1(res, status, body) {
|
|
3425
4021
|
const payload = JSON.stringify(body);
|
|
@@ -3496,6 +4092,106 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
3496
4092
|
});
|
|
3497
4093
|
}
|
|
3498
4094
|
});
|
|
4095
|
+
routes.push({
|
|
4096
|
+
kind: "exact",
|
|
4097
|
+
path: `${p}/enabled`,
|
|
4098
|
+
handler: async (req, res) => {
|
|
4099
|
+
let body;
|
|
4100
|
+
try {
|
|
4101
|
+
body = JSON.parse(await readBody$1(req));
|
|
4102
|
+
} catch {
|
|
4103
|
+
writeJson$1(res, 400, {
|
|
4104
|
+
ok: false,
|
|
4105
|
+
error: "invalid JSON body"
|
|
4106
|
+
});
|
|
4107
|
+
return;
|
|
4108
|
+
}
|
|
4109
|
+
if (typeof body.enabled !== "boolean") {
|
|
4110
|
+
writeJson$1(res, 400, {
|
|
4111
|
+
ok: false,
|
|
4112
|
+
error: "enabled must be a boolean"
|
|
4113
|
+
});
|
|
4114
|
+
return;
|
|
4115
|
+
}
|
|
4116
|
+
store.setEnabled(s.id, body.enabled);
|
|
4117
|
+
router.invalidateModels(s.id);
|
|
4118
|
+
writeJson$1(res, 200, {
|
|
4119
|
+
ok: true,
|
|
4120
|
+
id: s.id,
|
|
4121
|
+
enabled: router.isEnabled(s.id)
|
|
4122
|
+
});
|
|
4123
|
+
}
|
|
4124
|
+
});
|
|
4125
|
+
routes.push({
|
|
4126
|
+
kind: "exact",
|
|
4127
|
+
path: `${p}/probe`,
|
|
4128
|
+
handler: async (req, res) => {
|
|
4129
|
+
if (req.method !== "POST") {
|
|
4130
|
+
writeJson$1(res, 405, {
|
|
4131
|
+
ok: false,
|
|
4132
|
+
error: "probe requires POST"
|
|
4133
|
+
});
|
|
4134
|
+
return;
|
|
4135
|
+
}
|
|
4136
|
+
try {
|
|
4137
|
+
let pick = {};
|
|
4138
|
+
const raw = (await readBody$1(req)).trim();
|
|
4139
|
+
if (raw !== "") {
|
|
4140
|
+
const body = JSON.parse(raw);
|
|
4141
|
+
pick = {
|
|
4142
|
+
...typeof body.model === "string" ? { model: body.model } : {},
|
|
4143
|
+
...typeof body.uid === "string" ? { uid: body.uid } : {}
|
|
4144
|
+
};
|
|
4145
|
+
}
|
|
4146
|
+
writeJson$1(res, 200, {
|
|
4147
|
+
ok: true,
|
|
4148
|
+
report: await probeSupplier(loaded, {
|
|
4149
|
+
models: await router.modelsOf(s.id).catch(() => void 0),
|
|
4150
|
+
runBulkToggleRoundTrip: async () => {
|
|
4151
|
+
const original = [...store.get(s.id).disabled];
|
|
4152
|
+
const ids = (await router.modelsOf(s.id)).map((mm) => mm.id);
|
|
4153
|
+
if (ids.length === 0) return {
|
|
4154
|
+
ok: true,
|
|
4155
|
+
detail: "该供应商没有模型,跳过"
|
|
4156
|
+
};
|
|
4157
|
+
try {
|
|
4158
|
+
store.setAllModelsEnabled(s.id, false, ids);
|
|
4159
|
+
router.invalidateModels(s.id);
|
|
4160
|
+
const after = await router.modelsOf(s.id, true);
|
|
4161
|
+
const hidden = after.filter((mm) => !mm.enabled).length;
|
|
4162
|
+
const missing = ids.length - after.length;
|
|
4163
|
+
if (hidden !== ids.length || missing !== 0) return {
|
|
4164
|
+
ok: false,
|
|
4165
|
+
detail: `全部禁用后只剩 ${after.length}/${ids.length} 个 —— 插件在 listModels 里藏了已禁用的模型(启用状态由核心合并)`
|
|
4166
|
+
};
|
|
4167
|
+
return {
|
|
4168
|
+
ok: true,
|
|
4169
|
+
detail: `全部禁用后 ${after.length} 个都在,再启用可恢复`
|
|
4170
|
+
};
|
|
4171
|
+
} finally {
|
|
4172
|
+
store.setAllModelsEnabled(s.id, true, ids);
|
|
4173
|
+
for (const id of original) store.setAllModelsEnabled(s.id, false, [id]);
|
|
4174
|
+
router.invalidateModels(s.id);
|
|
4175
|
+
await router.modelsOf(s.id, true);
|
|
4176
|
+
}
|
|
4177
|
+
},
|
|
4178
|
+
runChatOnce: async (model) => {
|
|
4179
|
+
const r = await router.testModel(s.id, model, pick.uid);
|
|
4180
|
+
return {
|
|
4181
|
+
ok: r.ok,
|
|
4182
|
+
detail: r.ok ? "通了" : r.error ?? "上游没给原因"
|
|
4183
|
+
};
|
|
4184
|
+
}
|
|
4185
|
+
})
|
|
4186
|
+
});
|
|
4187
|
+
} catch (err) {
|
|
4188
|
+
writeJson$1(res, 500, {
|
|
4189
|
+
ok: false,
|
|
4190
|
+
error: err.message
|
|
4191
|
+
});
|
|
4192
|
+
}
|
|
4193
|
+
}
|
|
4194
|
+
});
|
|
3499
4195
|
routes.push({
|
|
3500
4196
|
kind: "exact",
|
|
3501
4197
|
path: `${p}/alias`,
|
|
@@ -3629,7 +4325,8 @@ function supplierRoutes(base, loaded, store, router) {
|
|
|
3629
4325
|
path: `${p}/models/test`,
|
|
3630
4326
|
handler: async (req, res) => {
|
|
3631
4327
|
const body = JSON.parse(await readBody$1(req));
|
|
3632
|
-
const
|
|
4328
|
+
const onlyUid = typeof body.uid === "string" && body.uid !== "" ? body.uid : void 0;
|
|
4329
|
+
const result = await router.testModel(s.id, body.id ?? "", onlyUid);
|
|
3633
4330
|
writeJson$1(res, result.ok ? 200 : 400, result);
|
|
3634
4331
|
}
|
|
3635
4332
|
});
|
|
@@ -3874,6 +4571,47 @@ function dshHome() {
|
|
|
3874
4571
|
return resolve(value);
|
|
3875
4572
|
}
|
|
3876
4573
|
//#endregion
|
|
4574
|
+
//#region src/host-base-url.ts
|
|
4575
|
+
/**
|
|
4576
|
+
* 网关端点地址(`http://127.0.0.1:<宿主端口>/v1`)的解析。
|
|
4577
|
+
*
|
|
4578
|
+
* ## 为什么端口不能写死
|
|
4579
|
+
*
|
|
4580
|
+
* dsh-router **自己不开端口**:`/v1/*` 是注册在**宿主** web server 上的路由
|
|
4581
|
+
* (见 `index.ts` 的 `ctx.webServer.register`)。所以端点地址里的端口由启动通道
|
|
4582
|
+
* 决定,而不是插件决定 —— `dsh web` 默认 3080,桌面端官方通道、以及配置
|
|
4583
|
+
* `port: 0`(让 OS 分配)时都**不是** 3080。之前 adapter 把
|
|
4584
|
+
* `http://localhost:3080/v1` 写死在构造参数里,于是非 3080 的宿主上 Router
|
|
4585
|
+
* 的每一轮对话都是 `dsh-router upstream call failed: fetch failed`(issue #9)。
|
|
4586
|
+
*
|
|
4587
|
+
* ## 为什么返回函数而不是字符串
|
|
4588
|
+
*
|
|
4589
|
+
* `webServer.port` 是**监听之后**才有值的 getter(listen 前是 `undefined`),
|
|
4590
|
+
* 且宿主重启/换端口后它会变。所以这里返回函数、**每次请求现算**,不在注册时
|
|
4591
|
+
* 算一次 —— 注册发生在启动早期,那时算只会把「拿不到端口」固化成常量。
|
|
4592
|
+
*
|
|
4593
|
+
* ## 拿不到端口时为什么抛,而不是回退 3080
|
|
4594
|
+
*
|
|
4595
|
+
* 回退等于把同一个 bug 换种写法:端口读不到时宿主的路由也没在服务,回退到 3080
|
|
4596
|
+
* 只会重新得到一句无从查证的 `fetch failed`。这里抛明确的话,让它出现在
|
|
4597
|
+
* `LlmError` 消息里(adapter 在 fetch 同一个 try 里调它),报错自带原因。
|
|
4598
|
+
*/
|
|
4599
|
+
/** 回环地址:宿主即使监听 `0.0.0.0`(LAN 模式),走 127.0.0.1 也通。 */
|
|
4600
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
4601
|
+
/**
|
|
4602
|
+
* 造一个「每次调用现读宿主端口」的端点解析器。
|
|
4603
|
+
*
|
|
4604
|
+
* @param readPort 读宿主监听端口(`webServer.port`)。取不到时抛错而不是猜。
|
|
4605
|
+
* @param path 端点路径前缀,默认 `/v1`。
|
|
4606
|
+
*/
|
|
4607
|
+
function loopbackBaseURL(readPort, path = "/v1") {
|
|
4608
|
+
return () => {
|
|
4609
|
+
const port = readPort();
|
|
4610
|
+
if (port === void 0) throw new Error("dsh-router: 宿主 webServer 端口未就绪(webServer.port 读不到),无法定位 /v1 端点");
|
|
4611
|
+
return `http://${LOOPBACK_HOST}:${String(port)}${path}`;
|
|
4612
|
+
};
|
|
4613
|
+
}
|
|
4614
|
+
//#endregion
|
|
3877
4615
|
//#region src/host-version.ts
|
|
3878
4616
|
/**
|
|
3879
4617
|
* 宿主 DSH 版本探测 —— **0.1.5 / 0.1.7 兼容分叉的唯一判据来源**。
|
|
@@ -4183,6 +4921,15 @@ function apply(rawContext) {
|
|
|
4183
4921
|
const extStore = new ExtStore(stateFile);
|
|
4184
4922
|
ctx.provide("router.extStore", extStore);
|
|
4185
4923
|
const registerLoaded = (loaded) => {
|
|
4924
|
+
const existing = loadedSuppliers.find((l) => l.supplier.id === loaded.supplier.id);
|
|
4925
|
+
if (existing !== void 0) {
|
|
4926
|
+
if (!supplierWins(loaded.source, existing.source)) {
|
|
4927
|
+
log(`supplier ${loaded.supplier.id} (${loaded.source}) skipped: ${existing.source} already registered`);
|
|
4928
|
+
return;
|
|
4929
|
+
}
|
|
4930
|
+
log(`supplier ${loaded.supplier.id}: ${existing.source} replaced by ${loaded.source}`);
|
|
4931
|
+
unregisterSupplier(loaded.supplier.id);
|
|
4932
|
+
}
|
|
4186
4933
|
loadedSuppliers.push(loaded);
|
|
4187
4934
|
router.add(loaded.supplier);
|
|
4188
4935
|
registerSupplierRoutes(loaded);
|
|
@@ -4193,22 +4940,24 @@ function apply(rawContext) {
|
|
|
4193
4940
|
for (const [sid, factory] of Object.entries(suppliers)) {
|
|
4194
4941
|
if (externalSupplierIds.includes(sid)) continue;
|
|
4195
4942
|
try {
|
|
4196
|
-
const
|
|
4943
|
+
const module = factory({
|
|
4197
4944
|
dataDir,
|
|
4198
4945
|
log,
|
|
4199
4946
|
store,
|
|
4200
4947
|
credentials
|
|
4201
|
-
})
|
|
4948
|
+
});
|
|
4949
|
+
const kind = factory.source === "builtin" ? "builtin" : "external";
|
|
4950
|
+
const loaded = wrapModule(module, {
|
|
4202
4951
|
dataDir,
|
|
4203
4952
|
log,
|
|
4204
4953
|
store,
|
|
4205
4954
|
credentials
|
|
4206
|
-
}, `service router.suppliers.${sid}
|
|
4955
|
+
}, `service router.suppliers.${sid}`, kind);
|
|
4207
4956
|
registerLoaded(loaded);
|
|
4208
4957
|
externalSupplierIds.push(loaded.supplier.id);
|
|
4209
|
-
log(
|
|
4958
|
+
log(`${kind} supplier loaded: ${sid}`);
|
|
4210
4959
|
} catch (err) {
|
|
4211
|
-
ctx.logger.warn(`[dsh-router]
|
|
4960
|
+
ctx.logger.warn(`[dsh-router] supplier ${sid} load failed: ${err.message}`);
|
|
4212
4961
|
}
|
|
4213
4962
|
}
|
|
4214
4963
|
};
|
|
@@ -4227,10 +4976,8 @@ function apply(rawContext) {
|
|
|
4227
4976
|
});
|
|
4228
4977
|
(async () => {
|
|
4229
4978
|
try {
|
|
4230
|
-
const userDir = join(profileDirOf(ctx.baseUrl), "suppliers");
|
|
4231
4979
|
const { suppliers, errors } = await loadSuppliers({
|
|
4232
|
-
|
|
4233
|
-
userDir,
|
|
4980
|
+
userDir: join(profileDirOf(ctx.baseUrl), "suppliers"),
|
|
4234
4981
|
dataDir,
|
|
4235
4982
|
store,
|
|
4236
4983
|
credentials,
|
|
@@ -4312,7 +5059,8 @@ function apply(rawContext) {
|
|
|
4312
5059
|
icon: loaded?.supplier.icon,
|
|
4313
5060
|
apiKeyHint: loaded?.supplier.apiKeyHint,
|
|
4314
5061
|
capabilities: loaded ? [...loaded.capabilities] : [],
|
|
4315
|
-
source: loaded?.source ?? "external"
|
|
5062
|
+
source: loaded?.source ?? "external",
|
|
5063
|
+
enabled: s.enabled
|
|
4316
5064
|
};
|
|
4317
5065
|
})
|
|
4318
5066
|
});
|
|
@@ -4568,6 +5316,7 @@ function apply(rawContext) {
|
|
|
4568
5316
|
...e.icon !== void 0 ? { icon: e.icon } : {},
|
|
4569
5317
|
enabled: extStore.isEnabled(e.id),
|
|
4570
5318
|
ready: st?.ready === true,
|
|
5319
|
+
...raw.source === "builtin" ? { source: "builtin" } : {},
|
|
4571
5320
|
...st?.detail !== void 0 ? { detail: st.detail } : {}
|
|
4572
5321
|
};
|
|
4573
5322
|
}).filter((e) => e !== void 0)
|
|
@@ -4651,13 +5400,13 @@ function apply(rawContext) {
|
|
|
4651
5400
|
});
|
|
4652
5401
|
log(`Router settings section registered (${settingsNs})`);
|
|
4653
5402
|
});
|
|
4654
|
-
disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter(
|
|
5403
|
+
disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter(loopbackBaseURL(() => ctx.webServer?.port), { comboModels: async () => (await router.combos()).map((c) => {
|
|
4655
5404
|
const w = router.comboContextWindow(c);
|
|
4656
5405
|
return {
|
|
4657
5406
|
id: c.name,
|
|
4658
5407
|
...w !== void 0 ? { contextWindow: w } : {}
|
|
4659
5408
|
};
|
|
4660
|
-
}) }, () => ctx.get("attachments"))));
|
|
5409
|
+
}) }, () => ctx.get("attachments"), void 0, (msg) => ctx.logger.warn(`[dsh-router] ${msg}`))));
|
|
4661
5410
|
log("llm provider (Router) + discovery + adapter registered ok");
|
|
4662
5411
|
(async () => {
|
|
4663
5412
|
for (const c of await router.combos()) for (const ref of c.models) {
|