dsh-router-core 0.3.10 → 0.3.12
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 +30 -1
- package/docs/ext.md +224 -0
- package/docs/pool-sticky-block.md +206 -0
- package/docs/suppliers.md +5 -1
- package/lib/client-registry.js +270 -96
- package/lib/client.js +270 -96
- package/lib/index.js +372 -13
- package/lib/suppliers/openrouter.js +6 -1
- package/lib/types/data-dir.d.ts +11 -0
- package/lib/types/ext/contract.d.ts +95 -0
- package/lib/types/ext/store.d.ts +16 -0
- package/lib/types/llm/adapter.d.ts +7 -0
- package/lib/types/router/account-pool.d.ts +47 -1
- package/lib/types/router/index.d.ts +15 -0
- package/lib/types/router/types.d.ts +2 -0
- package/lib/types/router/usage-store.d.ts +8 -0
- package/lib/types/shared.d.ts +20 -0
- package/lib/types/suppliers/contract.d.ts +21 -0
- package/package.json +1 -1
- package/src/client/ExtTab.tsx +143 -0
- package/src/client/RouterView.tsx +12 -4
- package/src/client/StatsTab.tsx +4 -2
- package/src/client/SupplierDetail.tsx +2 -2
- package/src/client/router.css +19 -0
- package/src/data-dir.test.ts +54 -0
- package/src/data-dir.ts +46 -0
- package/src/ext/contract.ts +115 -0
- package/src/ext/store.test.ts +144 -0
- package/src/ext/store.ts +125 -0
- package/src/index.ts +101 -6
- package/src/llm/adapter.test.ts +17 -0
- package/src/llm/adapter.ts +8 -1
- package/src/router/account-pool.test.ts +118 -1
- package/src/router/account-pool.ts +115 -3
- package/src/router/index.ts +41 -1
- package/src/router/models-cache.test.ts +38 -0
- package/src/router/types.ts +2 -0
- package/src/router/usage-store.ts +8 -0
- package/src/shared.ts +22 -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/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
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展 (Ext) 通用契约 —— dsh-router 的「扩展插件」扩展点(不是供应商)。
|
|
3
|
+
*
|
|
4
|
+
* 分工(2026-09 重构:核心只做管理,不做拦截):
|
|
5
|
+
* - **dsh-router 核心 = 管理面**:持有 `router.ext` 注册表(发现)、渲染面板与
|
|
6
|
+
* `/router/api/ext`(展示)、代存开关与插件数据(`router.extStore` →
|
|
7
|
+
* `<dataDir>/ext.json`)。核心**不挂 `tools/execute`、不裁决、不改写**。
|
|
8
|
+
* - **扩展插件 = 执行面**:自己挂监听、自己问 `isEnabled`、自己按 ready 裁决、
|
|
9
|
+
* 自己短路执行。怎么改一条命令是插件的**私事**,不在本契约里。
|
|
10
|
+
*
|
|
11
|
+
* 为什么把拦截从核心挪走:
|
|
12
|
+
* `tools/execute` 是任何插件都能自己挂的 around-dispatch waterfall(插件 ctx 不在
|
|
13
|
+
* agent scope 下就能收到全部派发)。核心代挂只在一个消费者(rtk)时是纯亏损:为留
|
|
14
|
+
* 40 行委派逻辑,付了共享表 + inject 广播 + 契约两处同步的代价。核心归位成管理面
|
|
15
|
+
* 后,扩展插件与 dsh-router 只在**注册表 + 存储**两处耦合。
|
|
16
|
+
*
|
|
17
|
+
* ponytail: 天花板 —— 核心代挂原本顺带解决「多个扩展插件各自挂监听会互相踩 / 顺序
|
|
18
|
+
* 不可控」。这个保护现在没了;目前只有 rtk 一家消费,无所谓。**第二家 ext 出现时
|
|
19
|
+
* 要重新收敛顺序**(升级路径:核心暴露一个按顺序委派的共享工具方法,而不是收回拦截)。
|
|
20
|
+
*
|
|
21
|
+
* 契约要点:
|
|
22
|
+
* - 扩展器只有**声明**(id / name / description)+ `getState()`;核心拿这些渲染面板。
|
|
23
|
+
* - `getState()` 只报**运行时事实**(ready 是否可用、detail 说明),不报开关。
|
|
24
|
+
* - **开关与数据归核心持久化**(`router.extStore` → `<dataDir>/ext.json`),插件不自己
|
|
25
|
+
* file IO —— 落盘位置由核心锚定(不跟 cwd 跑),读写经核心,插件无状态。
|
|
26
|
+
*/
|
|
27
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
28
|
+
|
|
29
|
+
/** 扩展器唯一 id(注册键)。 */
|
|
30
|
+
export type ExtId = string
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 扩展器当前状态(**运行时事实**,由插件现算,不持久化)。
|
|
34
|
+
*
|
|
35
|
+
* 注意:`enabled` 不在这里 —— 开关状态归核心持久化(`<dataDir>/ext.json`),
|
|
36
|
+
* 插件只负责做事 + 报自己是否可用。核心把两者合并后再给面板用。
|
|
37
|
+
*/
|
|
38
|
+
export interface ExtState {
|
|
39
|
+
/** 运行时是否就绪(如 RTK 二进制是否可用);false 时即使开启也不生效。 */
|
|
40
|
+
ready: boolean
|
|
41
|
+
/** 不就绪时的一句话说明(如「本机未装 rtk」),面板红字显示。 */
|
|
42
|
+
detail?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 注册到 `router.ext` 表的扩展器实现。核心(面板/API)只认这几个成员,
|
|
47
|
+
* 插件可以携带更多内部状态。
|
|
48
|
+
*/
|
|
49
|
+
export interface RouterExt {
|
|
50
|
+
/** 扩展器 id,也是 `router.ext` 表里的注册键。 */
|
|
51
|
+
readonly id: ExtId
|
|
52
|
+
/** 面板显示名(如「RTK」)。 */
|
|
53
|
+
readonly name: string
|
|
54
|
+
/** 面板副标题说明。 */
|
|
55
|
+
readonly description?: string
|
|
56
|
+
/** 当前运行时状态(ready + 不就绪时的说明)。 */
|
|
57
|
+
getState(): ExtState
|
|
58
|
+
/** 卸载清理(表删除时由 dsh-router 调用,可选)。 */
|
|
59
|
+
dispose?(): void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 面板/API 用的一条扩展器信息 = 核心存的开关 + 插件报的运行时事实。 */
|
|
63
|
+
export interface ExtInfo {
|
|
64
|
+
id: string
|
|
65
|
+
name: string
|
|
66
|
+
description?: string
|
|
67
|
+
/** 核心持久化的开关(默认关)。 */
|
|
68
|
+
enabled: boolean
|
|
69
|
+
/** 插件报的运行时就绪状态。 */
|
|
70
|
+
ready: boolean
|
|
71
|
+
/** 不就绪时的说明(面板红字)。 */
|
|
72
|
+
detail?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `router.ext` 共享聚合表:`{ [extId]: RouterExt }`(同一 live 对象可追加)。 */
|
|
76
|
+
export interface RouterExtService {
|
|
77
|
+
[extId: string]: RouterExt
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 扩展的存储面(核心 provide,插件 inject)。
|
|
82
|
+
*
|
|
83
|
+
* 插件**不自己 file IO**:落盘位置由核心用 `dataDirOf(ctx.baseUrl)` 锚定(不跟
|
|
84
|
+
* process.cwd() 跑),读写都经核心,同一文件、同一次原子写。
|
|
85
|
+
*
|
|
86
|
+
* 落盘形状:`<dataDir>/ext.json` = `{ "<id>": { "enabled": boolean, "data": unknown } }`
|
|
87
|
+
*/
|
|
88
|
+
export interface ExtStoreService {
|
|
89
|
+
/** 是否开启(未记录过 = 默认关)。 */
|
|
90
|
+
isEnabled(id: string): boolean
|
|
91
|
+
/** 置开关(面板/API 调)。 */
|
|
92
|
+
setEnabled(id: string, enabled: boolean): void
|
|
93
|
+
/** 读插件自己的数据块(没有 = undefined)。 */
|
|
94
|
+
readData<T = unknown>(id: string): T | undefined
|
|
95
|
+
/** 写插件自己的数据块(覆盖;与 enabled 同文件原子落盘)。 */
|
|
96
|
+
writeData(id: string, value: unknown): void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 读取当前 router.ext 表(同一 live 对象,可追加)——扩展插件用它注册自己。 */
|
|
100
|
+
export function currentExts(ctx: Context): RouterExtService | undefined {
|
|
101
|
+
const c = ctx as unknown as {
|
|
102
|
+
get?: (key: string) => unknown
|
|
103
|
+
router?: { ext?: RouterExtService }
|
|
104
|
+
}
|
|
105
|
+
return (c.get?.('router.ext') ?? c.router?.ext) as RouterExtService | undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 读取扩展存储服务——扩展插件用它读开关、读写自己存的数据。 */
|
|
109
|
+
export function currentExtStore(ctx: Context): ExtStoreService | undefined {
|
|
110
|
+
const c = ctx as unknown as {
|
|
111
|
+
get?: (key: string) => unknown
|
|
112
|
+
router?: { extStore?: ExtStoreService }
|
|
113
|
+
}
|
|
114
|
+
return (c.get?.('router.extStore') ?? c.router?.extStore) as ExtStoreService | undefined
|
|
115
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展开关存储测试 —— 开关状态归核心持久化(`<dataDir>/ext.json`)。
|
|
3
|
+
*
|
|
4
|
+
* 锁定的契约:
|
|
5
|
+
* - 默认关(没记录过 = 关)
|
|
6
|
+
* - 开启 → 落盘,新实例(模拟重启)读到
|
|
7
|
+
* - 按 id 隔离,互不影响
|
|
8
|
+
* - 坏数据读盘不炸
|
|
9
|
+
* - 重复设同值不写盘
|
|
10
|
+
*/
|
|
11
|
+
import test from 'node:test'
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { mkdtempSync, readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { ExtStore } from './store.ts'
|
|
17
|
+
|
|
18
|
+
/** 每个用例一个独立数据目录(stateFile 推导出 ext.json)。 */
|
|
19
|
+
function tempStore(preload?: unknown): { store: ExtStore; file: string } {
|
|
20
|
+
const dir = mkdtempSync(join(tmpdir(), 'dshr-ext-'))
|
|
21
|
+
const stateFile = join(dir, 'state.json')
|
|
22
|
+
if (preload !== undefined) writeFileSync(join(dir, 'ext.json'), JSON.stringify(preload))
|
|
23
|
+
return { store: new ExtStore(stateFile), file: join(dir, 'ext.json') }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('默认关', () => {
|
|
27
|
+
const { store } = tempStore()
|
|
28
|
+
assert.equal(store.isEnabled('rtk'), false)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('开启 → 落盘,重开还在', () => {
|
|
32
|
+
const { store, file } = tempStore()
|
|
33
|
+
store.setEnabled('rtk', true)
|
|
34
|
+
const raw = JSON.parse(readFileSync(file, 'utf8')) as { rtk?: { enabled: boolean } }
|
|
35
|
+
assert.equal(raw.rtk?.enabled, true)
|
|
36
|
+
// 新实例 = 模拟重启
|
|
37
|
+
assert.equal(new ExtStore(join(file, '..', 'state.json')).isEnabled('rtk'), true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('关回去 → 落盘 false', () => {
|
|
41
|
+
const { store, file } = tempStore()
|
|
42
|
+
store.setEnabled('rtk', true)
|
|
43
|
+
store.setEnabled('rtk', false)
|
|
44
|
+
assert.equal(new ExtStore(join(file, '..', 'state.json')).isEnabled('rtk'), false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('按 id 隔离,互不影响', () => {
|
|
48
|
+
const { store } = tempStore()
|
|
49
|
+
store.setEnabled('rtk', true)
|
|
50
|
+
assert.equal(store.isEnabled('rtk'), true)
|
|
51
|
+
assert.equal(store.isEnabled('other'), false)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('坏数据读盘不炸,当没配置过', () => {
|
|
55
|
+
const { store: s1 } = tempStore({ rtk: { enabled: 'yes' } })
|
|
56
|
+
assert.equal(s1.isEnabled('rtk'), false)
|
|
57
|
+
const { store: s2 } = tempStore({ rtk: null })
|
|
58
|
+
assert.equal(s2.isEnabled('rtk'), false)
|
|
59
|
+
const { store: s3 } = tempStore([])
|
|
60
|
+
assert.equal(s3.isEnabled('rtk'), false)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/** 造一个带旧 enhance.json 的目录(模拟升级前的状态)。 */
|
|
64
|
+
function legacyStore(enabled: boolean): { store: ExtStore; file: string } {
|
|
65
|
+
const dir = mkdtempSync(join(tmpdir(), 'dshr-ext-legacy-'))
|
|
66
|
+
writeFileSync(join(dir, 'enhance.json'), JSON.stringify({ enabled }))
|
|
67
|
+
const stateFile = join(dir, 'state.json')
|
|
68
|
+
return { store: new ExtStore(stateFile), file: join(dir, 'ext.json') }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test('迁移:旧 enhance.json 开着 → 新 ext.json 里 rtk 开', () => {
|
|
72
|
+
const { store, file } = legacyStore(true)
|
|
73
|
+
assert.equal(store.isEnabled('rtk'), true)
|
|
74
|
+
const raw = JSON.parse(readFileSync(file, 'utf8')) as { rtk?: { enabled: boolean } }
|
|
75
|
+
assert.equal(raw.rtk?.enabled, true)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('迁移:旧文件关着 → 不迁移(保持默认关)', () => {
|
|
79
|
+
const { store, file } = legacyStore(false)
|
|
80
|
+
assert.equal(store.isEnabled('rtk'), false)
|
|
81
|
+
assert.equal(existsSync(file), false)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('迁移:已有新配置时不覆盖', () => {
|
|
85
|
+
const { store: s1, file } = tempStore()
|
|
86
|
+
s1.setEnabled('rtk', false)
|
|
87
|
+
// 往同目录塞一个「开着」的旧文件,不应覆盖已经明确存过的新配置
|
|
88
|
+
writeFileSync(join(file, '..', 'enhance.json'), JSON.stringify({ enabled: true }))
|
|
89
|
+
const s2 = new ExtStore(join(file, '..', 'state.json'))
|
|
90
|
+
assert.equal(s2.isEnabled('rtk'), false)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('空 id 不落盘(连文件都不建)', () => {
|
|
94
|
+
const { store, file } = tempStore()
|
|
95
|
+
store.setEnabled('', true)
|
|
96
|
+
assert.equal(store.isEnabled(''), false)
|
|
97
|
+
assert.equal(existsSync(file), false, '空 id 应直接拒绝,不创建文件')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// ---- data 槽:给插件的通用抽屉(2026-09 加) ----
|
|
101
|
+
|
|
102
|
+
test('data:没存过 → undefined;写后能读回', () => {
|
|
103
|
+
const { store } = tempStore()
|
|
104
|
+
assert.equal(store.readData('rtk'), undefined)
|
|
105
|
+
store.writeData('rtk', { hits: 3 })
|
|
106
|
+
assert.deepEqual(store.readData('rtk'), { hits: 3 })
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('data:落盘 + 重启还在(与 enabled 同一文件)', () => {
|
|
110
|
+
const { store, file } = tempStore()
|
|
111
|
+
store.setEnabled('rtk', true)
|
|
112
|
+
store.writeData('rtk', { hits: 3 })
|
|
113
|
+
const raw = JSON.parse(readFileSync(file, 'utf8')) as { rtk?: { enabled: boolean; data?: { hits: number } } }
|
|
114
|
+
assert.equal(raw.rtk?.enabled, true)
|
|
115
|
+
assert.deepEqual(raw.rtk?.data, { hits: 3 })
|
|
116
|
+
const revived = new ExtStore(join(file, '..', 'state.json'))
|
|
117
|
+
assert.equal(revived.isEnabled('rtk'), true)
|
|
118
|
+
assert.deepEqual(revived.readData('rtk'), { hits: 3 })
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('data:写 data 不冲掉 enabled,置 enabled 不冲掉 data', () => {
|
|
122
|
+
const { store, file } = tempStore()
|
|
123
|
+
store.setEnabled('rtk', true)
|
|
124
|
+
store.writeData('rtk', { hits: 1 })
|
|
125
|
+
assert.equal(store.isEnabled('rtk'), true, '写 data 不能把开关冲掉')
|
|
126
|
+
store.setEnabled('rtk', false)
|
|
127
|
+
assert.deepEqual(store.readData('rtk'), { hits: 1 }, '置开关不能把 data 冲掉')
|
|
128
|
+
const revived = new ExtStore(join(file, '..', 'state.json'))
|
|
129
|
+
assert.equal(revived.isEnabled('rtk'), false)
|
|
130
|
+
assert.deepEqual(revived.readData('rtk'), { hits: 1 })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('data:按 id 隔离', () => {
|
|
134
|
+
const { store } = tempStore()
|
|
135
|
+
store.writeData('rtk', { hits: 1 })
|
|
136
|
+
assert.deepEqual(store.readData('other'), undefined)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('data:空 id 不落盘', () => {
|
|
140
|
+
const { store, file } = tempStore()
|
|
141
|
+
store.writeData('', { x: 1 })
|
|
142
|
+
assert.equal(store.readData(''), undefined)
|
|
143
|
+
assert.equal(existsSync(file), false)
|
|
144
|
+
})
|
package/src/ext/store.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展存储 —— **开关状态与插件数据都归核心持久化**,插件不自己 file IO。
|
|
3
|
+
*
|
|
4
|
+
* 落盘 `<dataDir>/ext.json`,按扩展器 id 存:
|
|
5
|
+
* `{ "rtk": { "enabled": true, "data": { ...插件自己的东西... } } }`
|
|
6
|
+
*
|
|
7
|
+
* 为什么归核心:跟 `supplier-config.json` 一个道理——开关是用户对**核心面板**的操作,
|
|
8
|
+
* 不该由扩展插件各自决定存哪、怎么存(否则每个插件长一份互相不一致的实现,核心也无
|
|
9
|
+
* 从统一裁决「能不能开」)。插件变薄:只报自己是否可用(`ready`)+ 做事。
|
|
10
|
+
*
|
|
11
|
+
* `data` 槽是给插件的通用抽屉(2026-09 加):插件不再为了存一点状态而自己开数据目录,
|
|
12
|
+
* 统一经 `router.extStore` service 读写,落盘位置仍由核心锚定(不跟 cwd 跑)。
|
|
13
|
+
*
|
|
14
|
+
* 写法与核心其他存储一致:先写 `.tmp` 再 `rename` 原子覆盖(避免写一半崩掉留坏文件),
|
|
15
|
+
* 权限 600;持久化失败不阻断。
|
|
16
|
+
*/
|
|
17
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
interface ExtFile {
|
|
21
|
+
[extId: string]: { enabled: boolean; data?: unknown }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Entry {
|
|
25
|
+
enabled: boolean
|
|
26
|
+
data: unknown
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 扩展开关 + 插件数据存储。 */
|
|
30
|
+
export class ExtStore {
|
|
31
|
+
private fp = ''
|
|
32
|
+
private byId: Record<string, Entry> = {}
|
|
33
|
+
|
|
34
|
+
constructor(stateFile: string) {
|
|
35
|
+
this.fp = stateFile ? join(dirname(stateFile), 'ext.json') : ''
|
|
36
|
+
this.load()
|
|
37
|
+
// 一次性迁移:开关早期由扩展插件自己写在 `enhance.json`(单文件、无法按 id 区分),
|
|
38
|
+
// 现在归核心按 id 存 `ext.json`。把旧值搬过来,避免用户已开的开关掉回关。
|
|
39
|
+
// 旧文件保留不删(不可逆操作留给人工),迁移只发生在新文件还没有时。
|
|
40
|
+
this.migrateLegacy()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private migrateLegacy(): void {
|
|
44
|
+
if (this.fp === '') return
|
|
45
|
+
if (Object.keys(this.byId).length > 0) return // 已有新配置,不覆盖
|
|
46
|
+
const legacy = join(dirname(this.fp), 'enhance.json')
|
|
47
|
+
let enabled = false
|
|
48
|
+
try {
|
|
49
|
+
const f = JSON.parse(readFileSync(legacy, 'utf8')) as { enabled?: unknown }
|
|
50
|
+
enabled = f.enabled === true
|
|
51
|
+
} catch {
|
|
52
|
+
return // 没有旧文件 / 读不了 → 无需迁移
|
|
53
|
+
}
|
|
54
|
+
if (!enabled) return
|
|
55
|
+
// 旧格式只有一个开关,当时唯一的扩展器就是 rtk。
|
|
56
|
+
this.byId.rtk = { enabled: true, data: undefined }
|
|
57
|
+
this.save()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private load(): void {
|
|
61
|
+
if (this.fp === '') return
|
|
62
|
+
let raw: string
|
|
63
|
+
try {
|
|
64
|
+
raw = readFileSync(this.fp, 'utf8')
|
|
65
|
+
} catch {
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const f = JSON.parse(raw) as ExtFile
|
|
70
|
+
for (const [id, v] of Object.entries(f)) {
|
|
71
|
+
if (id === '' || v === null || typeof v !== 'object') continue
|
|
72
|
+
if (typeof v?.enabled !== 'boolean') continue
|
|
73
|
+
this.byId[id] = { enabled: v.enabled, data: v.data }
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// 损坏则用默认关
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private save(): void {
|
|
81
|
+
if (this.fp === '') return
|
|
82
|
+
try {
|
|
83
|
+
const dir = dirname(this.fp)
|
|
84
|
+
if (dir !== '' && dir !== '.') mkdirSync(dir, { recursive: true })
|
|
85
|
+
const out: ExtFile = {}
|
|
86
|
+
for (const [id, e] of Object.entries(this.byId)) {
|
|
87
|
+
// 没数据的不写 data 键,保持文件干净(旧格式兼容)。
|
|
88
|
+
out[id] = e.data === undefined ? { enabled: e.enabled } : { enabled: e.enabled, data: e.data }
|
|
89
|
+
}
|
|
90
|
+
const raw = JSON.stringify(out, null, 2)
|
|
91
|
+
const tmp = this.fp + '.tmp'
|
|
92
|
+
writeFileSync(tmp, raw, { mode: 0o600 })
|
|
93
|
+
renameSync(tmp, this.fp)
|
|
94
|
+
} catch {
|
|
95
|
+
// 持久化失败不阻断
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 是否开启(未记录过 = 默认关)。 */
|
|
100
|
+
isEnabled(id: string): boolean {
|
|
101
|
+
return this.byId[id]?.enabled === true
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setEnabled(id: string, enabled: boolean): void {
|
|
105
|
+
if (id === '') return
|
|
106
|
+
const cur = this.byId[id]
|
|
107
|
+
if (cur?.enabled === enabled) return
|
|
108
|
+
this.byId[id] = { enabled, data: cur?.data }
|
|
109
|
+
this.save()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 读插件自己存的数据块(没存过 = undefined)。 */
|
|
113
|
+
readData<T = unknown>(id: string): T | undefined {
|
|
114
|
+
if (id === '') return undefined
|
|
115
|
+
return this.byId[id]?.data as T | undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 写插件自己的数据块(覆盖;与 enabled 同一文件落盘)。 */
|
|
119
|
+
writeData(id: string, value: unknown): void {
|
|
120
|
+
if (id === '') return
|
|
121
|
+
const cur = this.byId[id]
|
|
122
|
+
this.byId[id] = { enabled: cur?.enabled === true, data: value }
|
|
123
|
+
this.save()
|
|
124
|
+
}
|
|
125
|
+
}
|
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,9 @@ 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'
|
|
44
|
+
import { ExtStore } from './ext/store.ts'
|
|
45
|
+
import type { ExtInfo, ExtStoreService, RouterExt, RouterExtService } from './ext/contract.ts'
|
|
43
46
|
|
|
44
47
|
/**
|
|
45
48
|
* Plugin identity for cordis.yml rows — 必须与 package.json 的 name 一致。
|
|
@@ -129,12 +132,18 @@ export function apply(rawContext: unknown): void {
|
|
|
129
132
|
logger: { info: (message: string) => void; warn: (message: string) => void }
|
|
130
133
|
effect: (fn: () => () => void, label?: string) => void
|
|
131
134
|
inject: (services: string[], callback: (sctx: unknown) => void) => void
|
|
135
|
+
/** 配置树锚点 = profile 目录(宿主注入),用来把数据钉在 profile 里。 */
|
|
136
|
+
baseUrl?: string
|
|
132
137
|
}
|
|
133
138
|
const log = (msg: string): void => ctx.logger.info(`[dsh-router] ${msg}`)
|
|
134
139
|
|
|
135
|
-
|
|
140
|
+
// 数据目录必须是绝对路径:相对路径会让落盘位置跟着 process.cwd() 跑,
|
|
141
|
+
// 从别的目录启动就静默换一套空配置(见 src/data-dir.ts)。
|
|
142
|
+
const dataDir = dataDirOf(ctx.baseUrl)
|
|
143
|
+
const stateFile = join(dataDir, 'state.json')
|
|
144
|
+
log(`data dir: ${dataDir}`)
|
|
136
145
|
const store = new SupplierConfigStore(stateFile)
|
|
137
|
-
const credentials = new CredentialStore(join(
|
|
146
|
+
const credentials = new CredentialStore(join(dataDir, 'auths'))
|
|
138
147
|
const router = new Router(stateFile, store, log)
|
|
139
148
|
|
|
140
149
|
const keys = new KeysStore(stateFile)
|
|
@@ -178,8 +187,18 @@ export function apply(rawContext: unknown): void {
|
|
|
178
187
|
// 单装 dsh-router-codebuddy 时表永不出现,codebuddy 就一直不生效)。
|
|
179
188
|
ctx.provide('router.suppliers', {})
|
|
180
189
|
|
|
190
|
+
// ---- 扩展 (Ext):router.ext 注册表 + router.extStore 存储 ----
|
|
191
|
+
// 核心只做**管理面**:发现(注册表)、展示(面板/API)、代存开关与插件数据。
|
|
192
|
+
// 拦截 tools/execute / 裁决 enabled+ready / 短路执行全部归扩展插件自己 —— 见
|
|
193
|
+
// ext/contract.ts 文件头。同 router.suppliers 模式:核心持有空表,扩展插件经
|
|
194
|
+
// ctx.inject 追加进同一个 live 对象。
|
|
195
|
+
const exts: Record<string, unknown> = {}
|
|
196
|
+
ctx.provide('router.ext', exts)
|
|
197
|
+
// 开关 + 插件数据归核心持久化(<dataDir>/ext.json);插件经此 service 读写,不自己 file IO。
|
|
198
|
+
const extStore = new ExtStore(stateFile)
|
|
199
|
+
ctx.provide('router.extStore', extStore as unknown as ExtStoreService)
|
|
200
|
+
|
|
181
201
|
// 内置 + 用户 + 外部插件供应商(异步加载,完成后注册路由 + 加入路由器)
|
|
182
|
-
const dataDir = dirname(stateFile)
|
|
183
202
|
const registerLoaded = (loaded: LoadedSupplier): void => {
|
|
184
203
|
loadedSuppliers.push(loaded)
|
|
185
204
|
router.add(loaded.supplier)
|
|
@@ -222,7 +241,8 @@ export function apply(rawContext: unknown): void {
|
|
|
222
241
|
})
|
|
223
242
|
void (async () => {
|
|
224
243
|
try {
|
|
225
|
-
|
|
244
|
+
// 用户供应商目录也在 profile 里:跟着 baseUrl 走,别硬编码 `web`。
|
|
245
|
+
const userDir = join(profileDirOf(ctx.baseUrl), 'suppliers')
|
|
226
246
|
const { suppliers, errors } = await loadSuppliers({
|
|
227
247
|
builtinDir: join(import.meta.dirname, 'suppliers'), // 内置 js(opencode 等)
|
|
228
248
|
userDir,
|
|
@@ -297,6 +317,7 @@ export function apply(rawContext: unknown): void {
|
|
|
297
317
|
id: s.id,
|
|
298
318
|
name: s.name,
|
|
299
319
|
icon: loaded?.supplier.icon,
|
|
320
|
+
apiKeyHint: loaded?.supplier.apiKeyHint,
|
|
300
321
|
capabilities: loaded ? [...loaded.capabilities] : [],
|
|
301
322
|
source: loaded?.source ?? 'external',
|
|
302
323
|
}
|
|
@@ -439,6 +460,56 @@ export function apply(rawContext: unknown): void {
|
|
|
439
460
|
writeJson(res, 200, { ok: true, requireApiKey: keys.requireApiKey })
|
|
440
461
|
})
|
|
441
462
|
|
|
463
|
+
// ---- 扩展 (Ext):扩展器列表 + 开关 ----
|
|
464
|
+
route(`${ROUTER_API_BASE}/ext`, async (req, res) => {
|
|
465
|
+
if (req.method === 'PATCH') {
|
|
466
|
+
let body: { id?: unknown; enabled?: unknown }
|
|
467
|
+
try {
|
|
468
|
+
body = JSON.parse(await readBody(req, 64 << 10)) as { id?: unknown; enabled?: unknown }
|
|
469
|
+
} catch {
|
|
470
|
+
writeJson(res, 400, { ok: false, error: 'invalid JSON body' })
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
const ext = typeof body.id === 'string' ? exts[body.id] as RouterExtService[string] : undefined
|
|
474
|
+
if (!ext) {
|
|
475
|
+
writeJson(res, 404, { ok: false, error: 'extension not found' })
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
if (typeof body.enabled !== 'boolean') {
|
|
479
|
+
writeJson(res, 400, { ok: false, error: 'enabled must be a boolean' })
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
const wantOn = body.enabled
|
|
483
|
+
// 开启自检:不可用(ready !== true)的扩展**不能开启成功**——即使绕过面板
|
|
484
|
+
// 直连 API 也会被拒。由扩展器报告就绪状态,核心据此裁决。
|
|
485
|
+
if (wantOn && typeof ext.getState === 'function') {
|
|
486
|
+
const st = ext.getState()
|
|
487
|
+
if (st?.ready !== true) {
|
|
488
|
+
writeJson(res, 409, { ok: false, error: st?.detail ?? 'extension not ready' })
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// 开关归核心持久化。
|
|
493
|
+
extStore.setEnabled(ext.id, body.enabled)
|
|
494
|
+
}
|
|
495
|
+
const list: ExtInfo[] = Object.values(exts as RouterExtService)
|
|
496
|
+
.map((raw): ExtInfo | undefined => {
|
|
497
|
+
const e = raw as RouterExt
|
|
498
|
+
if (!e || typeof e.id !== 'string' || e.id === '') return undefined
|
|
499
|
+
const st = typeof e.getState === 'function' ? e.getState() : { ready: false }
|
|
500
|
+
return {
|
|
501
|
+
id: e.id,
|
|
502
|
+
name: e.name ?? e.id,
|
|
503
|
+
...(e.description !== undefined ? { description: e.description } : {}),
|
|
504
|
+
enabled: extStore.isEnabled(e.id),
|
|
505
|
+
ready: st?.ready === true,
|
|
506
|
+
...(st?.detail !== undefined ? { detail: st.detail } : {}),
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
.filter((e): e is ExtInfo => e !== undefined)
|
|
510
|
+
writeJson(res, 200, { ok: true, enhancers: list })
|
|
511
|
+
})
|
|
512
|
+
|
|
442
513
|
// ---- 概览看板:用量统计 ----
|
|
443
514
|
|
|
444
515
|
/** 解析并校验周期参数(不接受任意字符串)。 */
|
|
@@ -504,10 +575,26 @@ export function apply(rawContext: unknown): void {
|
|
|
504
575
|
return combos.map((c) => ({ id: c.name }))
|
|
505
576
|
}))
|
|
506
577
|
// adapter:模型目录自动带出组合;对话转发到本插件 /v1(组合路由在 /v1 内完成)。
|
|
578
|
+
// 带上组合的上下文窗口:没有它 dsh 的自动压缩算不出阈值、会静默关闭。
|
|
507
579
|
disposers.push(ctx.llm.registerAdapter(['router'], new RouterAdapter('http://localhost:3080/v1', {
|
|
508
|
-
comboModels: async () => (await router.combos()).map((c) =>
|
|
580
|
+
comboModels: async () => (await router.combos()).map((c) => {
|
|
581
|
+
const w = router.comboContextWindow(c)
|
|
582
|
+
return { id: c.name, ...(w !== undefined ? { contextWindow: w } : {}) }
|
|
583
|
+
}),
|
|
509
584
|
})))
|
|
510
585
|
log('llm provider (Router) + discovery + adapter registered ok')
|
|
586
|
+
// 预热模型缓存:`comboContextWindow` 只读缓存、不打上游,缓存空着就
|
|
587
|
+
// 报不出窗口。这里后台填一次,让第一次 resolveModel 就有值。
|
|
588
|
+
// 失败无所谓(下次 modelsOf 还会拉),不能冒出来。
|
|
589
|
+
void (async () => {
|
|
590
|
+
for (const c of await router.combos()) {
|
|
591
|
+
for (const ref of c.models) {
|
|
592
|
+
const at = ref.indexOf(',')
|
|
593
|
+
if (at < 0) continue
|
|
594
|
+
try { await router.modelsOf(ref.slice(0, at)) } catch { /* 单家失败不连坐 */ }
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
})().catch(() => {})
|
|
511
598
|
} catch (err) {
|
|
512
599
|
ctx.logger.warn(`[dsh-router] llm registration failed: ${(err as Error).message}`)
|
|
513
600
|
}
|
|
@@ -539,6 +626,14 @@ export function apply(rawContext: unknown): void {
|
|
|
539
626
|
|
|
540
627
|
ctx.effect(
|
|
541
628
|
() => () => {
|
|
629
|
+
// 注册表里的扩展器(插件自己 inject 的清理会摘键;这里兜底 dispose 仍在表里的)。
|
|
630
|
+
for (const raw of Object.values(exts as RouterExtService)) {
|
|
631
|
+
try {
|
|
632
|
+
;(raw as RouterExt)?.dispose?.()
|
|
633
|
+
} catch {
|
|
634
|
+
// 卸载清理失败不影响其他清理
|
|
635
|
+
}
|
|
636
|
+
}
|
|
542
637
|
for (const dispose of disposers.splice(0)) dispose()
|
|
543
638
|
router.dispose() // 内部会 flush 用量统计(防抖中的计数不能丢)
|
|
544
639
|
},
|
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
|
}
|