dsh-router-core 0.3.0 → 0.3.1
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 +19 -11
- package/docs/screenshot.png +0 -0
- package/docs/suppliers.md +302 -0
- package/lib/client-registry.js +464 -203
- package/lib/client.js +464 -203
- package/lib/types/shared.d.ts +17 -1
- package/package.json +21 -4
- package/src/client/CheckinCard.tsx +180 -0
- package/src/client/RouterView.tsx +31 -27
- package/src/client/StatsTab.tsx +11 -1
- package/src/client/index.tsx +61 -11
- package/src/client/router.css +294 -38
- package/src/client/settings-nav-icon.ts +55 -0
- package/src/client/settings-section.tsx +21 -0
- package/src/shared.ts +13 -1
package/README.md
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
# dsh-router
|
|
2
2
|
|
|
3
|
-
**插件版的 9router** —— 不是另开一个网关服务,而是直接作为 DSH 插件嵌进
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
**插件版的 9router** —— 不是另开一个网关服务,而是直接作为 DSH 插件嵌进 DSH web,
|
|
4
|
+
在 `http://localhost:3080/v1` 上原生暴露 OpenAI 兼容端点,把请求路由到内部供应商。
|
|
5
|
+
管理界面在**设置 → 路由**(官方设置页座位,不是自己开的页面)。装好即用,
|
|
6
|
+
不用多开一个 9router、不用维护第二个端口、不用在网关和 DSH 之间搬配置。
|
|
7
|
+
|
|
8
|
+

|
|
7
9
|
|
|
8
10
|
## 为什么更优雅
|
|
9
11
|
|
|
10
12
|
- **零额外进程**:dsh-router 就是 DSH 插件,随 `dsh web` 启停,天然同源
|
|
11
|
-
(`/router/api/*` 无 CORS
|
|
13
|
+
(`/router/api/*` 无 CORS、面板嵌在设置里),不像 9router 要独立跑一个
|
|
12
14
|
Next.js 服务再对接;
|
|
13
15
|
- **供应商即插即拔**:内置供应商(如 opencode / openrouter / nvidia)随插件分发;
|
|
14
16
|
更多供应商 = 装一个 DSH 插件(`dsh-router-*`)经 cordis service
|
|
@@ -29,12 +31,13 @@ OpenAI 兼容端点,把请求路由到内部供应商。装好即用,不用多
|
|
|
29
31
|
> 供应商开发与接入规范见 [`docs/suppliers.md`](docs/suppliers.md)
|
|
30
32
|
> (契约 / 加载顺序 / 模型统一策略 / 内置供应商参考实现)。
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
面板挂在 **设置 → 路由**(官方 `settings.section` 座位,排在「模型」下面):
|
|
33
35
|
|
|
34
|
-
- **返回会话** — 左侧按钮,关闭面板回到聊天;
|
|
35
36
|
- **概览** — 用量看板(默认页):
|
|
36
37
|
- 周期切换 **今日 / 24 小时 / 7 天 / 30 天**;
|
|
37
38
|
- 汇总卡:总请求(含成功率)、输入 Tokens、输出 Tokens、缓存 Tokens、平均耗时(含首字节);
|
|
39
|
+
- 签到卡:**一键签到**所有支持签到的供应商(按 `checkinNow` 能力筛),并显示
|
|
40
|
+
「今天点过没」;
|
|
38
41
|
- Token 趋势折线图:鼠标悬停 / 触摸点选 / 键盘 `←` `→`(`Home` `End` 到两端,`Esc` 取消)
|
|
39
42
|
看每个时段;读数和峰值用 K/M 缩写,精确值在悬停提示里;
|
|
40
43
|
- Top 榜:按供应商 / 按模型(请求数带失败计数);
|
|
@@ -47,6 +50,10 @@ OpenAI 兼容端点,把请求路由到内部供应商。装好即用,不用多
|
|
|
47
50
|
缓存口径:OpenAI 系 `prompt_tokens` **含**缓存,Claude 系不含(单报
|
|
48
51
|
`cache_read_input_tokens`),归一时统一折成「prompt 含缓存」,
|
|
49
52
|
所以「缓存 Tokens」是「输入 Tokens」的**子集**,不是并列的第三种;
|
|
53
|
+
签到口径:卡片上的「今日已点」= 今天在这个浏览器点过这个按钮(记在
|
|
54
|
+
localStorage),**不代表上游一定签上了**——真凭据是上游的 `checked_in`,
|
|
55
|
+
当前契约没有「查签到状态」的能力,要真状态得先给供应商契约加
|
|
56
|
+
`checkinStatus?()`(升级路径写进 `CheckinCard.tsx` 头注释)。
|
|
50
57
|
- **供应商** — 供应商卡片(内置 / 插件分组),点击进入详情:
|
|
51
58
|
- **链接池** — 账号列表(冷却/禁用/健康数/积分),支持删除;
|
|
52
59
|
- **加链接** — 按供应商能力弹出不同流程:URL 登录(生成链接 → 浏览器登录 → 回调)、
|
|
@@ -111,9 +118,10 @@ curl -X POST http://localhost:3080/v1/chat/completions \
|
|
|
111
118
|
|
|
112
119
|
```
|
|
113
120
|
浏览器(client 半)
|
|
114
|
-
└─
|
|
115
|
-
├─
|
|
116
|
-
├─ RouterView tab: 概览 / 供应商 / 组合 / 端点与密钥
|
|
121
|
+
└─ 设置 → 路由(settings.section 座位, order 10, 排在「模型」下面)
|
|
122
|
+
├─ RouterSettingsSection 注册入口(settings-section.tsx)
|
|
123
|
+
├─ RouterView tab: 概览 / 供应商 / 组合 / 端点与密钥(tab 条:下划线指示器)
|
|
124
|
+
├─ StatsTab 概览:用量看板(周期按钮组 + 汇总卡 + 折线趋势 + Top 榜 + 最近请求)
|
|
117
125
|
├─ SupplierDetail 供应商详情:链接池 + 加链接 + 可用模型
|
|
118
126
|
├─ EndpointTab 端点 URL + requireApiKey + 密钥管理
|
|
119
127
|
└─ fetch /router/api/* (同源,无 CORS)
|
|
@@ -173,7 +181,7 @@ dsh plugin --profile web add dsh-router-core
|
|
|
173
181
|
该命令会在 profile 里 `pnpm add`,并自动把 `dsh-router-core` 加入
|
|
174
182
|
`dsh.profile.bundles`(包声明了 `dsh.bundle.patch`,即 `cordis.patch.yml`)。
|
|
175
183
|
|
|
176
|
-
然后**重启 `dsh web
|
|
184
|
+
然后**重启 `dsh web`**。打开设置面板,左侧导航「模型」下面会出现 **路由**。
|
|
177
185
|
|
|
178
186
|
> 更多供应商:DSH 插件形态的供应商各自发 npm 包,同样 `dsh plugin --profile web add <包名>`
|
|
179
187
|
> 即可;供应商接入与开发见 [`docs/suppliers.md`](docs/suppliers.md)。
|
|
Binary file
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# 供应商开发
|
|
2
|
+
|
|
3
|
+
供应商是可插拔 js 模块,只提供**差异化能力**。写好放到
|
|
4
|
+
`~/.dsh/profiles/web/suppliers/*.js`,重启后自动加载,出现在「设置 → 路由 → 供应商」。
|
|
5
|
+
|
|
6
|
+
## 分工:策略归核心,js 只管单账户
|
|
7
|
+
|
|
8
|
+
这是整个契约的地基,写 js 前先认清边界:
|
|
9
|
+
|
|
10
|
+
| | 谁负责 |
|
|
11
|
+
|---|---|
|
|
12
|
+
| 组合回退、按策略选中模型 | 核心 |
|
|
13
|
+
| **账号池**:选号、冷却、禁用、连续错误累计、遍历回退 | 核心 |
|
|
14
|
+
| 响应写入(状态行、SSE 头、JSON body) | 核心 |
|
|
15
|
+
| 模型启用/禁用、自定义模型、别名 | 核心 |
|
|
16
|
+
| 凭证存储(`env.credentials`) | 核心 |
|
|
17
|
+
| **对单个账号调通上游**(协议、鉴权、SSE 解析/生成) | **js** |
|
|
18
|
+
| 列模型、登录、加 key、删链接、签到、拉积分 | **js** |
|
|
19
|
+
|
|
20
|
+
关键推论:**js 不遍历账号、不维护冷却表、不写 `res`**。
|
|
21
|
+
`chatOnce(uid, req)` 一次只服务一个账号,成败告诉核心,换不换号由核心决定。
|
|
22
|
+
这样「哪个账号坏了」「为什么坏」是核心的一等信息,组合回退才做得了判断。
|
|
23
|
+
|
|
24
|
+
## 契约
|
|
25
|
+
|
|
26
|
+
ESM 模块,export 一个对象(或 default export):
|
|
27
|
+
|
|
28
|
+
```js
|
|
29
|
+
export const id = 'myprovider' // 唯一 id(小写字母数字 - _)
|
|
30
|
+
export const name = 'My Provider' // 显示名
|
|
31
|
+
export const priority = 10 // 数字越小越优先(免费优先)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 必须
|
|
35
|
+
|
|
36
|
+
| 方法 | 说明 |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `status()` | 报账号**现在状态**:`{ id, name, accounts: [{ uid, nickname, credits, state }] }` |
|
|
39
|
+
| `listModels(force?)` | `[{ id, context_length? }]`;`force=true` 时强制刷新来源(「获取模型」按钮调用) |
|
|
40
|
+
| `getAlias()` | 默认前缀 = 供应商 id(模型全名 = alias/id,别名留空即用 id) |
|
|
41
|
+
| `chatOnce(uid, req)` | **对单个账号调一次上游**,见下 |
|
|
42
|
+
| `dispose()` | 停止时清理 |
|
|
43
|
+
|
|
44
|
+
### 可选(差异化)
|
|
45
|
+
|
|
46
|
+
| 方法 | 作用 |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `generateLoginUrl()` / `completeLogin(callbackUrl)` | 添加链接(有它才显示连接池) |
|
|
49
|
+
| `addApiKey({ name, apiKey })` / `removeLink(uid)` | API key 账号:弹窗填名字 + key;删除链接清凭证 |
|
|
50
|
+
| `pollLogin()` | 轮询式登录:面板隐藏「粘贴回调链接」步骤,登录后直接点完成 |
|
|
51
|
+
| `checkinNow(uid)` | 单个链接签到(核心遍历所有链接 + 汇总),见下 |
|
|
52
|
+
| `lastError()` | 上次 `chatOnce` 的失败原因(诊断用) |
|
|
53
|
+
|
|
54
|
+
凭证(token)由核心统一存储,js 通过 `env.credentials`(list/get/save/remove)访问。
|
|
55
|
+
|
|
56
|
+
## `status()`:报「现在状态」,不报冷却
|
|
57
|
+
|
|
58
|
+
js 报告**此刻**观察到的账户状况,而不是把上游原始响应丢给核心解读:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
{ uid, nickname?, credits: number, state: AccountState, message? }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`AccountState` 是 js **解读**上游信号后的语义值:
|
|
65
|
+
|
|
66
|
+
| state | 含义 |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `ok` | 正常 |
|
|
69
|
+
| `rate_limit` | 限流(429 类) |
|
|
70
|
+
| `quota` | 额度/权益不足 |
|
|
71
|
+
| `session_dead` | 凭证彻底失效,必须重新登录才能恢复 |
|
|
72
|
+
| `unavailable` | 上游不可用(404 / 服务下线) |
|
|
73
|
+
| `transport` | 网络/连接层失败(没拿到 HTTP 状态) |
|
|
74
|
+
| `unknown` | 说不清是什么错 |
|
|
75
|
+
| `no_such_model` | **这个模型不属于本供应商**(不是账号的失败) |
|
|
76
|
+
|
|
77
|
+
**`no_such_model` 是必须的,不能拿 `unavailable` 顶替。** 后者在核心策略里
|
|
78
|
+
会计一次连续错误——组合里每有一个别人家的模型,就会给无关账号攒一次错误,
|
|
79
|
+
攒够 3 次把它冷却 10 分钟,表现为「组合越用越没号可用」。所以「这个模型不属于我」
|
|
80
|
+
一定要报 `no_such_model`,核心才知道该换供应商而不是换账号。
|
|
81
|
+
|
|
82
|
+
**js 只说「现在怎么了」,不说「该怎么办」。** 冷却多久、是否禁用、要不要换号
|
|
83
|
+
都是核心的策略(见下),js 不该自己记冷却表——那样每个插件都会长出一份
|
|
84
|
+
互相不一致的实现。
|
|
85
|
+
|
|
86
|
+
核心在 `status()` 之上叠加 `cooling` / `disabled` / `err_count` / `until` / `reason`
|
|
87
|
+
再给面板,所以 js 不用(也不该)填这些字段。`accounts` 为空数组 = 无账号供应商。
|
|
88
|
+
|
|
89
|
+
## `chatOnce(uid, req)`:一次只服务一个账号
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
type ChatOnceResult =
|
|
93
|
+
| { ok: true; stream: ReadableStream<Uint8Array> } // 流式
|
|
94
|
+
| { ok: true; status: number; body: string } // 非流式
|
|
95
|
+
| { ok: false; state: AccountState; message: string } // 失败
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
要点:
|
|
99
|
+
|
|
100
|
+
- **不写 `res`**。响应写入归核心。流式时 js 把上游协议**转成 OpenAI SSE** 后把流
|
|
101
|
+
交回来(格式转换是上游协议细节,归 js;写响应是策略,归核心)
|
|
102
|
+
- **不遍历账号、不 `continue` 换号**。核心按 `poolOrder` + `poolStrategy` 选号,
|
|
103
|
+
拿到 `{ ok:false }` 自己决定下一个试谁
|
|
104
|
+
- 失败时给 `state`,核心按它处置:
|
|
105
|
+
|
|
106
|
+
| state | 核心处置 |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `rate_limit` | 冷却 1 分钟 |
|
|
109
|
+
| `quota` | 冷却 10 分钟 |
|
|
110
|
+
| `session_dead` | **永久禁用**(需重新登录才能恢复) |
|
|
111
|
+
| `unavailable` / `transport` / `unknown` | 计一次连续错误,攒够 3 次冷却 10 分钟 |
|
|
112
|
+
| `no_such_model` | **不惩罚账号**,核心直接跳过整个供应商换下一个 |
|
|
113
|
+
|
|
114
|
+
- 非流式若聚合后发现**空响应**(无 content / reasoning / tool_calls),请返回
|
|
115
|
+
`{ ok:false, state:'unknown' }` 而不是 `ok:true`——空响应交给核心换号/换模型回退,
|
|
116
|
+
比当作成功更有用
|
|
117
|
+
|
|
118
|
+
冷却/禁用/错误累计目前是**内存态**,重启归零(与凭证/积分的持久化不同)。
|
|
119
|
+
要跨重启保留,届时给账号池加一个落盘 state 文件即可,接口不用变。
|
|
120
|
+
|
|
121
|
+
### 流式一旦写出第一个字节才绑死
|
|
122
|
+
|
|
123
|
+
核心**推迟到第一个字节才写响应头**,所以:
|
|
124
|
+
|
|
125
|
+
- 「上游刚连上就断」(一个字节都没吐)还能换账号/换模型重试
|
|
126
|
+
- 一旦写出第一个字节就绑死了——HTTP 语义如此,换任何实现都一样
|
|
127
|
+
(9router 同样如此)。之后流中途出错只能作用于**后续**请求
|
|
128
|
+
- 要让「上游吐到一半才断」也能回退,必须在写响应头前缓冲判定,代价是
|
|
129
|
+
首字节延迟,目前没做
|
|
130
|
+
|
|
131
|
+
所以 js 的流转换要**尽早产出第一个字节**,别攒着。攒着不仅延迟高,还会把
|
|
132
|
+
自己的失败伪装成「可回退」,让组合以为没成功而反复重试别的模型。
|
|
133
|
+
|
|
134
|
+
## 测试模型(核心通用,js 不实现)
|
|
135
|
+
|
|
136
|
+
面板「测试」由核心统一处理:构造最小 ping 请求,走**真实的 `chatOnce` 路径**
|
|
137
|
+
(账号池回退/冷却自动生效),响应写进丢弃用的 sink。js 不需要(也不应该)自己
|
|
138
|
+
实现一份测试逻辑——只跑第一个账号的那种实现会分不清「这个账号额度没了」和
|
|
139
|
+
「这个模型真的不支持」。
|
|
140
|
+
|
|
141
|
+
判定:`chatOnce` 成功**且** sink 里状态码 < 400 才算通过。失败时错误来源优先级:
|
|
142
|
+
sink 里的上游错误 → `lastError()`。
|
|
143
|
+
|
|
144
|
+
js 只需把失败原因记下来并通过 `lastError()` 暴露,核心就能给出「额度/限流问题」
|
|
145
|
+
还是「模型不支持」这类可诊断提示。前置校验(模型不属于本供应商)也建议写进
|
|
146
|
+
`lastError`(如 `unknown model "xxx"`),否则用户只看到模糊的通用提示。
|
|
147
|
+
|
|
148
|
+
## 签到与积分(可选)
|
|
149
|
+
|
|
150
|
+
有 `checkinNow(uid)` 时面板才显示「签到」按钮。**js 只签一个 uid**,遍历所有
|
|
151
|
+
链接 + 结果汇总是核心的活。单个 uid 返回:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
{ ok, status, message? }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- `status`:`'ok'`(签到成功)/ `'already'`(今日已签到)/ `'error'`(失败)
|
|
158
|
+
- **禁用与否由 js 自己判定**:核心遍历**所有**链接(不替 js 筛),
|
|
159
|
+
js 对不可用链接自己回一个非成功 status
|
|
160
|
+
- `message` 直接透给用户看;某个 uid 抛错由核心兜住记成 `error`,不会带垮其它链接
|
|
161
|
+
- 核心汇总后给面板:`{ ok, total, succeeded, already, results }`
|
|
162
|
+
|
|
163
|
+
`credits`(`status().accounts[].credits`)用于面板显示积分/额度;上游接口不实时时
|
|
164
|
+
建议缓存 + 签到后刷新。**填「剩余额度」而不是「已消耗」**——额度类接口两者常常
|
|
165
|
+
都有,填反了面板数字会越用越多。
|
|
166
|
+
|
|
167
|
+
上游幂等要处理好:有的上游「今日已签到」返回 **HTTP 400 + 业务 code**,
|
|
168
|
+
所以要先解析 body 的 code 再判 HTTP 状态,否则幂等情况会被误报成失败。
|
|
169
|
+
|
|
170
|
+
## 用量统计(核心通用,js 不实现)
|
|
171
|
+
|
|
172
|
+
面板「概览」看板的数字全由核心采集,js 不用做任何事。但**流式 js 有一件事
|
|
173
|
+
要注意:别吞掉上游的 `usage` 帧**。
|
|
174
|
+
|
|
175
|
+
- 核心在流式路径上旁路统计:原字节原样转给客户端,统计在旁边读同一份流。
|
|
176
|
+
所以**统计不会增加首字节延迟**,js 也不必为统计做任何适配。
|
|
177
|
+
- 若上游把 `usage` 拆在多个事件里(如先给输入、后给输出),核心按**字段取
|
|
178
|
+
最大值**合并——不是取最后一帧。所以 js 不必为了「凑一份完整 usage」而缓存
|
|
179
|
+
或改写帧,**原样透传即可**。
|
|
180
|
+
- 上游压根不发 `usage` 时,核心按 ~4 字符/token 估算,面板上标 `~`。
|
|
181
|
+
想让看板显示真值,就在流的最后一帧把上游的 `usage` 原样带上。
|
|
182
|
+
- **失败请求不估算 token**:它没到上游,按请求体字符数编造输入 token 只会
|
|
183
|
+
把总量灌水。请求数、成功率、耗时照常统计。
|
|
184
|
+
|
|
185
|
+
### `usage` 字段形态:归一由核心做,别自己改
|
|
186
|
+
|
|
187
|
+
上游 `usage` 字段名各家不同(`prompt_tokens` / `input_tokens` /
|
|
188
|
+
`promptTokenCount` / `cache_read_input_tokens` …, 还有 OpenAI 的
|
|
189
|
+
`prompt_tokens_details.cached_tokens`),核心的 `normalizeUsage` 已经全部认。
|
|
190
|
+
**js 原样透传就行,不要自己换算字段名。**
|
|
191
|
+
|
|
192
|
+
缓存口径特别注意(这是唯一需要 js 理解的语义差异):
|
|
193
|
+
|
|
194
|
+
- **OpenAI 系**:`prompt_tokens` **已含**缓存 → 归一后 prompt 含缓存
|
|
195
|
+
- **Claude 系**:`input_tokens` **不含**缓存,缓存单报
|
|
196
|
+
`cache_read_input_tokens` → 核心把它折进来,统一成「prompt 含缓存」
|
|
197
|
+
|
|
198
|
+
所以下游两处口径都自洽:面板「缓存 Tokens」是「输入 Tokens」的**子集**
|
|
199
|
+
(不是并列的第三种),而交给 DSH 会话的 `TokenUsage` 走 **DISJOINT 口径**
|
|
200
|
+
(`inputTokens` 只算未缓存 + 单列 `cacheReadTokens`),由 `toTokenUsage`
|
|
201
|
+
在写入侧换算。这条换算不做会出真故障:DSH 的 token-meter 投影 schema 是
|
|
202
|
+
`z.number().int().nonnegative()`,NaN 一进去校验就抛,整条 `session.history`
|
|
203
|
+
RPC 失败,表现为「历史加载失败」。
|
|
204
|
+
|
|
205
|
+
## 刷新链接池(核心通用,js 不实现)
|
|
206
|
+
|
|
207
|
+
面板链接池的「刷新」按钮是核心的活,js 不用做任何事:
|
|
208
|
+
|
|
209
|
+
1. **积分**:核心连着调 `status()` 等它落地;`status().accounts[].credits`
|
|
210
|
+
该缓存就缓存,不该缓存就现拉,核心只认这个字段
|
|
211
|
+
2. **健康**:核心拿一个启用模型跑一次最简会话(复用「测试模型」的 `chatOnce`
|
|
212
|
+
路径),回答「这个供应商还有没有活着的链接」——**不按链接细分**
|
|
213
|
+
|
|
214
|
+
天花板与升级路径:刷新积分目前是**轮询等落地**(js 缓存没过期时不会真拉上游,
|
|
215
|
+
此时按钮退化成「重读一次状态」)。哪天 js 愿意暴露可 `await` 的刷新能力,
|
|
216
|
+
这里就不用轮询了。
|
|
217
|
+
|
|
218
|
+
## 示例
|
|
219
|
+
|
|
220
|
+
```js
|
|
221
|
+
// ~/.dsh/profiles/web/suppliers/echo.js
|
|
222
|
+
export const id = 'echo'
|
|
223
|
+
export const name = 'Echo'
|
|
224
|
+
export const priority = 10
|
|
225
|
+
|
|
226
|
+
// 只报「现在状态」:冷却/禁用/错误累计由核心叠加
|
|
227
|
+
const accounts = [{ uid: 'echo-1', nickname: 'Echo 1', credits: 100, state: 'ok' }]
|
|
228
|
+
|
|
229
|
+
export function status() { return { id, name, accounts } }
|
|
230
|
+
export function listModels() { return [{ id: 'echo-model' }] }
|
|
231
|
+
export function getAlias() { return 'echo' }
|
|
232
|
+
|
|
233
|
+
export async function chatOnce(uid, req) {
|
|
234
|
+
if (req.stream) {
|
|
235
|
+
const text = 'data: ' + JSON.stringify({
|
|
236
|
+
id: 'echo', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000),
|
|
237
|
+
model: req.model, choices: [{ index: 0, delta: { content: `echo reply (${uid})` } }],
|
|
238
|
+
}) + '\n\ndata: [DONE]\n\n'
|
|
239
|
+
return { ok: true, stream: new ReadableStream({ start(c) { c.enqueue(new TextEncoder().encode(text)); c.close() } }) }
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
ok: true,
|
|
243
|
+
status: 200,
|
|
244
|
+
body: JSON.stringify({ choices: [{ message: { role: 'assistant', content: `echo reply (${uid})` } }] }),
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function dispose() {}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## 加载顺序
|
|
252
|
+
|
|
253
|
+
1. 内置:`<plugin>/lib/suppliers/*.js`(随插件分发)
|
|
254
|
+
2. 用户:`~/.dsh/profiles/web/suppliers/*.js`(**覆盖**内置同 id)
|
|
255
|
+
3. 外部插件:其他 DSH 插件通过 cordis service `router.suppliers`
|
|
256
|
+
(值为 `{ [supplierId]: (env) => SupplierModule }`)暴露供应商,dsh-router
|
|
257
|
+
`ctx.inject(['router.suppliers'])` 延迟加载(service 可用即注册)。
|
|
258
|
+
cordis 的 `provide` 每个 service name 只允许一个插件提供,多个供应商插件采用
|
|
259
|
+
**共享表模式**:先 provide 的插件持有聚合表对象,
|
|
260
|
+
其余插件 **`inject` 后把工厂追加进同一对象**并广播一次
|
|
261
|
+
`internal/service`,dsh-router 的处理器按 live 表读取且幂等,追加即可增量加载。
|
|
262
|
+
|
|
263
|
+
单个供应商加载失败(缺 `id`/`name`、构造异常)不阻断其他供应商,错误记入 DSH 日志。
|
|
264
|
+
|
|
265
|
+
**模型统一策略**:供应商的模型列表**优先从上游拉取、不自行缓存**——
|
|
266
|
+
`listModels` 每次都从上游拉取(失败回退上次成功结果避免面板空模型),**缓存由
|
|
267
|
+
核心统一管**(`Router.modelsOf`,TTL 60s)。
|
|
268
|
+
|
|
269
|
+
这意味着 **js 的 `listModels` 会被核心频繁调用**,所以:
|
|
270
|
+
|
|
271
|
+
- 只有这两种场景会真的打到上游:**首次打开供应商详情**(缓存还没建)和
|
|
272
|
+
**手动点「获取模型」**(`force=true`)。组合面板只**读缓存**,不触发拉取
|
|
273
|
+
- 因此 `listModels` 要**快**:别在里面做全量探测之类的重活。重活放到
|
|
274
|
+
`force=true` 时做(如按模型探测可用性),别拖慢每次只读
|
|
275
|
+
- 失败时回退上次成功的结果,避免面板空模型
|
|
276
|
+
|
|
277
|
+
`/suppliers/:id/models` 与 `/combos` 共用这份缓存(模型增删改后失效),
|
|
278
|
+
`/v1/models`(LLM 侧)保持实时。
|
|
279
|
+
|
|
280
|
+
**例外**:上游没有公开 models 接口的供应商,`listModels` 直接返回一份内置列表
|
|
281
|
+
(取自官方客户端/实测记录),用户仍可在面板手动添加自定义模型。
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
## 模型全名与组合存储
|
|
285
|
+
|
|
286
|
+
**对外**(用户看到的、路由收到的)是 `alias/model`;**存储**是 `supplierId,modelId`。
|
|
287
|
+
两者由核心换算,js 和面板都不需要关心存储细节。
|
|
288
|
+
|
|
289
|
+
- **别名唯一**:改别名时核心拒绝与其它供应商冲突(含默认值)。别名是对外模型
|
|
290
|
+
全名的前缀,请求时靠它反查供应商,重复就会指错人
|
|
291
|
+
- **别名留空** = 用供应商 id(默认值)。所以默认全名形如 `opencode/xxx`
|
|
292
|
+
- **组合存 `supplierId,modelId`**:路由时直接定位供应商,不再挨个问
|
|
293
|
+
「这是不是你的模型」——既快,也避免同名模型串台(两个供应商都有 `gpt`)
|
|
294
|
+
|
|
295
|
+
### 为什么组合要存供应商 id 而不是裸模型 id
|
|
296
|
+
|
|
297
|
+
存裸 id 时,核心只能遍历所有供应商逐个试。插件用 `unavailable` 回答
|
|
298
|
+
「不是我的」,而这个状态在池策略里**计一次连续错误**——组合里每有一个别人家的
|
|
299
|
+
模型就给无关账号攒一次,攒够 3 次冷却 10 分钟,表现为「组合越用越没号可用」。
|
|
300
|
+
|
|
301
|
+
存了供应商 id 就不需要问了:直接调那一个。查不到供应商(拼错/供应商没加载)
|
|
302
|
+
直接失败,**不兜底遍历**——兜底会让拼错的 id 静默落到别的供应商上,更难查。
|