dsh-router-core 0.3.9 → 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/README.md +1 -1
- package/docs/suppliers.md +5 -1
- package/lib/client-registry.js +62 -8
- package/lib/client.js +62 -8
- package/lib/index.js +207 -18
- package/lib/suppliers/nvidia.js +6 -1
- package/lib/suppliers/opencode.js +6 -1
- package/lib/suppliers/openrouter.js +12 -2
- package/lib/types/data-dir.d.ts +11 -0
- package/lib/types/llm/adapter.d.ts +9 -5
- package/lib/types/router/index.d.ts +15 -0
- package/lib/types/router/types.d.ts +5 -1
- package/lib/types/shared.d.ts +2 -0
- package/lib/types/supplier-config.d.ts +6 -0
- package/lib/types/suppliers/contract.d.ts +26 -1
- package/package.json +1 -1
- package/src/client/RouterView.tsx +2 -2
- package/src/client/StatsTab.tsx +4 -2
- package/src/client/SupplierDetail.tsx +57 -4
- package/src/client/router.css +14 -0
- package/src/data-dir.test.ts +54 -0
- package/src/data-dir.ts +46 -0
- package/src/index.ts +53 -9
- package/src/llm/adapter.test.ts +34 -0
- package/src/llm/adapter.ts +34 -1
- package/src/router/index.ts +34 -2
- package/src/router/models-cache.test.ts +39 -1
- package/src/router/precise-dispatch.test.ts +2 -2
- package/src/router/types.ts +5 -1
- package/src/router/usage.test.ts +2 -2
- package/src/shared.ts +2 -0
- package/src/supplier-config.test.ts +15 -0
- package/src/supplier-config.ts +37 -1
- package/src/suppliers/contract.ts +27 -1
- package/src/suppliers/loader.test.ts +66 -1
- package/src/suppliers/loader.ts +29 -4
- package/src/suppliers/nvidia/plugin.ts +6 -1
- package/src/suppliers/opencode/plugin.ts +6 -1
- package/src/suppliers/openrouter/plugin.ts +11 -1
|
@@ -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` 时强制刷新来源(核心「获取模型」按钮调用)。 */
|
|
@@ -102,10 +123,14 @@ export interface SupplierModule {
|
|
|
102
123
|
* 选号/回退/健康判定全是核心的活。
|
|
103
124
|
* 无账号供应商(如 opencode)传空 uid,忽略即可。
|
|
104
125
|
*
|
|
126
|
+
* `lv` 是推理等级(reasoning effort),`'auto'` 表示不显式下发;
|
|
127
|
+
* 供应商各自决定怎么把 `lv` 映射到上游(OpenAI 兼容一般是
|
|
128
|
+
* `reasoning_effort` 字段)。
|
|
129
|
+
*
|
|
105
130
|
* 失败原因通过返回值的 `message` 报(核心在测试模型时直接用它做诊断提示),
|
|
106
131
|
* 插件不必自己存一份「上次失败」的状态。
|
|
107
132
|
*/
|
|
108
|
-
chatOnce(uid: string, req: ChatRequest): Promise<ChatOnceResult>;
|
|
133
|
+
chatOnce(uid: string, lv: string, req: ChatRequest): Promise<ChatOnceResult>;
|
|
109
134
|
dispose(): void;
|
|
110
135
|
/** 生成登录链接(加账号)。有它才显示连接池(账号池)。 */
|
|
111
136
|
generateLoginUrl?(): string | {
|
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
|
|
@@ -112,6 +112,8 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
112
112
|
const [checkingIn, setCheckingIn] = useState(false)
|
|
113
113
|
const [refreshing, setRefreshing] = useState(false)
|
|
114
114
|
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
|
115
|
+
const [renameUid, setRenameUid] = useState<string | null>(null)
|
|
116
|
+
const [renameDraft, setRenameDraft] = useState('')
|
|
115
117
|
const mounted = useRef(true)
|
|
116
118
|
|
|
117
119
|
useEffect(() => {
|
|
@@ -343,6 +345,33 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
343
345
|
setRemoveTarget(null)
|
|
344
346
|
}
|
|
345
347
|
|
|
348
|
+
const startRename = (account: RouterAccount): void => {
|
|
349
|
+
setRenameDraft(account.nickname ?? '')
|
|
350
|
+
setRenameUid(account.uid)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const saveRename = async (account: RouterAccount): Promise<void> => {
|
|
354
|
+
try {
|
|
355
|
+
const response = await fetch(`${ROUTER_API_BASE}/status/rename`, {
|
|
356
|
+
method: 'POST',
|
|
357
|
+
headers: { 'Content-Type': 'application/json' },
|
|
358
|
+
body: JSON.stringify({ supplier: supplier.id, uid: account.uid, name: renameDraft.trim() }),
|
|
359
|
+
cache: 'no-store',
|
|
360
|
+
})
|
|
361
|
+
if (!response.ok) {
|
|
362
|
+
showToast(`保存失败:HTTP ${response.status}`)
|
|
363
|
+
setRenameUid(null)
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
onRefresh()
|
|
367
|
+
showToast('连接名已保存')
|
|
368
|
+
} catch (err) {
|
|
369
|
+
showToast(`保存失败:${(err as Error).message}`)
|
|
370
|
+
} finally {
|
|
371
|
+
setRenameUid(null)
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
346
375
|
const toggleModel = async (id: string, enabled: boolean): Promise<void> => {
|
|
347
376
|
setToggling(true)
|
|
348
377
|
try {
|
|
@@ -644,7 +673,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
644
673
|
<div
|
|
645
674
|
key={account.uid}
|
|
646
675
|
className={`dshr-linkRow${dragIndex === index ? ' dshr-linkRowDragging' : ''}`}
|
|
647
|
-
draggable
|
|
676
|
+
draggable={renameUid !== account.uid}
|
|
648
677
|
onDragStart={(e) => { setDragIndex(index); e.dataTransfer.effectAllowed = 'move' }}
|
|
649
678
|
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }}
|
|
650
679
|
onDrop={(e) => {
|
|
@@ -667,7 +696,20 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
667
696
|
</div>
|
|
668
697
|
<div className="dshr-linkMain">
|
|
669
698
|
<div className="dshr-linkTitleLine">
|
|
670
|
-
|
|
699
|
+
{renameUid === account.uid
|
|
700
|
+
? (
|
|
701
|
+
<input
|
|
702
|
+
className="dshr-input dshr-mono"
|
|
703
|
+
value={renameDraft}
|
|
704
|
+
autoFocus
|
|
705
|
+
onChange={(e) => setRenameDraft(e.target.value)}
|
|
706
|
+
onKeyDown={(e) => {
|
|
707
|
+
if (e.key === 'Enter') void saveRename(account)
|
|
708
|
+
if (e.key === 'Escape') setRenameUid(null)
|
|
709
|
+
}}
|
|
710
|
+
/>
|
|
711
|
+
)
|
|
712
|
+
: <span className="dshr-linkName">{account.nickname || account.uid}</span>}
|
|
671
713
|
<span className={`dshr-badge ${badge.cls}`}>{badge.text}</span>
|
|
672
714
|
</div>
|
|
673
715
|
<div className="dshr-linkSub">
|
|
@@ -689,6 +731,17 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
689
731
|
</div>
|
|
690
732
|
</div>
|
|
691
733
|
<div className="dshr-linkOps">
|
|
734
|
+
<button
|
|
735
|
+
type="button"
|
|
736
|
+
className={`dshr-iconBtn dshr-iconBtn-sm dshr-linkEdit${renameUid === account.uid ? ' dshr-linkEdit-active' : ''}`}
|
|
737
|
+
title={renameUid === account.uid ? '保存名字' : '改名'}
|
|
738
|
+
onClick={() => {
|
|
739
|
+
if (renameUid === account.uid) void saveRename(account)
|
|
740
|
+
else startRename(account)
|
|
741
|
+
}}
|
|
742
|
+
>
|
|
743
|
+
<Icon d={renameUid === account.uid ? I.check : I.edit} size={15} />
|
|
744
|
+
</button>
|
|
692
745
|
<button type="button" className="dshr-iconBtn dshr-iconBtn-sm dshr-linkDelete" title="删除链接" onClick={() => setRemoveTarget(account)}>
|
|
693
746
|
<Icon d={I.delete} size={15} />
|
|
694
747
|
</button>
|
|
@@ -895,7 +948,7 @@ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRe
|
|
|
895
948
|
{canApiKey
|
|
896
949
|
? (
|
|
897
950
|
<div className="dshr-modalForm">
|
|
898
|
-
<p className="dshr-muted"
|
|
951
|
+
<p className="dshr-muted">{supplier.apiKeyHint ?? '填入 API key,可起名字区分多个 key。'}</p>
|
|
899
952
|
{error !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{error}</span></div>}
|
|
900
953
|
<input
|
|
901
954
|
className="dshr-input"
|
package/src/client/router.css
CHANGED
|
@@ -1634,6 +1634,20 @@ html[data-dsh-router-active]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-
|
|
|
1634
1634
|
background: color-mix(in srgb, var(--rs-danger) 14%, transparent);
|
|
1635
1635
|
}
|
|
1636
1636
|
|
|
1637
|
+
/* 改名:与删除一致,hover 行才显示;编辑中保持可见(否则保存按钮被藏住) */
|
|
1638
|
+
.dshr-linkEdit {
|
|
1639
|
+
color: var(--rs-muted);
|
|
1640
|
+
opacity: 0;
|
|
1641
|
+
transition: opacity .12s ease, color .12s ease, background .12s ease;
|
|
1642
|
+
}
|
|
1643
|
+
.dshr-linkRow:hover .dshr-linkEdit { opacity: 1; }
|
|
1644
|
+
.dshr-linkEdit:focus-visible { opacity: 1; }
|
|
1645
|
+
.dshr-linkEdit:hover {
|
|
1646
|
+
color: var(--rs-accent);
|
|
1647
|
+
background: color-mix(in srgb, var(--rs-accent) 14%, transparent);
|
|
1648
|
+
}
|
|
1649
|
+
.dshr-linkEdit.dshr-linkEdit-active { opacity: 1; }
|
|
1650
|
+
|
|
1637
1651
|
/* 模型 chips(9router ModelRow 风格) */
|
|
1638
1652
|
.dshr-modelChips {
|
|
1639
1653
|
display: flex;
|
|
@@ -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,
|
|
@@ -269,15 +276,18 @@ export function apply(rawContext: unknown): void {
|
|
|
269
276
|
writeJson(res, 413, { error: { message: 'request body too large', type: 'api_error', code: 'request_too_large' } })
|
|
270
277
|
return
|
|
271
278
|
}
|
|
272
|
-
let peek: { stream?: boolean; model?: string }
|
|
279
|
+
let peek: { stream?: boolean; model?: string; reasoning_effort?: unknown }
|
|
273
280
|
try {
|
|
274
|
-
peek = JSON.parse(body) as { stream?: boolean; model?: string }
|
|
281
|
+
peek = JSON.parse(body) as { stream?: boolean; model?: string; reasoning_effort?: unknown }
|
|
275
282
|
} catch {
|
|
276
283
|
writeJson(res, 400, { error: { message: 'invalid JSON body', type: 'api_error', code: 'invalid_request' } })
|
|
277
284
|
return
|
|
278
285
|
}
|
|
286
|
+
const lv = typeof peek.reasoning_effort === 'string' && peek.reasoning_effort !== ''
|
|
287
|
+
? peek.reasoning_effort
|
|
288
|
+
: 'auto'
|
|
279
289
|
await router.chatCompletions(
|
|
280
|
-
{ rawBody: body, stream: !!peek.stream, model: typeof peek.model === 'string' ? peek.model : '' },
|
|
290
|
+
{ rawBody: body, stream: !!peek.stream, model: typeof peek.model === 'string' ? peek.model : '', lv },
|
|
281
291
|
res,
|
|
282
292
|
)
|
|
283
293
|
}))
|
|
@@ -294,6 +304,7 @@ export function apply(rawContext: unknown): void {
|
|
|
294
304
|
id: s.id,
|
|
295
305
|
name: s.name,
|
|
296
306
|
icon: loaded?.supplier.icon,
|
|
307
|
+
apiKeyHint: loaded?.supplier.apiKeyHint,
|
|
297
308
|
capabilities: loaded ? [...loaded.capabilities] : [],
|
|
298
309
|
source: loaded?.source ?? 'external',
|
|
299
310
|
}
|
|
@@ -309,6 +320,23 @@ export function apply(rawContext: unknown): void {
|
|
|
309
320
|
writeJson(res, 200, { ok: true, accounts })
|
|
310
321
|
})
|
|
311
322
|
|
|
323
|
+
// 连接池连接改名:只改面板显示名(存核心 store 的 accountNames,uid 不变)。
|
|
324
|
+
route(`${ROUTER_API_BASE}/status/rename`, async (req, res) => {
|
|
325
|
+
let body: { supplier?: string; uid?: string; name?: string }
|
|
326
|
+
try {
|
|
327
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { supplier?: string; uid?: string; name?: string }
|
|
328
|
+
} catch {
|
|
329
|
+
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
if (typeof body.supplier !== 'string' || body.supplier === '' || typeof body.uid !== 'string' || body.uid === '') {
|
|
333
|
+
writeJson(res, 400, { ok: false, error: 'supplier and uid are required' })
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
store.setAccountName(body.supplier, body.uid, typeof body.name === 'string' ? body.name : '')
|
|
337
|
+
writeJson(res, 200, { ok: true })
|
|
338
|
+
})
|
|
339
|
+
|
|
312
340
|
route(`${ROUTER_API_BASE}/models`, async (_req, res) => {
|
|
313
341
|
try {
|
|
314
342
|
const models = await router.listModels()
|
|
@@ -484,10 +512,26 @@ export function apply(rawContext: unknown): void {
|
|
|
484
512
|
return combos.map((c) => ({ id: c.name }))
|
|
485
513
|
}))
|
|
486
514
|
// adapter:模型目录自动带出组合;对话转发到本插件 /v1(组合路由在 /v1 内完成)。
|
|
515
|
+
// 带上组合的上下文窗口:没有它 dsh 的自动压缩算不出阈值、会静默关闭。
|
|
487
516
|
disposers.push(ctx.llm.registerAdapter(['router'], new RouterAdapter('http://localhost:3080/v1', {
|
|
488
|
-
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
|
+
}),
|
|
489
521
|
})))
|
|
490
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(() => {})
|
|
491
535
|
} catch (err) {
|
|
492
536
|
ctx.logger.warn(`[dsh-router] llm registration failed: ${(err as Error).message}`)
|
|
493
537
|
}
|
package/src/llm/adapter.test.ts
CHANGED
|
@@ -509,3 +509,37 @@ test('流式:上游不发空行分隔时也要能分帧(前瞻完整 JSON)
|
|
|
509
509
|
}
|
|
510
510
|
assert.equal(texts.join(''), 'AB', '两帧都要收到')
|
|
511
511
|
})
|
|
512
|
+
|
|
513
|
+
test('模型目录:resolveModel 对每个组合声明推理等级(off/low/high/max,默认 high)', async () => {
|
|
514
|
+
const adapter = new RouterAdapter('http://x', { comboModels: async () => [
|
|
515
|
+
{ id: 'c-a', name: '组合A' },
|
|
516
|
+
{ id: 'c-b' },
|
|
517
|
+
] })
|
|
518
|
+
const a = await adapter.resolveModel('router', 'c-a')
|
|
519
|
+
assert.equal(a.provider, 'router')
|
|
520
|
+
assert.equal(a.id, 'c-a')
|
|
521
|
+
assert.equal(a.name, '组合A')
|
|
522
|
+
assert.ok(a.reasoning, '组合应声明推理等级,DSH 才允许显式 effort')
|
|
523
|
+
assert.deepEqual(a.reasoning!.efforts.map((e) => e.id), ['off', 'low', 'high', 'max'])
|
|
524
|
+
assert.equal(a.reasoning!.defaultEffort, 'high', '默认 High:调用方不指定时物化为 high')
|
|
525
|
+
// 无显示名时用模型 id 兜底,保证 name 非空(dsh-llm normalizeModelInfo 要求)
|
|
526
|
+
const b = await adapter.resolveModel('router', 'c-b')
|
|
527
|
+
assert.equal(b.name, 'c-b')
|
|
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
|
@@ -10,17 +10,26 @@ import {
|
|
|
10
10
|
EMPTY_RESPONSE_CODE,
|
|
11
11
|
LlmAdapter,
|
|
12
12
|
LlmError,
|
|
13
|
+
ReasoningEffortId,
|
|
13
14
|
ToolCallId,
|
|
14
15
|
attributionHeaders,
|
|
15
16
|
type GenerateOptions,
|
|
16
17
|
type LlmModelInfo,
|
|
18
|
+
type LlmReasoningEffortInfo,
|
|
19
|
+
type LlmResolvedModelInfo,
|
|
17
20
|
type StreamChunk,
|
|
18
21
|
} from '@deepseek-ai/dsh-llm'
|
|
19
22
|
import { mergeUsage, normalizeUsage, toTokenUsage, type UsageTokens } from '../router/usage-tokens.ts'
|
|
20
23
|
|
|
21
24
|
/** 模型目录来源:组合。 */
|
|
22
25
|
export interface RouterAdapterSource {
|
|
23
|
-
|
|
26
|
+
/**
|
|
27
|
+
* 组合列表。`contextWindow` 可选,但**必须尽量给**——自动压缩靠它算阈值
|
|
28
|
+
* (`dsh-compaction-basic` 在 `context` 缺失时直接抛错并静默关闭自动压缩,
|
|
29
|
+
* 于是上下文会一路涨到模型硬上限才炸)。
|
|
30
|
+
* 组合背后是异构供应商时给**最小的那个**(保守,宁可早压缩)。
|
|
31
|
+
*/
|
|
32
|
+
comboModels: () => Promise<Array<{ id: string; name?: string; contextWindow?: number }>>
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
/**
|
|
@@ -353,6 +362,14 @@ function completeJson(text: string): boolean {
|
|
|
353
362
|
* DSH adapter:provider `router`。模型目录 = 组合;stream 转发到本插件
|
|
354
363
|
* /v1/chat/completions(组合路由在 /v1 内完成)。
|
|
355
364
|
*/
|
|
365
|
+
/** Router provider 对外暴露的推理等级(对齐 dsh/DeepSeek:off/low/high/max)。 */
|
|
366
|
+
const ROUTER_REASONING_EFFORTS: readonly LlmReasoningEffortInfo[] = [
|
|
367
|
+
{ id: ReasoningEffortId('off'), name: 'Off' },
|
|
368
|
+
{ id: ReasoningEffortId('low'), name: 'Low' },
|
|
369
|
+
{ id: ReasoningEffortId('high'), name: 'High' },
|
|
370
|
+
{ id: ReasoningEffortId('max'), name: 'Max' },
|
|
371
|
+
]
|
|
372
|
+
|
|
356
373
|
export class RouterAdapter extends LlmAdapter {
|
|
357
374
|
private readonly baseURL: string
|
|
358
375
|
private readonly source: RouterAdapterSource
|
|
@@ -379,6 +396,22 @@ export class RouterAdapter extends LlmAdapter {
|
|
|
379
396
|
return out
|
|
380
397
|
}
|
|
381
398
|
|
|
399
|
+
async resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
|
400
|
+
const combos = await this.source.comboModels()
|
|
401
|
+
const found = combos.find((m) => m.id === model || m.name === model)
|
|
402
|
+
const name = found?.name ?? found?.id ?? model
|
|
403
|
+
// 组合背后是异构供应商,统一声明 dsh 推理等级;实际能否生效取决于
|
|
404
|
+
// 命中的上游(供应商各有各自的映射/忽略规则,见 applyReasoning + chatOnce(lv))。
|
|
405
|
+
// 默认 High:调用方不指定 effort 时,runtime 会物化为 'high' 下发。
|
|
406
|
+
return {
|
|
407
|
+
provider: 'router',
|
|
408
|
+
id: model,
|
|
409
|
+
name,
|
|
410
|
+
...(found?.contextWindow !== undefined ? { context: { contextWindow: found.contextWindow } } : {}),
|
|
411
|
+
reasoning: { efforts: ROUTER_REASONING_EFFORTS, defaultEffort: ReasoningEffortId('high') },
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
382
415
|
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
|
383
416
|
const body = wireRequest(options)
|
|
384
417
|
const controller = new AbortController()
|
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 []
|
|
@@ -950,7 +982,7 @@ export class Router {
|
|
|
950
982
|
const cfg = this.store.get(s.id)
|
|
951
983
|
// 无账号供应商:直接调一次(uid 传空,插件忽略)
|
|
952
984
|
if (s.accounts !== undefined && s.accounts().length === 0) {
|
|
953
|
-
const r = await s.chatOnce('', req)
|
|
985
|
+
const r = await s.chatOnce('', req.lv ?? 'auto', req)
|
|
954
986
|
if (!r.ok) {
|
|
955
987
|
if (r.state === 'no_such_model') return false // 不是我的模型:换供应商,不记账
|
|
956
988
|
if (trace !== undefined) trace.lastState = r.state
|
|
@@ -977,7 +1009,7 @@ export class Router {
|
|
|
977
1009
|
const uid = pool.pick(s.accounts().filter((a) => !tried.has(a.uid)), cfg.poolOrder, cfg.poolStrategy, req.model)
|
|
978
1010
|
if (uid === undefined) return false
|
|
979
1011
|
tried.add(uid)
|
|
980
|
-
const r = await s.chatOnce(uid, req)
|
|
1012
|
+
const r = await s.chatOnce(uid, req.lv ?? 'auto', req)
|
|
981
1013
|
if (!r.ok) {
|
|
982
1014
|
// 模型不属于本供应商:整个供应商都跳过,换号重试没有意义,
|
|
983
1015
|
// 也不能记在账号头上(否则无关账号会被攒够错误冷却掉)
|