dsh-router-core 0.3.10 → 0.3.11
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/docs/suppliers.md +5 -1
- package/lib/client-registry.js +4 -2
- package/lib/client.js +4 -2
- package/lib/index.js +109 -9
- package/lib/suppliers/openrouter.js +6 -1
- package/lib/types/data-dir.d.ts +11 -0
- package/lib/types/llm/adapter.d.ts +7 -0
- package/lib/types/router/index.d.ts +15 -0
- package/lib/types/router/types.d.ts +2 -0
- package/lib/types/shared.d.ts +2 -0
- package/lib/types/suppliers/contract.d.ts +21 -0
- package/package.json +1 -1
- package/src/client/RouterView.tsx +2 -2
- package/src/client/StatsTab.tsx +4 -2
- package/src/client/SupplierDetail.tsx +2 -2
- package/src/data-dir.test.ts +54 -0
- package/src/data-dir.ts +46 -0
- package/src/index.ts +30 -6
- package/src/llm/adapter.test.ts +17 -0
- package/src/llm/adapter.ts +8 -1
- package/src/router/index.ts +32 -0
- package/src/router/models-cache.test.ts +38 -0
- package/src/router/types.ts +2 -0
- package/src/shared.ts +2 -0
- package/src/suppliers/contract.ts +22 -0
- package/src/suppliers/loader.test.ts +66 -1
- package/src/suppliers/loader.ts +22 -2
- package/src/suppliers/openrouter/plugin.ts +5 -0
package/docs/suppliers.md
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# 供应商开发
|
|
2
2
|
|
|
3
3
|
供应商是可插拔 js 模块,只提供**差异化能力**。写好放到
|
|
4
|
-
`~/.dsh/profiles/web
|
|
4
|
+
`<profile>/suppliers/*.js`(默认 profile = `~/.dsh/profiles/web`),重启后自动加载,
|
|
5
|
+
出现在「设置 → 路由 → 供应商」。
|
|
6
|
+
|
|
7
|
+
数据目录同理是 `<profile>/data/`,**不跟进程 cwd 跑**——从任何目录启动 `dsh web`
|
|
8
|
+
读到的都是同一份配置。
|
|
5
9
|
|
|
6
10
|
## 分工:策略归核心,js 只管单账户
|
|
7
11
|
|
package/lib/client-registry.js
CHANGED
|
@@ -1258,7 +1258,7 @@ window.__ModuleLoader__.load({
|
|
|
1258
1258
|
children: [
|
|
1259
1259
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1260
1260
|
className: "dshr-muted",
|
|
1261
|
-
children: "填入
|
|
1261
|
+
children: supplier.apiKeyHint ?? "填入 API key,可起名字区分多个 key。"
|
|
1262
1262
|
}),
|
|
1263
1263
|
error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1264
1264
|
className: "dshr-alert",
|
|
@@ -2776,9 +2776,11 @@ window.__ModuleLoader__.load({
|
|
|
2776
2776
|
function fmt(n) {
|
|
2777
2777
|
return new Intl.NumberFormat().format(n ?? 0);
|
|
2778
2778
|
}
|
|
2779
|
-
/** 大数缩写:1.2K / 3.4M(卡片和柱图上放不下完整数字时用)。 */
|
|
2779
|
+
/** 大数缩写:1.2K / 3.4M / 5.6B / 7.8T(卡片和柱图上放不下完整数字时用)。 */
|
|
2780
2780
|
function fmtShort(n) {
|
|
2781
2781
|
const v = n ?? 0;
|
|
2782
|
+
if (v >= 0xe8d4a51000) return `${(v / 0xe8d4a51000).toFixed(1)}T`;
|
|
2783
|
+
if (v >= 1e9) return `${(v / 1e9).toFixed(1)}B`;
|
|
2782
2784
|
if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
|
|
2783
2785
|
if (v >= 1e4) return `${(v / 1e3).toFixed(1)}K`;
|
|
2784
2786
|
return fmt(v);
|
package/lib/client.js
CHANGED
|
@@ -1258,7 +1258,7 @@ window.__ModuleLoader__.load({
|
|
|
1258
1258
|
children: [
|
|
1259
1259
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1260
1260
|
className: "dshr-muted",
|
|
1261
|
-
children: "填入
|
|
1261
|
+
children: supplier.apiKeyHint ?? "填入 API key,可起名字区分多个 key。"
|
|
1262
1262
|
}),
|
|
1263
1263
|
error !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1264
1264
|
className: "dshr-alert",
|
|
@@ -2776,9 +2776,11 @@ window.__ModuleLoader__.load({
|
|
|
2776
2776
|
function fmt(n) {
|
|
2777
2777
|
return new Intl.NumberFormat().format(n ?? 0);
|
|
2778
2778
|
}
|
|
2779
|
-
/** 大数缩写:1.2K / 3.4M(卡片和柱图上放不下完整数字时用)。 */
|
|
2779
|
+
/** 大数缩写:1.2K / 3.4M / 5.6B / 7.8T(卡片和柱图上放不下完整数字时用)。 */
|
|
2780
2780
|
function fmtShort(n) {
|
|
2781
2781
|
const v = n ?? 0;
|
|
2782
|
+
if (v >= 0xe8d4a51000) return `${(v / 0xe8d4a51000).toFixed(1)}T`;
|
|
2783
|
+
if (v >= 1e9) return `${(v / 1e9).toFixed(1)}B`;
|
|
2782
2784
|
if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
|
|
2783
2785
|
if (v >= 1e4) return `${(v / 1e3).toFixed(1)}K`;
|
|
2784
2786
|
return fmt(v);
|
package/lib/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { basename, dirname, join } from "node:path";
|
|
1
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
2
2
|
import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, ReasoningEffortId, ToolCallId, attributionHeaders } from "@deepseek-ai/dsh-llm";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
|
-
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { homedir } from "node:os";
|
|
7
8
|
//#region src/shared.ts
|
|
8
9
|
/** Same-origin API base the client panel fetches (host half serves it). */
|
|
9
10
|
const ROUTER_API_BASE = "/router/api";
|
|
@@ -1608,6 +1609,36 @@ var Router = class {
|
|
|
1608
1609
|
* 只有两种情况会真的等:`force`(用户主动刷新)和**从来没有过缓存值**
|
|
1609
1610
|
* (冷启动,没有旧值可给)。后者只发生一次。
|
|
1610
1611
|
*/
|
|
1612
|
+
/**
|
|
1613
|
+
* 组合的上下文窗口(token):组合内各模型窗口的**最小值**,拿不到就 undefined。
|
|
1614
|
+
*
|
|
1615
|
+
* 只读缓存、绝不打上游:调用方是 llm adapter 的 `resolveModel`,它在每次
|
|
1616
|
+
* 请求前后都可能被调用,不能为了报一个容量去触发按供应商的网络往返。
|
|
1617
|
+
* 冷启动(还没缓存)时给 undefined —— 自动压缩会因此跳过这一轮,下次就
|
|
1618
|
+
* 有了,比撒谎说一个窗口值安全。
|
|
1619
|
+
*
|
|
1620
|
+
* 单位:`ModelInfo.context_length` 存的是 **K**(供应商插件统一
|
|
1621
|
+
* `Math.round(tokens / 1000)`,见 codebuddy plugin),这里换算回 token。
|
|
1622
|
+
*
|
|
1623
|
+
* 取最小而不是最大:组合会按策略 fallback 到任意一个模型,按最窄的声明
|
|
1624
|
+
* 才能让「快满了」这件事在任何一条分支上都成立。
|
|
1625
|
+
*/
|
|
1626
|
+
comboContextWindow(combo) {
|
|
1627
|
+
let min;
|
|
1628
|
+
for (const ref of combo.models) {
|
|
1629
|
+
const at = ref.indexOf(",");
|
|
1630
|
+
if (at < 0) continue;
|
|
1631
|
+
const supplierId = ref.slice(0, at);
|
|
1632
|
+
const modelId = ref.slice(at + 1);
|
|
1633
|
+
const hit = this.modelsCache.get(supplierId);
|
|
1634
|
+
if (hit === void 0) return void 0;
|
|
1635
|
+
const k = hit.models.find((x) => x.id === modelId)?.context_length;
|
|
1636
|
+
if (typeof k !== "number" || !Number.isFinite(k) || k <= 0) continue;
|
|
1637
|
+
const tokens = Math.round(k * 1e3);
|
|
1638
|
+
if (min === void 0 || tokens < min) min = tokens;
|
|
1639
|
+
}
|
|
1640
|
+
return min;
|
|
1641
|
+
}
|
|
1611
1642
|
async modelsOf(supplierId, force = false) {
|
|
1612
1643
|
const s = this.suppliers.find((x) => x.id === supplierId);
|
|
1613
1644
|
if (s === void 0) return [];
|
|
@@ -2424,6 +2455,7 @@ var RouterAdapter = class extends LlmAdapter {
|
|
|
2424
2455
|
provider: "router",
|
|
2425
2456
|
id: model,
|
|
2426
2457
|
name: found?.name ?? found?.id ?? model,
|
|
2458
|
+
...found?.contextWindow !== void 0 ? { context: { contextWindow: found.contextWindow } } : {},
|
|
2427
2459
|
reasoning: {
|
|
2428
2460
|
efforts: ROUTER_REASONING_EFFORTS,
|
|
2429
2461
|
defaultEffort: ReasoningEffortId("high")
|
|
@@ -2696,15 +2728,24 @@ const DIFF_CAPS = [
|
|
|
2696
2728
|
"pollLogin",
|
|
2697
2729
|
"checkinNow"
|
|
2698
2730
|
];
|
|
2731
|
+
/**
|
|
2732
|
+
* 每个供应商一份 env 副本:`onLateFailure` 是**按供应商**绑到本供应商的池上
|
|
2733
|
+
* 的(见 wrapModule),共用一个 env 对象会让所有插件的上报都落到最后一个
|
|
2734
|
+
* 供应商的池里。副本是浅拷贝,store/credentials 仍是同一实例。
|
|
2735
|
+
*/
|
|
2736
|
+
function ownEnv(env) {
|
|
2737
|
+
return { ...env };
|
|
2738
|
+
}
|
|
2699
2739
|
/** 从文件构造 LoadedSupplier。 */
|
|
2700
2740
|
async function loadOne(file, env, source) {
|
|
2701
2741
|
const mod = await importSupplierFile(file);
|
|
2742
|
+
const mine = ownEnv(env);
|
|
2702
2743
|
let instance;
|
|
2703
2744
|
if (typeof mod === "function") {
|
|
2704
|
-
instance = mod(
|
|
2745
|
+
instance = mod(mine);
|
|
2705
2746
|
if (typeof instance !== "object" || instance === null || typeof instance.id !== "string" || instance.id === "") throw new Error(`供应商 factory 返回无效: ${file}`);
|
|
2706
2747
|
} else instance = mod;
|
|
2707
|
-
return wrapModule(instance,
|
|
2748
|
+
return wrapModule(instance, mine, file, source);
|
|
2708
2749
|
}
|
|
2709
2750
|
/**
|
|
2710
2751
|
* 从 SupplierModule 实例构造 LoadedSupplier(能力检测 + Supplier 包装)。
|
|
@@ -2715,11 +2756,16 @@ function wrapModule(instance, env, source, kind = "external") {
|
|
|
2715
2756
|
const capabilities = /* @__PURE__ */ new Set();
|
|
2716
2757
|
for (const key of DIFF_CAPS) if (isFn(instance[key])) capabilities.add(key);
|
|
2717
2758
|
const pool = new AccountPool(instance.id);
|
|
2759
|
+
env.onLateFailure = (uid, model, state, message) => {
|
|
2760
|
+
if (uid === "") return;
|
|
2761
|
+
pool.noteFailure(uid, model, state, message);
|
|
2762
|
+
};
|
|
2718
2763
|
const supplier = {
|
|
2719
2764
|
id: instance.id,
|
|
2720
2765
|
name: instance.name,
|
|
2721
2766
|
priority: instance.priority ?? 0,
|
|
2722
2767
|
icon: instance.icon,
|
|
2768
|
+
apiKeyHint: instance.apiKeyHint,
|
|
2723
2769
|
status: () => {
|
|
2724
2770
|
const now = instance.status();
|
|
2725
2771
|
const accounts = now.accounts.map((a) => {
|
|
@@ -3245,6 +3291,43 @@ var CredentialStore = class {
|
|
|
3245
3291
|
}
|
|
3246
3292
|
};
|
|
3247
3293
|
//#endregion
|
|
3294
|
+
//#region src/data-dir.ts
|
|
3295
|
+
/**
|
|
3296
|
+
* 落盘路径解析 —— dsh-router 所有持久化路径的唯一来源。
|
|
3297
|
+
*
|
|
3298
|
+
* 以前写的是相对路径 `data/state.json`:**落盘位置跟着 `process.cwd()` 跑**。
|
|
3299
|
+
* 不在 `~/.dsh/profiles/web` 里 `dsh web` 启动,state.json / supplier-config.json
|
|
3300
|
+
* / keys.json / usage.json 就会静默写进另一个目录(面板显示空配置),切回原目录
|
|
3301
|
+
* 启动又「回来」——用户看到的是「配置丢了」。所以这里一律产出绝对路径。
|
|
3302
|
+
*/
|
|
3303
|
+
/**
|
|
3304
|
+
* 本 profile 的目录。
|
|
3305
|
+
*
|
|
3306
|
+
* 宿主把配置树锚点 `ctx.baseUrl` 挂在每个插件的 context 上,它正是 profile
|
|
3307
|
+
* 目录(`cordis.yml` 所在处)——不用猜、也不用像 dsh-market 那样嗅探 argv。
|
|
3308
|
+
* 兜底只服务「宿主没给锚点」的冷启动(单测、非常规宿主):沿用今天的事实
|
|
3309
|
+
* 标准 `web` profile。
|
|
3310
|
+
*/
|
|
3311
|
+
function profileDirOf(baseUrl) {
|
|
3312
|
+
if (typeof baseUrl === "string" && baseUrl !== "") try {
|
|
3313
|
+
return resolve(fileURLToPath(baseUrl));
|
|
3314
|
+
} catch {}
|
|
3315
|
+
return join(dshHome(), "profiles", "web");
|
|
3316
|
+
}
|
|
3317
|
+
/** 数据目录:state.json / supplier-config.json / keys.json / auths/ 都在这里。 */
|
|
3318
|
+
function dataDirOf(baseUrl) {
|
|
3319
|
+
return join(profileDirOf(baseUrl), "data");
|
|
3320
|
+
}
|
|
3321
|
+
/** `$DSH_HOME`(支持 `~` 前缀),缺省 `~/.dsh`。 */
|
|
3322
|
+
function dshHome() {
|
|
3323
|
+
const raw = process.env.DSH_HOME;
|
|
3324
|
+
if (raw === void 0 || raw.trim() === "") return join(homedir(), ".dsh");
|
|
3325
|
+
const value = raw.trim();
|
|
3326
|
+
if (value === "~") return homedir();
|
|
3327
|
+
if (value.startsWith("~/") || value.startsWith("~\\")) return resolve(homedir(), value.slice(2));
|
|
3328
|
+
return resolve(value);
|
|
3329
|
+
}
|
|
3330
|
+
//#endregion
|
|
3248
3331
|
//#region src/index.ts
|
|
3249
3332
|
/**
|
|
3250
3333
|
* Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
|
|
@@ -3282,9 +3365,11 @@ function readBody(req, limit) {
|
|
|
3282
3365
|
function apply(rawContext) {
|
|
3283
3366
|
const ctx = rawContext;
|
|
3284
3367
|
const log = (msg) => ctx.logger.info(`[dsh-router] ${msg}`);
|
|
3285
|
-
const
|
|
3368
|
+
const dataDir = dataDirOf(ctx.baseUrl);
|
|
3369
|
+
const stateFile = join(dataDir, "state.json");
|
|
3370
|
+
log(`data dir: ${dataDir}`);
|
|
3286
3371
|
const store = new SupplierConfigStore(stateFile);
|
|
3287
|
-
const credentials = new CredentialStore(join(
|
|
3372
|
+
const credentials = new CredentialStore(join(dataDir, "auths"));
|
|
3288
3373
|
const router = new Router(stateFile, store, log);
|
|
3289
3374
|
const keys = new KeysStore(stateFile);
|
|
3290
3375
|
const disposers = [];
|
|
@@ -3316,7 +3401,6 @@ function apply(rawContext) {
|
|
|
3316
3401
|
log(`supplier unregistered: ${id}`);
|
|
3317
3402
|
};
|
|
3318
3403
|
ctx.provide("router.suppliers", {});
|
|
3319
|
-
const dataDir = dirname(stateFile);
|
|
3320
3404
|
const registerLoaded = (loaded) => {
|
|
3321
3405
|
loadedSuppliers.push(loaded);
|
|
3322
3406
|
router.add(loaded.supplier);
|
|
@@ -3362,7 +3446,7 @@ function apply(rawContext) {
|
|
|
3362
3446
|
});
|
|
3363
3447
|
(async () => {
|
|
3364
3448
|
try {
|
|
3365
|
-
const userDir = join(
|
|
3449
|
+
const userDir = join(profileDirOf(ctx.baseUrl), "suppliers");
|
|
3366
3450
|
const { suppliers, errors } = await loadSuppliers({
|
|
3367
3451
|
builtinDir: join(import.meta.dirname, "suppliers"),
|
|
3368
3452
|
userDir,
|
|
@@ -3440,6 +3524,7 @@ function apply(rawContext) {
|
|
|
3440
3524
|
id: s.id,
|
|
3441
3525
|
name: s.name,
|
|
3442
3526
|
icon: loaded?.supplier.icon,
|
|
3527
|
+
apiKeyHint: loaded?.supplier.apiKeyHint,
|
|
3443
3528
|
capabilities: loaded ? [...loaded.capabilities] : [],
|
|
3444
3529
|
source: loaded?.source ?? "external"
|
|
3445
3530
|
};
|
|
@@ -3676,8 +3761,23 @@ function apply(rawContext) {
|
|
|
3676
3761
|
disposers.push(ctx.llm.registerModelDiscovery(LLM_NS, async () => {
|
|
3677
3762
|
return (await router.combos()).map((c) => ({ id: c.name }));
|
|
3678
3763
|
}));
|
|
3679
|
-
disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter("http://localhost:3080/v1", { comboModels: async () => (await router.combos()).map((c) =>
|
|
3764
|
+
disposers.push(ctx.llm.registerAdapter(["router"], new RouterAdapter("http://localhost:3080/v1", { comboModels: async () => (await router.combos()).map((c) => {
|
|
3765
|
+
const w = router.comboContextWindow(c);
|
|
3766
|
+
return {
|
|
3767
|
+
id: c.name,
|
|
3768
|
+
...w !== void 0 ? { contextWindow: w } : {}
|
|
3769
|
+
};
|
|
3770
|
+
}) })));
|
|
3680
3771
|
log("llm provider (Router) + discovery + adapter registered ok");
|
|
3772
|
+
(async () => {
|
|
3773
|
+
for (const c of await router.combos()) for (const ref of c.models) {
|
|
3774
|
+
const at = ref.indexOf(",");
|
|
3775
|
+
if (at < 0) continue;
|
|
3776
|
+
try {
|
|
3777
|
+
await router.modelsOf(ref.slice(0, at));
|
|
3778
|
+
} catch {}
|
|
3779
|
+
}
|
|
3780
|
+
})().catch(() => {});
|
|
3681
3781
|
} catch (err) {
|
|
3682
3782
|
ctx.logger.warn(`[dsh-router] llm registration failed: ${err.message}`);
|
|
3683
3783
|
}
|
|
@@ -4,6 +4,10 @@ const name = "OpenRouter";
|
|
|
4
4
|
const priority = 10;
|
|
5
5
|
/** 面板图标。 */
|
|
6
6
|
const icon = "data:image/png;base64,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";
|
|
7
|
+
/**
|
|
8
|
+
* 「添加 API key」弹窗提示(OpenRouter API key 获取页)。
|
|
9
|
+
*/
|
|
10
|
+
const apiKeyHint = "填入 OpenRouter API key(https://openrouter.ai/settings/keys),可起名字区分多个 key。";
|
|
7
11
|
const BASE = "https://openrouter.ai/api/v1";
|
|
8
12
|
const MODELS_URL = `${BASE}/models`;
|
|
9
13
|
const CHAT_URL = `${BASE}/chat/completions`;
|
|
@@ -79,6 +83,7 @@ function factory(env) {
|
|
|
79
83
|
name,
|
|
80
84
|
priority: 10,
|
|
81
85
|
icon,
|
|
86
|
+
apiKeyHint,
|
|
82
87
|
status: () => {
|
|
83
88
|
const accounts = orderedKeys().map(({ uid, acct }) => ({
|
|
84
89
|
uid,
|
|
@@ -224,4 +229,4 @@ function factory(env) {
|
|
|
224
229
|
};
|
|
225
230
|
}
|
|
226
231
|
//#endregion
|
|
227
|
-
export { factory as default, icon, id, name, priority };
|
|
232
|
+
export { apiKeyHint, factory as default, icon, id, name, priority };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本 profile 的目录。
|
|
3
|
+
*
|
|
4
|
+
* 宿主把配置树锚点 `ctx.baseUrl` 挂在每个插件的 context 上,它正是 profile
|
|
5
|
+
* 目录(`cordis.yml` 所在处)——不用猜、也不用像 dsh-market 那样嗅探 argv。
|
|
6
|
+
* 兜底只服务「宿主没给锚点」的冷启动(单测、非常规宿主):沿用今天的事实
|
|
7
|
+
* 标准 `web` profile。
|
|
8
|
+
*/
|
|
9
|
+
export declare function profileDirOf(baseUrl?: string): string;
|
|
10
|
+
/** 数据目录:state.json / supplier-config.json / keys.json / auths/ 都在这里。 */
|
|
11
|
+
export declare function dataDirOf(baseUrl?: string): string;
|
|
@@ -9,9 +9,16 @@
|
|
|
9
9
|
import { LlmAdapter, type GenerateOptions, type LlmModelInfo, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
10
10
|
/** 模型目录来源:组合。 */
|
|
11
11
|
export interface RouterAdapterSource {
|
|
12
|
+
/**
|
|
13
|
+
* 组合列表。`contextWindow` 可选,但**必须尽量给**——自动压缩靠它算阈值
|
|
14
|
+
* (`dsh-compaction-basic` 在 `context` 缺失时直接抛错并静默关闭自动压缩,
|
|
15
|
+
* 于是上下文会一路涨到模型硬上限才炸)。
|
|
16
|
+
* 组合背后是异构供应商时给**最小的那个**(保守,宁可早压缩)。
|
|
17
|
+
*/
|
|
12
18
|
comboModels: () => Promise<Array<{
|
|
13
19
|
id: string;
|
|
14
20
|
name?: string;
|
|
21
|
+
contextWindow?: number;
|
|
15
22
|
}>>;
|
|
16
23
|
}
|
|
17
24
|
/** openai SSE payload → DSH StreamChunk 流。导出以便单测锁死 usage 契约。 */
|
|
@@ -66,6 +66,21 @@ export declare class Router {
|
|
|
66
66
|
* 只有两种情况会真的等:`force`(用户主动刷新)和**从来没有过缓存值**
|
|
67
67
|
* (冷启动,没有旧值可给)。后者只发生一次。
|
|
68
68
|
*/
|
|
69
|
+
/**
|
|
70
|
+
* 组合的上下文窗口(token):组合内各模型窗口的**最小值**,拿不到就 undefined。
|
|
71
|
+
*
|
|
72
|
+
* 只读缓存、绝不打上游:调用方是 llm adapter 的 `resolveModel`,它在每次
|
|
73
|
+
* 请求前后都可能被调用,不能为了报一个容量去触发按供应商的网络往返。
|
|
74
|
+
* 冷启动(还没缓存)时给 undefined —— 自动压缩会因此跳过这一轮,下次就
|
|
75
|
+
* 有了,比撒谎说一个窗口值安全。
|
|
76
|
+
*
|
|
77
|
+
* 单位:`ModelInfo.context_length` 存的是 **K**(供应商插件统一
|
|
78
|
+
* `Math.round(tokens / 1000)`,见 codebuddy plugin),这里换算回 token。
|
|
79
|
+
*
|
|
80
|
+
* 取最小而不是最大:组合会按策略 fallback 到任意一个模型,按最窄的声明
|
|
81
|
+
* 才能让「快满了」这件事在任何一条分支上都成立。
|
|
82
|
+
*/
|
|
83
|
+
comboContextWindow(combo: Combo): number | undefined;
|
|
69
84
|
modelsOf(supplierId: string, force?: boolean): Promise<ModelWithEnabled[]>;
|
|
70
85
|
/**
|
|
71
86
|
* 真拉一次上游并写缓存。并发调用共享同一个 in-flight promise——
|
|
@@ -62,6 +62,8 @@ export interface Supplier {
|
|
|
62
62
|
readonly priority: number;
|
|
63
63
|
/** 图标(URL 或 SVG data URI),面板供应商卡片显示。可选。 */
|
|
64
64
|
readonly icon?: string;
|
|
65
|
+
/** 「添加 API key」弹窗提示文案(缺省前端用通用文案)。可选。 */
|
|
66
|
+
readonly apiKeyHint?: string;
|
|
65
67
|
status(): SupplierStatus;
|
|
66
68
|
listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[];
|
|
67
69
|
/** 面板展示的模型(含启用/自定义标记)。 */
|
package/lib/types/shared.d.ts
CHANGED
|
@@ -68,6 +68,8 @@ export interface RouterHealthResponse {
|
|
|
68
68
|
name: string;
|
|
69
69
|
/** 图标(URL 或 SVG data URI)。 */
|
|
70
70
|
icon?: string;
|
|
71
|
+
/** 「添加 API key」弹窗提示文案(缺省前端用通用文案)。 */
|
|
72
|
+
apiKeyHint?: string;
|
|
71
73
|
/** 面板能力集合(登录/签到/连接池/模型管理等,按存在性)。 */
|
|
72
74
|
capabilities?: string[];
|
|
73
75
|
/** 来源:内置 / 用户目录 / 外部插件。 */
|
|
@@ -22,6 +22,21 @@ export interface SupplierEnv {
|
|
|
22
22
|
store: SupplierConfigStore;
|
|
23
23
|
/** 通用凭证存储(账号凭证,核心统一管)。 */
|
|
24
24
|
credentials: CredentialStore;
|
|
25
|
+
/**
|
|
26
|
+
* 迟到的失败上报(可选)——**只在响应已提交之后**才用。
|
|
27
|
+
*
|
|
28
|
+
* 流式请求一旦写出第一个字节就绑死(HTTP 语义),此时上游再报错已经换不了
|
|
29
|
+
* 号、也改不了状态码。但「这个号坏了」这件事对**后续**请求仍然有价值:
|
|
30
|
+
* 不报上来,它就会继续留在池里被轮转选中,每次都白撞一次同一个错误。
|
|
31
|
+
*
|
|
32
|
+
* 核心实现 = `pool.noteFailure(uid, model, state, message)`,按该状态的规则
|
|
33
|
+
* 冷却/禁用。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
|
|
34
|
+
* 流内 `code=4008`,三个号里两个如此,round-robin 下 2/3 请求直接失败
|
|
35
|
+
* (2026-09-08 实测)——有了这条通道,坏号被冷一次就退出轮转。
|
|
36
|
+
*
|
|
37
|
+
* 插件拿不到也不用兜底:不调用就退化成今天的行为,不影响正确性。
|
|
38
|
+
*/
|
|
39
|
+
onLateFailure?: (uid: string, model: string, state: AccountState, message: string) => void;
|
|
25
40
|
}
|
|
26
41
|
/** 通用供应商工厂:一个 js 模块 export 它(或 default export 它),loader 调用得到实例。 */
|
|
27
42
|
export type SupplierFactory = (env: SupplierEnv) => SupplierModule;
|
|
@@ -93,6 +108,12 @@ export interface SupplierModule {
|
|
|
93
108
|
readonly priority?: number;
|
|
94
109
|
/** 图标(URL 或 SVG data URI),面板供应商卡片显示。可选。 */
|
|
95
110
|
readonly icon?: string;
|
|
111
|
+
/**
|
|
112
|
+
* 「添加 API key」弹窗的提示文案(HTML 未转义前是纯文本)。可选。
|
|
113
|
+
* 缺省时前端用通用文案。供应商若走 `addApiKey` 且有自己的 key 获取方式,
|
|
114
|
+
* 应提供它——否则所有供应商弹窗都显示同一句通用提示(如默认会写 OpenRouter)。
|
|
115
|
+
*/
|
|
116
|
+
readonly apiKeyHint?: string;
|
|
96
117
|
/** 报账号「现在状态」。冷却/禁用/错误累计由核心叠加,插件不算这些。 */
|
|
97
118
|
status(): SupplierStatusNow;
|
|
98
119
|
/** 模型来源。`force=true` 时强制刷新来源(核心「获取模型」按钮调用)。 */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-router-core",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -54,7 +54,7 @@ interface RouterViewProps {
|
|
|
54
54
|
|
|
55
55
|
export function RouterView({ onBack }: RouterViewProps): JSX.Element {
|
|
56
56
|
const [tab, setTab] = useState<TabId>('overview')
|
|
57
|
-
const [detailSupplier, setDetailSupplier] = useState<{ id: string; name: string; icon?: string } | null>(null)
|
|
57
|
+
const [detailSupplier, setDetailSupplier] = useState<{ id: string; name: string; icon?: string; apiKeyHint?: string } | null>(null)
|
|
58
58
|
const [snapshot, setSnapshot] = useState<Snapshot>({ health: null, status: null, combos: null })
|
|
59
59
|
const [refreshing, setRefreshing] = useState(false)
|
|
60
60
|
const mounted = useRef(true)
|
|
@@ -90,7 +90,7 @@ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
|
|
|
90
90
|
const combosError = snapshot.combos?.ok === false ? snapshot.combos.error : undefined
|
|
91
91
|
|
|
92
92
|
/** 渲染一组供应商卡片(内置 / 插件分开)。 */
|
|
93
|
-
const renderSupplierGroup = (title: string, group: Array<{ id: string; name: string; icon?: string; source?: string }>): ReactNode => {
|
|
93
|
+
const renderSupplierGroup = (title: string, group: Array<{ id: string; name: string; icon?: string; apiKeyHint?: string; source?: string }>): ReactNode => {
|
|
94
94
|
if (group.length === 0) return null
|
|
95
95
|
return (
|
|
96
96
|
<div className="dshr-supplierGroup">
|
package/src/client/StatsTab.tsx
CHANGED
|
@@ -70,10 +70,12 @@ function fmt(n: number | undefined): string {
|
|
|
70
70
|
return new Intl.NumberFormat().format(n ?? 0)
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/** 大数缩写:1.2K / 3.4M(卡片和柱图上放不下完整数字时用)。 */
|
|
73
|
+
/** 大数缩写:1.2K / 3.4M / 5.6B / 7.8T(卡片和柱图上放不下完整数字时用)。 */
|
|
74
74
|
function fmtShort(n: number | undefined): string {
|
|
75
75
|
const v = n ?? 0
|
|
76
|
-
if (v >=
|
|
76
|
+
if (v >= 1e12) return `${(v / 1e12).toFixed(1)}T`
|
|
77
|
+
if (v >= 1e9) return `${(v / 1e9).toFixed(1)}B`
|
|
78
|
+
if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`
|
|
77
79
|
if (v >= 10_000) return `${(v / 1000).toFixed(1)}K`
|
|
78
80
|
return fmt(v)
|
|
79
81
|
}
|
|
@@ -43,7 +43,7 @@ const I = {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
interface SupplierDetailProps {
|
|
46
|
-
supplier: { id: string; name: string; capabilities?: string[]; icon?: string }
|
|
46
|
+
supplier: { id: string; name: string; capabilities?: string[]; icon?: string; apiKeyHint?: string }
|
|
47
47
|
accounts: RouterAccount[]
|
|
48
48
|
/** 状态是否还在加载。 */
|
|
49
49
|
statusLoading: boolean
|
|
@@ -948,7 +948,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
948
948
|
{canApiKey
|
|
949
949
|
? (
|
|
950
950
|
<div className="dshr-modalForm">
|
|
951
|
-
<p className="dshr-muted"
|
|
951
|
+
<p className="dshr-muted">{supplier.apiKeyHint ?? '填入 API key,可起名字区分多个 key。'}</p>
|
|
952
952
|
{error !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{error}</span></div>}
|
|
953
953
|
<input
|
|
954
954
|
className="dshr-input"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数据目录解析测试 —— 锁死「落盘位置不跟 cwd 跑」这个 bug。
|
|
3
|
+
*
|
|
4
|
+
* 起因:以前写的是相对路径 `data/state.json`,宿主 Node 进程的 cwd 决定它
|
|
5
|
+
* 落到哪。从别的目录 `dsh web` 启动 → 配置静默写进另一个目录,面板显示空
|
|
6
|
+
* 配置,用户看到的就是「配置丢了」。
|
|
7
|
+
*/
|
|
8
|
+
import test from 'node:test'
|
|
9
|
+
import assert from 'node:assert/strict'
|
|
10
|
+
import { mkdtempSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { tmpdir } from 'node:os'
|
|
12
|
+
import { join, resolve } from 'node:path'
|
|
13
|
+
import { pathToFileURL } from 'node:url'
|
|
14
|
+
import { dataDirOf, profileDirOf } from './data-dir.ts'
|
|
15
|
+
|
|
16
|
+
/** 一个临时 profile 目录的 file: URL(宿主 `ctx.baseUrl` 的形状)。 */
|
|
17
|
+
function tempProfileUrl(): { url: string; dir: string } {
|
|
18
|
+
const dir = mkdtempSync(join(tmpdir(), 'dshr-profile-'))
|
|
19
|
+
writeFileSync(join(dir, 'cordis.yml'), '[]\n') // 让它像个 profile
|
|
20
|
+
return { url: pathToFileURL(dir).href + '/', dir }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('有 baseUrl → 数据目录钉在 profile 里(绝对路径)', () => {
|
|
24
|
+
const { url, dir } = tempProfileUrl()
|
|
25
|
+
assert.equal(profileDirOf(url), dir)
|
|
26
|
+
assert.equal(dataDirOf(url), join(dir, 'data'))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('换 cwd 不影响落盘位置(回归:不跟 process.cwd() 跑)', () => {
|
|
30
|
+
const { url, dir } = tempProfileUrl()
|
|
31
|
+
const before = dataDirOf(url)
|
|
32
|
+
const cwd = process.cwd()
|
|
33
|
+
try {
|
|
34
|
+
process.chdir(tmpdir())
|
|
35
|
+
assert.equal(dataDirOf(url), before)
|
|
36
|
+
} finally {
|
|
37
|
+
process.chdir(cwd)
|
|
38
|
+
}
|
|
39
|
+
assert.ok(resolve(before).startsWith(resolve(dir)))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('baseUrl 缺失/异常 → 兜底 $DSH_HOME 的 web profile', () => {
|
|
43
|
+
const saved = process.env.DSH_HOME
|
|
44
|
+
const home = mkdtempSync(join(tmpdir(), 'dshr-home-'))
|
|
45
|
+
try {
|
|
46
|
+
process.env.DSH_HOME = home
|
|
47
|
+
assert.equal(profileDirOf(undefined), join(home, 'profiles', 'web'))
|
|
48
|
+
assert.equal(profileDirOf(''), join(home, 'profiles', 'web'))
|
|
49
|
+
assert.equal(profileDirOf('http://example.com/x'), join(home, 'profiles', 'web'))
|
|
50
|
+
} finally {
|
|
51
|
+
if (saved === undefined) delete process.env.DSH_HOME
|
|
52
|
+
else process.env.DSH_HOME = saved
|
|
53
|
+
}
|
|
54
|
+
})
|
package/src/data-dir.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 落盘路径解析 —— dsh-router 所有持久化路径的唯一来源。
|
|
3
|
+
*
|
|
4
|
+
* 以前写的是相对路径 `data/state.json`:**落盘位置跟着 `process.cwd()` 跑**。
|
|
5
|
+
* 不在 `~/.dsh/profiles/web` 里 `dsh web` 启动,state.json / supplier-config.json
|
|
6
|
+
* / keys.json / usage.json 就会静默写进另一个目录(面板显示空配置),切回原目录
|
|
7
|
+
* 启动又「回来」——用户看到的是「配置丢了」。所以这里一律产出绝对路径。
|
|
8
|
+
*/
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import { join, resolve } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 本 profile 的目录。
|
|
15
|
+
*
|
|
16
|
+
* 宿主把配置树锚点 `ctx.baseUrl` 挂在每个插件的 context 上,它正是 profile
|
|
17
|
+
* 目录(`cordis.yml` 所在处)——不用猜、也不用像 dsh-market 那样嗅探 argv。
|
|
18
|
+
* 兜底只服务「宿主没给锚点」的冷启动(单测、非常规宿主):沿用今天的事实
|
|
19
|
+
* 标准 `web` profile。
|
|
20
|
+
*/
|
|
21
|
+
export function profileDirOf(baseUrl?: string): string {
|
|
22
|
+
if (typeof baseUrl === 'string' && baseUrl !== '') {
|
|
23
|
+
try {
|
|
24
|
+
// 尾部斜杠留给 join 吸收是隐式的,这里显式归一:调用方拿到的是干净目录。
|
|
25
|
+
return resolve(fileURLToPath(baseUrl))
|
|
26
|
+
} catch {
|
|
27
|
+
// 不是 file: URL(或不可解析)——落到兜底分支
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return join(dshHome(), 'profiles', 'web')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 数据目录:state.json / supplier-config.json / keys.json / auths/ 都在这里。 */
|
|
34
|
+
export function dataDirOf(baseUrl?: string): string {
|
|
35
|
+
return join(profileDirOf(baseUrl), 'data')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `$DSH_HOME`(支持 `~` 前缀),缺省 `~/.dsh`。 */
|
|
39
|
+
function dshHome(): string {
|
|
40
|
+
const raw = process.env.DSH_HOME
|
|
41
|
+
if (raw === undefined || raw.trim() === '') return join(homedir(), '.dsh')
|
|
42
|
+
const value = raw.trim()
|
|
43
|
+
if (value === '~') return homedir()
|
|
44
|
+
if (value.startsWith('~/') || value.startsWith('~\\')) return resolve(homedir(), value.slice(2))
|
|
45
|
+
return resolve(value)
|
|
46
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* active library key. `/router/api/*` is same-origin, no auth.
|
|
31
31
|
*/
|
|
32
32
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
33
|
-
import { join
|
|
33
|
+
import { join } from 'node:path'
|
|
34
34
|
import { ROUTER_API_BASE, type RouterPeriod } from './shared.ts'
|
|
35
35
|
import { Router } from './router/index.ts'
|
|
36
36
|
import { RouterAdapter } from './llm/adapter.ts'
|
|
@@ -40,6 +40,7 @@ import { supplierRoutes } from './suppliers/registry.ts'
|
|
|
40
40
|
import type { SupplierEnv, SupplierModule } from './suppliers/contract.ts'
|
|
41
41
|
import { SupplierConfigStore } from './supplier-config.ts'
|
|
42
42
|
import { CredentialStore } from './credential-store.ts'
|
|
43
|
+
import { dataDirOf, profileDirOf } from './data-dir.ts'
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
|
|
@@ -129,12 +130,18 @@ export function apply(rawContext: unknown): void {
|
|
|
129
130
|
logger: { info: (message: string) => void; warn: (message: string) => void }
|
|
130
131
|
effect: (fn: () => () => void, label?: string) => void
|
|
131
132
|
inject: (services: string[], callback: (sctx: unknown) => void) => void
|
|
133
|
+
/** 配置树锚点 = profile 目录(宿主注入),用来把数据钉在 profile 里。 */
|
|
134
|
+
baseUrl?: string
|
|
132
135
|
}
|
|
133
136
|
const log = (msg: string): void => ctx.logger.info(`[dsh-router] ${msg}`)
|
|
134
137
|
|
|
135
|
-
|
|
138
|
+
// 数据目录必须是绝对路径:相对路径会让落盘位置跟着 process.cwd() 跑,
|
|
139
|
+
// 从别的目录启动就静默换一套空配置(见 src/data-dir.ts)。
|
|
140
|
+
const dataDir = dataDirOf(ctx.baseUrl)
|
|
141
|
+
const stateFile = join(dataDir, 'state.json')
|
|
142
|
+
log(`data dir: ${dataDir}`)
|
|
136
143
|
const store = new SupplierConfigStore(stateFile)
|
|
137
|
-
const credentials = new CredentialStore(join(
|
|
144
|
+
const credentials = new CredentialStore(join(dataDir, 'auths'))
|
|
138
145
|
const router = new Router(stateFile, store, log)
|
|
139
146
|
|
|
140
147
|
const keys = new KeysStore(stateFile)
|
|
@@ -179,7 +186,6 @@ export function apply(rawContext: unknown): void {
|
|
|
179
186
|
ctx.provide('router.suppliers', {})
|
|
180
187
|
|
|
181
188
|
// 内置 + 用户 + 外部插件供应商(异步加载,完成后注册路由 + 加入路由器)
|
|
182
|
-
const dataDir = dirname(stateFile)
|
|
183
189
|
const registerLoaded = (loaded: LoadedSupplier): void => {
|
|
184
190
|
loadedSuppliers.push(loaded)
|
|
185
191
|
router.add(loaded.supplier)
|
|
@@ -222,7 +228,8 @@ export function apply(rawContext: unknown): void {
|
|
|
222
228
|
})
|
|
223
229
|
void (async () => {
|
|
224
230
|
try {
|
|
225
|
-
|
|
231
|
+
// 用户供应商目录也在 profile 里:跟着 baseUrl 走,别硬编码 `web`。
|
|
232
|
+
const userDir = join(profileDirOf(ctx.baseUrl), 'suppliers')
|
|
226
233
|
const { suppliers, errors } = await loadSuppliers({
|
|
227
234
|
builtinDir: join(import.meta.dirname, 'suppliers'), // 内置 js(opencode 等)
|
|
228
235
|
userDir,
|
|
@@ -297,6 +304,7 @@ export function apply(rawContext: unknown): void {
|
|
|
297
304
|
id: s.id,
|
|
298
305
|
name: s.name,
|
|
299
306
|
icon: loaded?.supplier.icon,
|
|
307
|
+
apiKeyHint: loaded?.supplier.apiKeyHint,
|
|
300
308
|
capabilities: loaded ? [...loaded.capabilities] : [],
|
|
301
309
|
source: loaded?.source ?? 'external',
|
|
302
310
|
}
|
|
@@ -504,10 +512,26 @@ export function apply(rawContext: unknown): void {
|
|
|
504
512
|
return combos.map((c) => ({ id: c.name }))
|
|
505
513
|
}))
|
|
506
514
|
// adapter:模型目录自动带出组合;对话转发到本插件 /v1(组合路由在 /v1 内完成)。
|
|
515
|
+
// 带上组合的上下文窗口:没有它 dsh 的自动压缩算不出阈值、会静默关闭。
|
|
507
516
|
disposers.push(ctx.llm.registerAdapter(['router'], new RouterAdapter('http://localhost:3080/v1', {
|
|
508
|
-
comboModels: async () => (await router.combos()).map((c) =>
|
|
517
|
+
comboModels: async () => (await router.combos()).map((c) => {
|
|
518
|
+
const w = router.comboContextWindow(c)
|
|
519
|
+
return { id: c.name, ...(w !== undefined ? { contextWindow: w } : {}) }
|
|
520
|
+
}),
|
|
509
521
|
})))
|
|
510
522
|
log('llm provider (Router) + discovery + adapter registered ok')
|
|
523
|
+
// 预热模型缓存:`comboContextWindow` 只读缓存、不打上游,缓存空着就
|
|
524
|
+
// 报不出窗口。这里后台填一次,让第一次 resolveModel 就有值。
|
|
525
|
+
// 失败无所谓(下次 modelsOf 还会拉),不能冒出来。
|
|
526
|
+
void (async () => {
|
|
527
|
+
for (const c of await router.combos()) {
|
|
528
|
+
for (const ref of c.models) {
|
|
529
|
+
const at = ref.indexOf(',')
|
|
530
|
+
if (at < 0) continue
|
|
531
|
+
try { await router.modelsOf(ref.slice(0, at)) } catch { /* 单家失败不连坐 */ }
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
})().catch(() => {})
|
|
511
535
|
} catch (err) {
|
|
512
536
|
ctx.logger.warn(`[dsh-router] llm registration failed: ${(err as Error).message}`)
|
|
513
537
|
}
|
package/src/llm/adapter.test.ts
CHANGED
|
@@ -526,3 +526,20 @@ test('模型目录:resolveModel 对每个组合声明推理等级(off/low/hi
|
|
|
526
526
|
const b = await adapter.resolveModel('router', 'c-b')
|
|
527
527
|
assert.equal(b.name, 'c-b')
|
|
528
528
|
})
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* 上下文窗口透传 —— dsh 的自动压缩靠 `resolveModel().context.contextWindow`
|
|
532
|
+
* 算阈值(默认用到 80% 触发)。缺失时 dsh-compaction-basic 抛错并静默关闭
|
|
533
|
+
* 自动压缩,上下文会一路涨到模型硬上限才炸。
|
|
534
|
+
*/
|
|
535
|
+
test('resolveModel 透传组合的 contextWindow(自动压缩才能算阈值)', async () => {
|
|
536
|
+
const adapter = new RouterAdapter('http://x', { comboModels: async () => [
|
|
537
|
+
{ id: 'c-a', contextWindow: 1_000_000 },
|
|
538
|
+
{ id: 'c-b' }, // 没拿到窗口
|
|
539
|
+
] })
|
|
540
|
+
const a = await adapter.resolveModel('router', 'c-a')
|
|
541
|
+
assert.deepEqual(a.context, { contextWindow: 1_000_000 }, '有就报')
|
|
542
|
+
|
|
543
|
+
const b = await adapter.resolveModel('router', 'c-b')
|
|
544
|
+
assert.equal(b.context, undefined, '没拿到就不声明 context,绝不填一个假窗口')
|
|
545
|
+
})
|
package/src/llm/adapter.ts
CHANGED
|
@@ -23,7 +23,13 @@ import { mergeUsage, normalizeUsage, toTokenUsage, type UsageTokens } from '../r
|
|
|
23
23
|
|
|
24
24
|
/** 模型目录来源:组合。 */
|
|
25
25
|
export interface RouterAdapterSource {
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* 组合列表。`contextWindow` 可选,但**必须尽量给**——自动压缩靠它算阈值
|
|
28
|
+
* (`dsh-compaction-basic` 在 `context` 缺失时直接抛错并静默关闭自动压缩,
|
|
29
|
+
* 于是上下文会一路涨到模型硬上限才炸)。
|
|
30
|
+
* 组合背后是异构供应商时给**最小的那个**(保守,宁可早压缩)。
|
|
31
|
+
*/
|
|
32
|
+
comboModels: () => Promise<Array<{ id: string; name?: string; contextWindow?: number }>>
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
/**
|
|
@@ -401,6 +407,7 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
401
407
|
provider: 'router',
|
|
402
408
|
id: model,
|
|
403
409
|
name,
|
|
410
|
+
...(found?.contextWindow !== undefined ? { context: { contextWindow: found.contextWindow } } : {}),
|
|
404
411
|
reasoning: { efforts: ROUTER_REASONING_EFFORTS, defaultEffort: ReasoningEffortId('high') },
|
|
405
412
|
}
|
|
406
413
|
}
|
package/src/router/index.ts
CHANGED
|
@@ -615,6 +615,38 @@ export class Router {
|
|
|
615
615
|
* 只有两种情况会真的等:`force`(用户主动刷新)和**从来没有过缓存值**
|
|
616
616
|
* (冷启动,没有旧值可给)。后者只发生一次。
|
|
617
617
|
*/
|
|
618
|
+
/**
|
|
619
|
+
* 组合的上下文窗口(token):组合内各模型窗口的**最小值**,拿不到就 undefined。
|
|
620
|
+
*
|
|
621
|
+
* 只读缓存、绝不打上游:调用方是 llm adapter 的 `resolveModel`,它在每次
|
|
622
|
+
* 请求前后都可能被调用,不能为了报一个容量去触发按供应商的网络往返。
|
|
623
|
+
* 冷启动(还没缓存)时给 undefined —— 自动压缩会因此跳过这一轮,下次就
|
|
624
|
+
* 有了,比撒谎说一个窗口值安全。
|
|
625
|
+
*
|
|
626
|
+
* 单位:`ModelInfo.context_length` 存的是 **K**(供应商插件统一
|
|
627
|
+
* `Math.round(tokens / 1000)`,见 codebuddy plugin),这里换算回 token。
|
|
628
|
+
*
|
|
629
|
+
* 取最小而不是最大:组合会按策略 fallback 到任意一个模型,按最窄的声明
|
|
630
|
+
* 才能让「快满了」这件事在任何一条分支上都成立。
|
|
631
|
+
*/
|
|
632
|
+
comboContextWindow(combo: Combo): number | undefined {
|
|
633
|
+
let min: number | undefined
|
|
634
|
+
for (const ref of combo.models) {
|
|
635
|
+
const at = ref.indexOf(',')
|
|
636
|
+
if (at < 0) continue
|
|
637
|
+
const supplierId = ref.slice(0, at)
|
|
638
|
+
const modelId = ref.slice(at + 1)
|
|
639
|
+
const hit = this.modelsCache.get(supplierId)
|
|
640
|
+
if (hit === undefined) return undefined // 这家还没缓存过,不猜
|
|
641
|
+
const m = hit.models.find((x) => x.id === modelId)
|
|
642
|
+
const k = m?.context_length
|
|
643
|
+
if (typeof k !== 'number' || !Number.isFinite(k) || k <= 0) continue
|
|
644
|
+
const tokens = Math.round(k * 1000)
|
|
645
|
+
if (min === undefined || tokens < min) min = tokens
|
|
646
|
+
}
|
|
647
|
+
return min
|
|
648
|
+
}
|
|
649
|
+
|
|
618
650
|
async modelsOf(supplierId: string, force = false): Promise<ModelWithEnabled[]> {
|
|
619
651
|
const s = this.suppliers.find((x) => x.id === supplierId)
|
|
620
652
|
if (s === undefined) return []
|
|
@@ -671,3 +671,41 @@ test('流式:剥掉上游多发一次的空白 tool_call name/id', async () =>
|
|
|
671
671
|
assert.equal(callId, 'chatcmpl-tool-a7ee5a0d3c361c7f', 'callId 也不能被空串冲掉')
|
|
672
672
|
assert.deepEqual(JSON.parse(args), { command: 'echo hello' }, 'arguments 正常累积')
|
|
673
673
|
})
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* 组合上下文窗口 —— dsh 的自动压缩靠它算阈值(默认窗口用到 80% 触发)。
|
|
677
|
+
* adapter 报不出 contextWindow 时,dsh-compaction-basic 直接抛错并**静默
|
|
678
|
+
* 关闭自动压缩**,上下文于是一路涨到模型硬上限才炸。
|
|
679
|
+
*/
|
|
680
|
+
test('组合窗口:取组合内模型的最小值,单位 K → token', async () => {
|
|
681
|
+
const router = new Router('')
|
|
682
|
+
addSupplier(router, supplier('a', [
|
|
683
|
+
{ id: 'a-1', context_length: 128 }, // 128K
|
|
684
|
+
{ id: 'a-2', context_length: 1000 }, // 1M
|
|
685
|
+
]).s)
|
|
686
|
+
await router.modelsOf('a') // 填缓存
|
|
687
|
+
const w = router.comboContextWindow({ id: 'x', name: 'x', strategy: 'fallback', models: ['a,a-1', 'a,a-2'] })
|
|
688
|
+
assert.equal(w, 128_000, '取最小的那个(fallback 到任意一条分支都得成立)')
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
test('组合窗口:供应商还没缓存过 → undefined(不猜,绝不打上游)', () => {
|
|
692
|
+
const router = new Router('')
|
|
693
|
+
addSupplier(router, supplier('a', [{ id: 'a-1', context_length: 128 }]).s)
|
|
694
|
+
// 故意不调 modelsOf:模拟冷启动
|
|
695
|
+
assert.equal(
|
|
696
|
+
router.comboContextWindow({ id: 'x', name: 'x', strategy: 'fallback', models: ['a,a-1'] }),
|
|
697
|
+
undefined,
|
|
698
|
+
'没缓存就报 undefined —— 压缩这轮跳过,下次就有,比撒谎安全',
|
|
699
|
+
)
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
test('组合窗口:模型都没报 context_length → undefined', async () => {
|
|
703
|
+
const router = new Router('')
|
|
704
|
+
addSupplier(router, supplier('a', [{ id: 'a-1' }]).s)
|
|
705
|
+
await router.modelsOf('a')
|
|
706
|
+
assert.equal(
|
|
707
|
+
router.comboContextWindow({ id: 'x', name: 'x', strategy: 'fallback', models: ['a,a-1'] }),
|
|
708
|
+
undefined,
|
|
709
|
+
'一家都没报就别编一个数(traework/nvidia 目前就是如此)',
|
|
710
|
+
)
|
|
711
|
+
})
|
package/src/router/types.ts
CHANGED
|
@@ -70,6 +70,8 @@ export interface Supplier {
|
|
|
70
70
|
readonly priority: number
|
|
71
71
|
/** 图标(URL 或 SVG data URI),面板供应商卡片显示。可选。 */
|
|
72
72
|
readonly icon?: string
|
|
73
|
+
/** 「添加 API key」弹窗提示文案(缺省前端用通用文案)。可选。 */
|
|
74
|
+
readonly apiKeyHint?: string
|
|
73
75
|
status(): SupplierStatus
|
|
74
76
|
listModels(force?: boolean): Promise<ModelInfo[]> | ModelInfo[]
|
|
75
77
|
/** 面板展示的模型(含启用/自定义标记)。 */
|
package/src/shared.ts
CHANGED
|
@@ -70,6 +70,8 @@ export interface RouterHealthResponse {
|
|
|
70
70
|
name: string
|
|
71
71
|
/** 图标(URL 或 SVG data URI)。 */
|
|
72
72
|
icon?: string
|
|
73
|
+
/** 「添加 API key」弹窗提示文案(缺省前端用通用文案)。 */
|
|
74
|
+
apiKeyHint?: string
|
|
73
75
|
/** 面板能力集合(登录/签到/连接池/模型管理等,按存在性)。 */
|
|
74
76
|
capabilities?: string[]
|
|
75
77
|
/** 来源:内置 / 用户目录 / 外部插件。 */
|
|
@@ -23,6 +23,21 @@ export interface SupplierEnv {
|
|
|
23
23
|
store: SupplierConfigStore
|
|
24
24
|
/** 通用凭证存储(账号凭证,核心统一管)。 */
|
|
25
25
|
credentials: CredentialStore
|
|
26
|
+
/**
|
|
27
|
+
* 迟到的失败上报(可选)——**只在响应已提交之后**才用。
|
|
28
|
+
*
|
|
29
|
+
* 流式请求一旦写出第一个字节就绑死(HTTP 语义),此时上游再报错已经换不了
|
|
30
|
+
* 号、也改不了状态码。但「这个号坏了」这件事对**后续**请求仍然有价值:
|
|
31
|
+
* 不报上来,它就会继续留在池里被轮转选中,每次都白撞一次同一个错误。
|
|
32
|
+
*
|
|
33
|
+
* 核心实现 = `pool.noteFailure(uid, model, state, message)`,按该状态的规则
|
|
34
|
+
* 冷却/禁用。真实案例:TRAE 免费通道对某账号拒绝某模型时发 HTTP 200 +
|
|
35
|
+
* 流内 `code=4008`,三个号里两个如此,round-robin 下 2/3 请求直接失败
|
|
36
|
+
* (2026-09-08 实测)——有了这条通道,坏号被冷一次就退出轮转。
|
|
37
|
+
*
|
|
38
|
+
* 插件拿不到也不用兜底:不调用就退化成今天的行为,不影响正确性。
|
|
39
|
+
*/
|
|
40
|
+
onLateFailure?: (uid: string, model: string, state: AccountState, message: string) => void
|
|
26
41
|
}
|
|
27
42
|
|
|
28
43
|
/** 通用供应商工厂:一个 js 模块 export 它(或 default export 它),loader 调用得到实例。 */
|
|
@@ -97,6 +112,13 @@ export interface SupplierModule {
|
|
|
97
112
|
/** 图标(URL 或 SVG data URI),面板供应商卡片显示。可选。 */
|
|
98
113
|
readonly icon?: string
|
|
99
114
|
|
|
115
|
+
/**
|
|
116
|
+
* 「添加 API key」弹窗的提示文案(HTML 未转义前是纯文本)。可选。
|
|
117
|
+
* 缺省时前端用通用文案。供应商若走 `addApiKey` 且有自己的 key 获取方式,
|
|
118
|
+
* 应提供它——否则所有供应商弹窗都显示同一句通用提示(如默认会写 OpenRouter)。
|
|
119
|
+
*/
|
|
120
|
+
readonly apiKeyHint?: string
|
|
121
|
+
|
|
100
122
|
// ---- 核心(必须) ----
|
|
101
123
|
/** 报账号「现在状态」。冷却/禁用/错误累计由核心叠加,插件不算这些。 */
|
|
102
124
|
status(): SupplierStatusNow
|
|
@@ -15,7 +15,7 @@ import { join } from 'node:path'
|
|
|
15
15
|
import { wrapModule } from './loader.ts'
|
|
16
16
|
import { SupplierConfigStore } from '../supplier-config.ts'
|
|
17
17
|
import type { CredentialStore } from '../credential-store.ts'
|
|
18
|
-
import type { SupplierModule, SupplierAccountNow, ChatOnceResult } from './contract.ts'
|
|
18
|
+
import type { SupplierEnv, SupplierModule, SupplierAccountNow, ChatOnceResult } from './contract.ts'
|
|
19
19
|
import type { ChatRequest } from '../router/types.ts'
|
|
20
20
|
|
|
21
21
|
const UNKNOWN = -1
|
|
@@ -116,3 +116,68 @@ test('不持久化(store fp 为空)时也不炸', async () => {
|
|
|
116
116
|
const loaded = wrapModule(plugin('codebuddy', [[acct('cb-1', 50)]]), e, 'test')
|
|
117
117
|
assert.equal(loaded.supplier.status().accounts[0]?.credits, 50)
|
|
118
118
|
})
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// onLateFailure:流式响应已提交后才发现的失败,要落回本供应商的池
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 造一个会调用 env.onLateFailure 的插件(模拟 traework 的流式 4008 上报)。
|
|
126
|
+
* 记录它拿到的 env —— 用于验证「factory 先跑、回调后挂」这个顺序下插件仍能用。
|
|
127
|
+
*/
|
|
128
|
+
function latePlugin(id: string, seen: SupplierEnv[]): SupplierModule {
|
|
129
|
+
let captured: SupplierEnv | undefined
|
|
130
|
+
return {
|
|
131
|
+
id,
|
|
132
|
+
name: id,
|
|
133
|
+
status: () => ({ id, name: id, accounts: [acct('u1', 0), acct('u2', 0)] }),
|
|
134
|
+
listModels: () => [],
|
|
135
|
+
getAlias: () => id,
|
|
136
|
+
chatOnce: async (_uid: string, _req: ChatRequest): Promise<ChatOnceResult> => ({ ok: true, status: 200, body: '{}' }),
|
|
137
|
+
dispose: () => {},
|
|
138
|
+
// 真实插件(traework)在 factory 里存下 env,调用时才读 onLateFailure
|
|
139
|
+
__capture: (env: SupplierEnv) => { captured = env; seen.push(env) },
|
|
140
|
+
__report: (uid: string, model: string) => captured?.onLateFailure?.(uid, model, 'quota', 'boom'),
|
|
141
|
+
} as unknown as SupplierModule
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
test('onLateFailure 挂到 env 上,且冷却作用于本供应商的池(坏号退出选号)', () => {
|
|
145
|
+
const { env: e } = env()
|
|
146
|
+
const seen: SupplierEnv[] = []
|
|
147
|
+
const p = latePlugin('supA', seen)
|
|
148
|
+
const loaded = wrapModule(p, e, 'test')
|
|
149
|
+
assert.notEqual(e.onLateFailure, undefined, 'wrapModule 之后回调已挂上(插件必须调用时才读)')
|
|
150
|
+
// 模拟插件在 factory 里存下 env —— 此刻再读 onLateFailure 才有值
|
|
151
|
+
;(p as unknown as { __capture(env: SupplierEnv): void }).__capture(e)
|
|
152
|
+
|
|
153
|
+
// 上报前:两个号都健康
|
|
154
|
+
const before = loaded.supplier.status().accounts.map((a) => a.cooling)
|
|
155
|
+
assert.deepEqual(before, [false, false])
|
|
156
|
+
// 上报 u1 在模型 m1 上的配额失败
|
|
157
|
+
;(p as unknown as { __report(uid: string, model: string): void }).__report('u1', 'm1')
|
|
158
|
+
const after = new Map(loaded.supplier.status().accounts.map((a) => [a.uid, a.cooling]))
|
|
159
|
+
assert.equal(after.get('u1'), true, '上报后该号应进入冷却')
|
|
160
|
+
assert.equal(after.get('u2'), false, '冷却是 (模型,号) 粒度,不能连坐同供应商其它号')
|
|
161
|
+
// 且真的退出选号:只剩 u2
|
|
162
|
+
assert.equal(loaded.supplier.pool.pick(loaded.supplier.accounts(), [], 'fallback', 'm1'), 'u2')
|
|
163
|
+
assert.equal(loaded.supplier.pool.pick(loaded.supplier.accounts(), [], 'fallback', 'm2'), 'u1', '换个模型不受影响')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('onLateFailure 不串供应商:各自落到自己的池', () => {
|
|
167
|
+
const { env: ea } = env()
|
|
168
|
+
const { env: eb } = env()
|
|
169
|
+
const loadedA = wrapModule(latePlugin('supA', []), ea, 'testA')
|
|
170
|
+
const loadedB = wrapModule(latePlugin('supB', []), eb, 'testB')
|
|
171
|
+
ea.onLateFailure?.('u1', 'm1', 'quota', 'boom')
|
|
172
|
+
const coolA = new Map(loadedA.supplier.status().accounts.map((a) => [a.uid, a.cooling]))
|
|
173
|
+
const coolB = new Map(loadedB.supplier.status().accounts.map((a) => [a.uid, a.cooling]))
|
|
174
|
+
assert.equal(coolA.get('u1'), true)
|
|
175
|
+
assert.equal(coolB.get('u1'), false, 'A 的失败不能冷到 B 的号')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('onLateFailure 空 uid(无账号供应商)→ 不记,不炸', () => {
|
|
179
|
+
const { env: e } = env()
|
|
180
|
+
const loaded = wrapModule(latePlugin('supA', []), e, 'test')
|
|
181
|
+
assert.doesNotThrow(() => e.onLateFailure?.('', 'm1', 'quota', 'boom'))
|
|
182
|
+
assert.deepEqual(loaded.supplier.status().accounts.map((a) => a.cooling), [false, false])
|
|
183
|
+
})
|
package/src/suppliers/loader.ts
CHANGED
|
@@ -83,13 +83,23 @@ const DIFF_CAPS = [
|
|
|
83
83
|
'checkinNow',
|
|
84
84
|
] as const
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* 每个供应商一份 env 副本:`onLateFailure` 是**按供应商**绑到本供应商的池上
|
|
88
|
+
* 的(见 wrapModule),共用一个 env 对象会让所有插件的上报都落到最后一个
|
|
89
|
+
* 供应商的池里。副本是浅拷贝,store/credentials 仍是同一实例。
|
|
90
|
+
*/
|
|
91
|
+
function ownEnv(env: SupplierEnv): SupplierEnv {
|
|
92
|
+
return { ...env }
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
/** 从文件构造 LoadedSupplier。 */
|
|
87
96
|
async function loadOne(file: string, env: SupplierEnv, source: 'builtin' | 'user'): Promise<LoadedSupplier> {
|
|
88
97
|
const mod = await importSupplierFile(file)
|
|
98
|
+
const mine = ownEnv(env)
|
|
89
99
|
// 支持 factory: 模块本身就是工厂函数
|
|
90
100
|
let instance: SupplierModule
|
|
91
101
|
if (typeof mod === 'function') {
|
|
92
|
-
instance = (mod as unknown as SupplierFactory)(
|
|
102
|
+
instance = (mod as unknown as SupplierFactory)(mine)
|
|
93
103
|
if (typeof instance !== 'object' || instance === null || typeof instance.id !== 'string' || instance.id === '') {
|
|
94
104
|
throw new Error(`供应商 factory 返回无效: ${file}`)
|
|
95
105
|
}
|
|
@@ -97,7 +107,7 @@ async function loadOne(file: string, env: SupplierEnv, source: 'builtin' | 'user
|
|
|
97
107
|
} else {
|
|
98
108
|
instance = mod
|
|
99
109
|
}
|
|
100
|
-
return wrapModule(instance,
|
|
110
|
+
return wrapModule(instance, mine, file, source)
|
|
101
111
|
}
|
|
102
112
|
|
|
103
113
|
/**
|
|
@@ -119,11 +129,21 @@ export function wrapModule(instance: SupplierModule, env: SupplierEnv, source: s
|
|
|
119
129
|
// 冷却键带 supplierId(跨供应商同名模型不串),故构造时注入本供应商 id。
|
|
120
130
|
const pool = new AccountPool(instance.id)
|
|
121
131
|
|
|
132
|
+
// 迟到失败的上报通道:流式响应已提交后插件才发现的错误,经它落回同一个池。
|
|
133
|
+
// **直接挂在插件持有的那个 env 对象上**(不新建副本)——factory 在建 pool
|
|
134
|
+
// 之前就跑完了,插件若构造时读了 env 就永远拿不到这个字段。插件必须在
|
|
135
|
+
// **调用时**读 `env.onLateFailure`(见契约注释),此刻它已经挂好。
|
|
136
|
+
env.onLateFailure = (uid, model, state, message): void => {
|
|
137
|
+
if (uid === '') return // 无账号供应商(如 opencode)没号可记
|
|
138
|
+
pool.noteFailure(uid, model, state, message)
|
|
139
|
+
}
|
|
140
|
+
|
|
122
141
|
const supplier: Supplier = {
|
|
123
142
|
id: instance.id,
|
|
124
143
|
name: instance.name,
|
|
125
144
|
priority: instance.priority ?? 0,
|
|
126
145
|
icon: (instance as { icon?: string }).icon,
|
|
146
|
+
apiKeyHint: (instance as { apiKeyHint?: string }).apiKeyHint,
|
|
127
147
|
status: (): SupplierStatus => {
|
|
128
148
|
const now = instance.status()
|
|
129
149
|
// 连接池显示别名覆盖:uid 不变,只改面板展示名
|
|
@@ -18,6 +18,10 @@ export const name = 'OpenRouter'
|
|
|
18
18
|
export const priority = 10 // 免费直连(opencode=0)之后
|
|
19
19
|
/** 面板图标。 */
|
|
20
20
|
export const icon = 'data:image/png;base64,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'
|
|
21
|
+
/**
|
|
22
|
+
* 「添加 API key」弹窗提示(OpenRouter API key 获取页)。
|
|
23
|
+
*/
|
|
24
|
+
export const apiKeyHint = '填入 OpenRouter API key(https://openrouter.ai/settings/keys),可起名字区分多个 key。'
|
|
21
25
|
|
|
22
26
|
const BASE = 'https://openrouter.ai/api/v1'
|
|
23
27
|
const MODELS_URL = `${BASE}/models`
|
|
@@ -109,6 +113,7 @@ export default function factory(env: SupplierEnv): SupplierModule {
|
|
|
109
113
|
name,
|
|
110
114
|
priority,
|
|
111
115
|
icon,
|
|
116
|
+
apiKeyHint,
|
|
112
117
|
status: (): SupplierStatusNow => {
|
|
113
118
|
// 只报「现在状态」:凭证是否存在。冷却/禁用/错误累计由核心叠加。
|
|
114
119
|
const accounts = orderedKeys().map(({ uid, acct }) => ({
|