- Home
- Plugins
- Models & Providers
- dsh-model-relay
dsh-model-relay
leafyezi233/dsh-model-relay
Model relay station for DeepSeek Harness (模型中转站): serve any registered DSH LLM provider (e.g. @tnnevol/dsh-codebuddy) over an OpenAI-compatible /v1 API, so other projects can call those models with a standard OpenAI client.
Install
dsh plugin --profile web add github:leafyezi233/dsh-model-relayREADME
模型中转站 (@leaf233/dsh-model-relay)
本仓库是 qilin-zhu/dsh-model-relay 的 fork,包名
@leaf233/dsh-model-relay。上游 npm 包dsh-model-relay(0.1.1)与本 fork 无关、也不兼容0.1.2-rc.1,见下文「安装」。
给 DeepSeek Harness 挂一个 OpenAI 兼容的 /v1 接口,把 harness 里已注册的大模型(比如 @tnnevol/dsh-codebuddy 里的 CodeBuddy 模型)反代出去,其他项目用标准 OpenAI 客户端就能调用。
在 设置 → 模型中转站 里可以看到所有接口地址、分组、创建和管理 API 密钥、开关鉴权、查看可用模型。
为什么不是"再登一次"
这个插件不碰任何凭据。它自己不会读写token等,而是把请求交给 DSH 的 llm 服务转发——也就是直接复用已注册的 provider 适配器。
这是因为一些事实性存在的问题:CodeBuddy 的 refresh token 会轮换,而且 @tnnevol/dsh-codebuddy 内部用单飞(single-flight) 保证并发刷新只发生一次。如果本插件自己再建一个 session,两次刷新会互相作废,结果是 Web 界面里的登录被踢掉。走 ctx.llm 就不可能出现这种情况。
由此还顺带复用了上游插件的账号故障转移、额度感知、图片序列化和模型目录。
DSH 版本支持
| DSH 版本 | 状态 | 说明 |
|---|---|---|
0.1.5-rc.2 ~ 0.1.6-alpha.2 | ✅ | 上游原本支持的范围 |
0.1.2-rc.1 | ✅ | 本 fork 新增,需要下面三处修复 |
0.1.1 及更早 | ❌ | 不在声明范围内,且缺少请求级 system 字段 |
本仓库的 package.json 声明:
"engines": { "dsh": "0.1.2-rc.1 || ^0.1.5-rc.2 || ^0.1.6-alpha.1" }
六个 @deepseek-ai/* peer 依赖同样带上 0.1.2-rc.1 || 分支。
模型分组还需要
@deepseek-ai/schemastery。 分组要出现在 设置 → 模型 里,就必须同时注册一个 settings section(见下文"模型分组"),而installSection需要一个 schema。这是唯一一个非dsh-*的 peer 依赖;它由 DSH 自带,正常情况下不需要单独安装。
为什么写成
||而不是>=0.1.2-rc.1 <0.2.0-0?后者看起来更宽,其实是陷阱。semver 规定:只有范围里某个比较符与目标版本的
major.minor.patch元组完全一致、且自身带预发布标签时,预发布版本才会被放行。 所以>=0.1.2-rc.1 <0.2.0-0里的<0.2.0-0不构成放行条件,而>=0.1.2-rc.1只对0.1.2-*生效——结果是0.1.5-rc.2和0.1.6-rc.1全被排除,用户会撞上ERESOLVE。三段式||才是正确的。
三种写法的实际匹配结果:
| 写法 | 0.1.2-rc.1 | 0.1.5-rc.2 | 0.1.6-rc.1 |
|---|---|---|---|
>=0.1.5-rc.2 <0.2.0-0(上游原写法) | ❌ | ✅ | ❌ |
>=0.1.2-rc.1 <0.2.0-0(看着更宽) | ✅ | ❌ | ❌ |
0.1.2-rc.1 || ^0.1.5-rc.2 || ^0.1.6-alpha.1 | ✅ | ✅ | ✅ |
0.1.2-rc.1 上必须的三处修复
上游 0.1.1 的代码在 0.1.2-rc.1 上装得上但跑不起来,本 fork 修的正是这三处:
- 系统提示词:
0.1.2-rc.1的@deepseek-ai/dsh-llm不再导出createSystemMessage,MessageSourceMap里也没有system。原代码的顶层命名导入会直接抛SyntaxError,插件完全加载不了。正确形状是请求级的options.system(GenerateOptions.system),适配器会把它映射到 provider 自己的 system slot。 Tag组件:dsh-client-ui-primitives在0.1.2-rc.1里不导出Tag,primitives.Tag是undefined,渲染设置页时抛错、整个区块挂不上。改用实际存在的Pill(注意它接的是active,不是tone)。connectionpatch:见上文「配置」里的说明,已删除。
compatibility.json 里的 dshPluginApi.version 也改了,但那是惰性元数据——DSH 运行时不读这个字段,真正起作用的是 package.json 的 engines.dsh 和 peer 范围。
安装
命令行安装:
# 从 npm 安装
dsh plugin --profile web add @leaf233/dsh-model-relay
# 本地开发(file: 链接)
dsh plugin --profile web add /绝对路径/relay-upstream
装完重启 Web profile,服务地址是:
http://127.0.0.1:3080/v1
这个 fork 用
@leaf233作用域名,不是上游的dsh-model-relay。npm 上那个不带 scope 的
dsh-model-relay属于上游作者(maintainerjarvistop,仓库qilin-zhu/dsh-model-relay),本 fork 无权发布它,而且它在0.1.2-rc.1上会直接崩(详见下文「DSH 版本支持」)。所以如果需要版本支持或上游不一样的功能,需要使用
@leaf233/dsh-model-relay(本 fork)。注意区分三个名字
名字 值 用途 包名 @leaf233/dsh-model-relaynpm 安装、 bundles列表patch 行的 iddsh-model-relay你在自己 profile 里覆盖配置时的锚点 provider id dsh-model-relayDSH 模型选择器里的分组命名空间( <provider>_<模型>)只有包名带 scope。
id和 provider id 保持不带 scope,所以已有的配置覆盖和分组名不受影响。
用法
任意 OpenAI 客户端都可以,base_url 指向上面的地址。
鉴权是可选的:还没创建密钥、也没在配置里写固定密钥时,接口对所有人开放(随便填一个 sk-xxx 或留空都行)。创建第一个密钥后自动开始校验。
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:3080/v1", api_key="sk-dshgw-...")
resp = client.chat.completions.create(
model="codebuddy_deepseek-v4.1-flash",
messages=[{"role": "user", "content": "你好"}],
)
print(resp.choices[0].message.content)
curl http://127.0.0.1:3080/v1/models
curl http://127.0.0.1:3080/v1/chat/completions \
-H 'content-type: application/json' \
-d '{"model":"codebuddy_deepseek-v4.1-flash","messages":[{"role":"user","content":"你好"}]}'
支持的接口:
| 方法 | 路径 | 说明 |
|---|---|---|
GET | /v1/models | 列出所有已注册 provider 的模型,以及你的模型分组 |
POST | /v1/chat/completions | 对话,支持 stream: true |
请求体与 OpenAI Chat Completions 一致:model、messages、stream、tools、tool_choice、temperature、max_tokens、stop、reasoning_effort。支持 role: "tool" 的工具结果回传、assistant 的 tool_calls 回放、以及 image_url 图片输入(data URL 或远程 URL)。
流式响应是标准 SSE,以 data: [DONE] 结束。
模型分组
分组 = 一组模型的候选列表 + 一套调度方式。 给这组模型起个名字,之后用这个名字调用,网关按调度方式决定先试谁,第一个能应答的就被采用。
默认就是"按顺序逐个尝试",也就是"额度轮换"最直接的用法:把同一个模型在不同供应商上的实例排成一组,一个用不了了自动落到下一个。
# 直接调用分组名
resp = client.chat.completions.create(
model="fast-chat",
messages=[{"role": "user", "content": "你好"}],
)
在 设置 → 模型中转站 → 模型分组 里创建和管理,用 ↑ ↓ 调候选顺序。
调度方式
设置页提供四个预设:
| 预设 | 行为 |
|---|---|
| 顺序(默认) | 按候选列表从上到下依次尝试,遇到不可用就顺延。 |
| 均衡 | 每次请求轮换起点,把负载摊到所有候选上。 |
| 随机 | 每次请求打乱顺序。 |
| 顺序 + 限流重试 | 按顺序尝试;某条腿遇到限流(429)时在同一条腿上重试若干次,再顺延到下一个。 |
存储上其实是两个正交的字段:strategy(sequential / round-robin / random)决定顺序,retry429(0–3)决定同一条腿重试几次。上面四个预设是这两者里最常用的四个组合,不是全部——存储本身能表达 12 种,比如"均衡 + 重试"(round-robin + retry429: 1)可以直接写进 model-relay-groups.json,只是设置页暂时没有对应的按钮。分成两个字段而不是一个枚举,就是为了让这些组合不必为每种顺序各复制一份重试逻辑。
均衡和随机都是完整排列,不是子集。 被排到后面的候选仍然是回退链的一部分,所以任何调度方式都不会缩短分组的兜底能力。
⚠️ 均衡和随机会牺牲会话粘性。 顺序模式下同一个会话始终落在同一条腿上,上游的 prompt cache 因此命中;换成均衡或随机后,多轮对话会在供应商之间跳,prompt cache 基本失效,成本和首字延迟都会上升。另外不同供应商的 tokenizer 不同,同一段文本的 token 计数会漂移,
usage对账会对不上。这是选择轮换必然要付的代价,不是 bug。
在 DSH 本体里也能用
本插件会把自己注册成一个名为 dsh-model-relay 的 DSH provider,它的"模型目录"就是你的分组列表。打开 设置 → 模型,在 dsh-model-relay 下面就能像选普通模型一样选到分组名。
分组名同样出现在 /v1/models 里,所以外部客户端和 DSH 两边都能用。
DSH 的模型选择器是全局的——注册之后,所有会话都能选到这些分组。
底层模型仍然可以直接调用(
codebuddy_deepseek-v4-flash这种全名照常可用)。想让外部只看到分组,把providers配成[dsh-model-relay]即可——但那是对外的过滤,不影响 DSH 界面里能看到什么。
回退只在"还没有任何输出"之前发生
唯一需要理解的行为约束:
- 上游还没吐出任何内容就失败(连不上、凭证失效、上游报错)→ 自动换下一个候选,客户端完全察觉不到。
- 上游已经吐出内容之后再断 → 不换,流被截断。
第二条不是偷懒:已经吐出去的内容客户端已经收到了,此时换腿会造成重复输出。所以分组能兜住"这个供应商现在用不了",但兜不住"响应写到一半断了"。
"吐出内容"的判定见上面的排查小节——只有 usage 分片不算。
限流重试(retry429)
开启后,某条腿遇到限流会在同一条腿上重试,而不是立刻换下一条腿。三条约束:
- 只重试限流。 判据是失败码
RATE_LIMIT,不是 HTTP 状态。因为statusForCode把RATE_LIMIT、QUOTA、QUOTA_EXCEEDED全都映射成 429,但额度用尽重试也没用——再问一次还是同一个答案,只是白花调用方的时间。 - 已经吐出内容就不再重试。 和换腿是同一条规矩:重放调用方已经收到过的内容会造成重复输出。
- 每条腿有等待预算(2 秒)。 上游给的
Retry-After优先于本地退避(本地是 250ms 起、翻倍、封顶 1 秒),但如果那个提示超过预算,就直接换下一条腿而不是干等——否则一条死腿会让本来能立刻应答的下一条腿陪着一起等。
⚠️ 两条路径的重试会相乘。
/v1绕过了代理循环,没有外层重试;而 DSH 本体调用还有dsh-llm-retry(对RATE_LIMIT最多重试 5 次)。所以在 DSH 本体路径上,retry429 = 2的最坏情况是 3 × 6 = 18 次上游调用。建议本体路径上把retry429控制在 1。
分组内每条腿的成功率
设置页的模型分组卡片里,每个候选后面会跟一个百分比,说明这条腿最近答上来的比例。
hinds [均衡] [刷新统计] [新建分组]
[codebuddy_gpt-5 98%] [codebuddy_claude 41%] [xxx_gemini 0%]
颜色分三档:≥90% 绿、50–89% 黄、<50% 红;从没被调用过的显示灰色、不带百分比。 鼠标悬停能看到完整数字:尝试次数、已应答 / 未应答、限流重试次数、累计应答率、最近一次失败的失败码和时间。
两个刻意的口径,值得先知道
一、统计的是「有没有应答」,不是「有没有成功」。
上游已经吐出内容之后才断掉的调用,会被算进「已应答」。这不是漏了,而是两条调用路径的可观测性本来就不同:
| 路径 | 能否看见「应答后中途死」 |
|---|---|
DSH 本体(streamGroup) | 能 |
/v1(openStream) | 不能——提交后的那次尝试已经交给 resume(...),失败发生在里面 |
要让两条路径口径一致、又不去包装流式热路径,就只能统一问「答没答上来」。所以计数器叫 answered / refused,不叫「成功 / 失败」。
二、不是这条腿的错的失败,不算它的。
下面这些失败换任何一条腿都是同样的结果,记到当时排在第一位的那条腿上只会冤枉一个健康的模型:
| 失败码 | 为什么不记 |
|---|---|
CONTEXT_WINDOW_EXCEEDED | 请求本身太大 |
INVALID_REQUEST / INVALID_PREPARED_CALL | 请求本身有问题 |
UNSUPPORTED_CONTENT / UNSUPPORTED_OPTION / UNSUPPORTED_REASONING_EFFORT | 请求不被支持 |
ABORTED | 调用方自己取消的 |
这些计入 ignored,不进分母,所以不会拉低成功率。悬停提示里会单独列出它们。
其他要知道的
- 只统计分组。 直接调用某个底层模型(不走分组)不产生任何统计。
- 按「最近 20 次」算,不是按历史累计。 悬停里的「累计应答率」才是历史值。这是有意的:一条腿早上坏了、现在好了,累计比率永远追不回来,而近期比率能立刻反映恢复。
- 两条线:实时徽章在内存,历史图表落盘。 徽章/悬停的计分板仍是纯内存(同一条纪律:统计不能影响路由);从 0.5.0 起,按天聚合的历史会异步落盘到
~/.dsh/model-relay-stats.json(可用statsPersist: false关掉),重启后图表不清零。明细请求列表不落盘——落盘的只有聚合计数。 - 不会自动刷新。 点刷新统计按钮才更新;这个页面没有后台轮询。
- 徽章带样本量。 百分比旁边就是尝试次数(如
98% · 124);样本不足 20 次时悬停会明确提示。近期比率远好于累计比率时,徽章转为「恢复中」琥珀色,累计值在悬停里。 - 组合分组的成员可以点开。 点成员徽章展开内层分组自己的计分板——组合分组自身的数字对同一次调用在两层各计一次,展开后看到的是内层的独立口径。
候选全挂了之后:失败码必须活着走出边界
所有候选都失败时,网关抛出一个 GatewayError。这个异常要穿过 dsh-llm 的适配器边界,而边界上的 normalizeLlmFailure 只认 HarnessError 的 code;其它 Error 一律被压成 UNKNOWN。
这件事要命的地方在于:dsh-base 里挂着 dsh-llm-retry,它的重试判据是
policy.retryableCodes.includes(failure.code)
默认集合是 EMPTY_RESPONSE / RATE_LIMIT / SERVER / TIMEOUT / TRANSPORT。所以一个 code 被压成 UNKNOWN 的失败不会被重试——整个分组会把一次瞬时 429 变成一次死掉的回合,而且不报错,只是"没反应"。
修法是给异常挂一个自己的 failure 数据属性({message, code, status})。这是外部适配器跨越该边界的正规做法,也是 LlmError 内部做的事;快照里的 code 必须和异常自身的 code 一致,否则边界会丢弃它。
test/failure-code.test.mjs 钉住这条:它把真插件挂到真 LlmService 上,读代理循环真正会看到的那个终止分片。用 mock 的 llm 永远看不见这个 bug——假的 llm 不跑真边界。
各种失败分别会怎样
下表的 /v1 列是实测值(真插件挂真 LlmService,hinds 只有一个成员且它失败):
| 上游失败码 | /v1 状态 | Retry-After | DSH 会重试吗 |
|---|---|---|---|
RATE_LIMIT | 429 | 有则输出 | 会 |
SERVER | 502 | 有则输出 | 会 |
TIMEOUT | 502 | — | 会 |
TRANSPORT | 502 | — | 会 |
EMPTY_RESPONSE | 502 | — | 会 |
QUOTA | 429 | 有则输出 | 不会 |
AUTH | 401 | — | 不会 |
INVALID_CREDENTIAL / MISSING_CREDENTIAL | 401 | — | 不会 |
UNSUPPORTED_REASONING_EFFORT | 400 | — | 不会 |
CONTEXT_WINDOW_EXCEEDED | 400 | — | 不会 |
INVALID_REQUEST | 400 | — | 不会 |
ABORTED | 499 | — | 不会 |
| 其它 / 未知 | 502 | — | 不会 |
三列读法:
/v1状态:走statusForFailure——优先用上游自己报的 HTTP 状态(dsh-llm-deepseek会带上status: response.status),没有才回退到按码分类。因为码比状态粗:AUTH同时覆盖 401 和 403,而像 402 这种状态根本没有对应码。回退分类见statusForCode。Retry-After:只在响应头还没发出去时能给(非流式、或流式但第一个分片之前就失败)。SSE 一旦写出第一个分片就已经是 200 了,Retry-After在协议上不可能再加;而且客户端已经收到半个流,重试这个响应也没意义。毫秒向上取整到秒,最小 1 秒——向下取整会变成"立刻重试",正好是这个头要防的事。- DSH 会重试吗:判据是
dsh-llm-retry的retryableCodes默认集合(EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT)。QUOTA不在里面:额度用尽重试也没用,直接报错更快。注意/v1路径没有外层重试——它绕过了代理循环,dsh-llm-retry只作用于 DSH 本体调用。分组自己的retry429是另一回事,那条在两条路径上都生效(见上文"限流重试")。
ABORTED 是特例:它被报成 kind: 'aborted' 而不是 error,所以网关不会换腿,dsh-llm-retry 也不会重试。这是对的——取消是调用方放弃,不是供应商失败,换腿等于复活一个刚被取消的请求。
长会话自动压缩
分组对外声明的上下文窗口是所有候选里最小的那个。DSH 的自动压缩按这个数算阈值,取最小值才能保证"快满了"在任何一条腿上都成立;取大的话,会话可能撑爆实际应答的那条腿。
任何一条候选的窗口读不出来 → 整个窗口字段不输出。这不是保守:DSH 读不到窗口时只是警告一次然后继续跑(长会话最终可能撞上 CONTEXT_WINDOW_EXCEEDED),而报一个错的数会让每次压缩都算错,静默得多。
组合分组的窗口几乎必然是空的。 因为"任一成员读不出来就整个不输出"这条规则会逐层累加:组合分组要问内层,内层要问它自己的每个成员,两层里只要有一个未知,最外层就是 undefined。这是诚实的结果(分组确实无法承诺一个确定的容量),代价是 DSH 对组合分组的自动压缩会静默关闭。推理档位同理:交集会因为同样原因退化成空,档位选择器不出现。
两个事实(窗口、档位交集)在同一次成员遍历里取到,结果按分组缓存 30 秒;分组在设置页被增删改时缓存立即清空。
分组名不能用下划线
只能用字母、数字、- 和 .,且首字符必须是字母或数字。
因为每个模型的对外名都是 <供应商>_<模型>,下划线就是那个分隔符。禁止分组名使用下划线,两个命名空间就天然不冲突,不需要任何优先级规则。
分组名优先于模型名解析——但你不用记这条,符合规则的命名本来就撞不上。
两种分组:普通分组与组合分组
每个分组有一个类型(kind),类型决定它的成员能放什么。
| 类型 | 成员 | 说明 |
|---|---|---|
| 普通分组(默认) | 只能是具体模型 | 现有分组全都是这一种,行为一字未变 |
| 组合分组 | 只能是一个已存在的普通分组,写成 dsh-model-relay_<分组名> | 用来复用别的分组 |
组合分组不能包含组合分组。 这不是一条单独的限制,而是类型规则的推论:组合分组的成员类型是"普通分组",而组合分组自己不是普通分组。所以"深度最多一层"是自动成立的,不需要额外记。
成员必须写成 dsh-model-relay_<分组名>,不能写裸名。 这不是美观问题:裸名会被解析成"那个分组的第一条腿",内层剩下的候选和内层的调度方式会全部丢掉。带前缀的写法才会把内层当成一个整体交给 DSH 派发,从而让内层按它自己的顺序/轮询/重试设置运行。设置页的候选下拉框只会给出正确的那种写法。
类型创建后不可改。 想把一个普通分组变成组合分组,请新建一个。
三种递归路径都有守卫
分组图可以递归,而且有三条互不相干的路径,各自有自己的深度上限和错误码:
| 路径 | 什么时候走 | 错误码 |
|---|---|---|
| 解析期 | 分组成员指向分组时,在派发之前 | group_cycle |
| 派发期 | 组合分组的成员真正被交给 DSH 之后 | group_cycle |
| 能力期 | 构建模型目录时(打开"模型"设置页就会走) | group_capability_cycle |
能力期那条尤其要注意:它不需要任何人调用这个分组,光是打开设置页就会触发。
正常情况下你碰不到这些错误码,因为设置页在保存时就会拒绝会形成环的成员。它们挡的是手改 model-relay-groups.json 的情况。
被引用的分组被删/改名之后
允许删除或改名一个被组合分组引用的普通分组(否则内层分组就变成不可删了)。这时引用它的组合分组会失效:
- 设置页会在那个分组上显示"有 N 个成员已失效"。
- 运行时会跳过这些成员(和"这条腿解析不了"一样处理),并在日志里写出是哪几个组合分组受了影响。
修正方式是编辑那个组合分组,把失效的成员删掉或换成有效的。
推理强度(reasoning effort)
分组对外声明的档位是所有候选的交集,并且不设默认档。
交集,不是并集。并集会带来一个很隐蔽的故障:某一档只有部分候选支持,DSH 校验通过、放行,然后请求被转发给不支持它的那条腿,在请求中途以 UNSUPPORTED_REASONING_EFFORT 失败。改成交集后,DSH 自己就挡住了——调用方根本选不出没有任何一条腿能兑现的档位。
不设默认档是必须的,不是保守。DSH 里的 defaultEffort 不是"提示",而是物化:只要声明了它,每一个没有指定档位的请求都会被补上这个值(dsh-llm 的 resolveCallWithInfo)。这个值随后被原样转发给最终应答的那条腿——于是调用方什么都没选,却收到了"不支持该档位"的报错。分组横跨多个供应商,没有哪一档能代表所有成员,所以正确的做法是不替调用方做决定,让每条腿用它自己的默认值。
具体表现:
- 所有候选都声明了推理档位 → 取交集,聊天里出现档位选择器。
- 任一候选没有声明推理档位(该模型根本不接受这个参数)、或能力读不出来 → 交集为空 → 整个
reasoning字段不输出,选择器不出现。这是诚实的答案:分组无法承诺任何选择,就不该假装能。
空交集时不能输出空的 efforts 数组——DSH 会以 INVALID_MODEL_REASONING 拒绝,而 buildModelCatalog 把"某个模型抛错"当成"整个 provider 失败",结果是整个 dsh-model-relay 从聊天选择器里消失。
/v1 那条路径是另一回事:调用分组时,显式传的 reasoning_effort 如果目标腿不支持,会被丢弃并写警告,而不是报 400。调用方指定的是分组,具体哪条腿应答由路由器决定,调用方无从校验,所以不该让它因此失败;档位是偏好,让上游用它自己的默认值继续跑,比整条请求失败要好,警告保证这件事不会被悄悄咽掉。
直接指定具体模型时不做这个处理——模型和档位都是调用方自己选的,这时明确报错比悄悄丢掉它的指令更诚实。
排查
- 响应头
x-relay-group(分组名)和x-relay-model(实际命中的那一条腿)直接告诉你走了谁。 - 每次分组回退都会写日志,包括"换了下一个"和"这条腿解析不了"。
- 所有候选都失败时,返回的是最后一次失败的原始错误,不是笼统的 503——这样上游的真实原因不会丢。
- 如果某条腿不支持调用方指定的
reasoning_effort,日志里会有does not accept reasoning effort ...; dropping it,请求本身继续。
什么算"可以换下一条腿":只有当这条腿还没有吐出任何内容时才算失败。一旦已经转发过 block-start / text-delta / reasoning-delta / tool-call-delta / block-end,响应就已经提交,再换腿会把调用方已经看到的内容重放一遍,所以此时失败就是最终结果。只有 usage 分片不算提交——它不携带任何模型输出。aborted(调用方自己取消)永远不触发换腿,否则会把刚被取消的工作重新拉起来。
一个排查提示:设置页看得到,聊天里选不到
这两处是两条不同的路径:
| 位置 | 数据来源 |
|---|---|
| 设置 → 模型 | llm.listProviders() × llm.listConfigurableProviders(),按 settingsNs 拼装。不碰适配器。 |
| 聊天模型选择器 | session.modelCatalog() → llm.listModels() + llm.resolveModelInfo()。会调适配器。 |
所以如果卡片在、但聊天里选不到,几乎可以断定是适配器抛错了(返回给 DSH 的结构不合法)。这时去看 DSH 启动日志里 dsh-model-relay 加载失败:... 那条,它会直接给出原因。
模型名怎么写
推荐用 供应商_模型,例如 codebuddy_deepseek-v4-flash。
不同供应商的模型 id 会重名。实测这套组合里 deepseek-v4-flash 和 deepseek-v4-pro 同时存在于 deepseek-official 和 codebuddy。所以每个模型都按 <供应商>_<模型id> 命名,/v1/models 返回的 id 就是这个形式。
用下划线而不是斜杠,是为了让模型名对客户端保持单个不透明 token:有些 OpenAI 兼容工具会把 / 当作路径分隔符,而且斜杠会和下面这种旧写法混淆。
三种写法都接受,优先级从高到低:
| 写法 | 例子 | 说明 |
|---|---|---|
供应商_模型 | codebuddy_deepseek-v4-flash | 推荐,唯一无歧义 |
供应商/模型 | codebuddy/deepseek-v4-flash | 旧写法,仍兼容 |
| 裸模型名 | glm-5.2 | 先查 defaultProvider,再找目录里的唯一匹配,都没有时若只注册了一个供应商就用它 |
裸名在多个供应商上都存在时返回 400,并在错误信息里给出全部可选的全名,例如:
model "deepseek-v4-flash" is served by several providers;
use a namespaced id such as codebuddy_deepseek-v4-flash, deepseek-official_deepseek-v4-flash
响应里的 model 字段回显你请求时用的全名,方便对照日志。
模型目录是建议性的:适配器接受未列出的 id。目录会缓存 30 秒。
设置页
装好后打开 设置 → 模型中转站,页面上有:
- 模型分组(页面最上):创建分组、增删候选模型、调整顺序、选择调度方式,以及每条候选的应答率和刷新统计按钮
- 接口地址:Base URL、两个端点、一段可直接复制的 Python 示例
- API 密钥:创建(带备注名)、删除、以及开启/关闭鉴权
- 可用模型:当前暴露的模型清单
分组是怎么存的
- 存在
~/.dsh/model-relay-groups.json,和密钥分开两个文件。 - 密钥文件那套"权限被放宽就当作不存在"的规则不适用于分组:那个规则是为凭据设计的,分组只是路由偏好,把它当成敌意文件会让功能无谓地失效。
- 同样是原子替换 + 串行化队列。
- 文件损坏或某条记录不可用时,该条被丢弃而不是修复——编一个名字或空候选列表只会造出一个永远无法服务的分组,那比它不存在更糟。
- 每条记录带
strategy和retry429。取不到或非法的调度值会退回到默认值(顺序、不重试),而不是丢弃整条记录——候选列表才是值钱的部分,一个偏好不值得拿整个分组去换。旧版本写的文件(没有这两个字段)照常加载。 version字段从 1 提到 2,但它只是描述性的:读取时从不校验,所以旧插件读新文件只是忽略多出来的字段。
统计是怎么存的
分两层。
实时层(内存):每条候选的调用统计(尝试、已应答、未应答、限流重试、最近 20 次结局、token/TTFT 累计)只活在内存里。这是徽章和悬停的数据源,纪律不变:统计不能影响路由,也不能让一个请求失败或变慢。
历史层(落盘,0.5.0 起):按天聚合的计数异步写到独立文件 ~/.dsh/model-relay-stats.json:
- 记什么:分组名、候选模型名、每日/每小时的请求量与调用次数、已应答/未应答、token 数、TTFT 累计。
- 不记什么:prompt 正文、响应正文、密钥——一个都没有。
- 保留 90 天(
statsRetentionDays可调,1–365),到期自动修剪。 - 小时桶只存全局:24 小时窗口是全局口径,分组没有小时级历史——按分组存小时桶会把文件放大几十倍,图表上会明说这一点,而不是显示一个空的分组 24h 图。
- 写入是去抖的(约 2 秒):进程被强杀时最多丢最后约 2 秒的计数;退出/重载时会先落一次盘。
- 权限 0600;文件损坏时降级为空重新开始,绝不阻断启动。
为什么是独立文件而不是并进分组文件:分组文件是整体原子替换且走串行化写队列,把每个请求都变成一次全量磁盘写、还和设置页的编辑抢队列。统计用自己的文件、自己的去抖定时器、自己的队列。
为什么 0.4.x「不存」而现在存:0.4.x 的理由是「现在哪条腿是死的」不需要历史;有了热力图和趋势图,历史本身成了功能。写盘的一切失败都被吞掉并告警,两条铁律(不影响路由、不拖慢请求)原样成立。
密钥是怎么处理的
- 服务端只保存 SHA-256 哈希,明文只在创建弹窗里显示一次,关掉就再也拿不回来——丢了就删掉重建。
- 列表里显示的是
sk-dshgw-abcd…wxyz这种可辨认的掩码,不能用来调用。 - 密钥文件默认在
~/.dsh/model-relay-keys.json,权限0600;权限被放宽的文件会被当作不存在,不会被信任。 - 写入是原子替换 + 串行化队列,连续创建多个密钥不会互相覆盖。
一个刻意的安全设置
删掉最后一个密钥,接口仍然保持锁定,必须显式点"关闭鉴权"才会重新开放。
这不是偷懒:如果删除密钥会自动放开接口,那么"清理一个不再使用的密钥"这个日常动作就会静默地把接口暴露给所有人。权限只应由明确的操作放开,而不是由清理动作的副作用放开。页面上有对应的提示文案。
反向代理(飞牛统一网关等)
调用模型请用端口地址,不要走反向代理。
页面上显示的 Base URL 是 http://<host>:<port>/v1,其中 port 是 DSH 实际监听的端口(从 ctx.webServer.port 读取,不是页面地址)。这样做的原因:
- 反向代理会重写路径。飞牛网关把应用挂在
/app/fn-deepseek-harness下,转发前剥掉这个前缀,并按自己的白名单给页面资源加前缀。SSE 流式响应和这个网关的路径改写叠加起来很容易出问题。 - 直接打端口就绕开了所有这些不确定性。
页面上会提示当前是"只监听本机"还是"监听所有网卡",据此判断其他机器能否调用。
局域网访问
想让同一网络的其他设备(手机、笔记本、另一台服务器)也能调用,开一个独立监听端口:
- id: dsh-model-relay
inject:
- llm
- webServer
config:
lanPort: 3081
然后其他设备用:
http://<这台机器的局域网IP>:3081/v1
设置页的"局域网访问"卡片会直接列出可用的 IP 地址,每个都能一键复制。
只列出真正的局域网地址。 装了 Docker/libvirt 的机器通常会有十几个 172.x/br-* 虚拟网桥地址,局域网设备根本连不上。这些会被过滤掉(docker、br-、veth、virbr、vmnet、wg、tailscale 等前缀),默认路由所在网卡的地址排在最前。被隐藏的数量会在卡片下方注明,方便排查——万一你的可用地址恰好落在某个网桥上,能看出是过滤导致的,而不是"没有地址"。
必须配密钥
开启局域网监听前请先创建一个 API 密钥。 没有密钥时,这个端口对同网络的任何设备开放。启动时如果发现没配密钥,日志会打一条警告,设置页上也会显示红色提示。
密钥校验在两条监听上是同一套:本机调用和局域网调用都认同一份密钥。
配置项
| 字段 | 默认 | 说明 |
|---|---|---|
lanPort | false | false 关闭;端口号开启独立监听;0 让系统分配一个空闲端口 |
lanHost | 0.0.0.0 | 监听地址。只想给某一个网卡用时填该网卡的 IP |
lanPort 是整数(0–65535)或 false,其他值会在加载时直接报错,而不是等到运行时才发现。
防火墙
如果局域网连不上,先确认主机防火墙放行了这个端口。以飞牛/群晖这类 NAS 为例,通常需要在系统防火墙里额外放行 TCP 3081。
设置页自身是怎么在网关下工作的
设置页需要在网关下也能用,所以它的请求走了一个专门的约定:
- 端点挂在
/api/model-relay,而不是插件私有的通道名。/api是每个部署(包括飞牛网关的白名单)都一定会转发的唯一前缀;插件私有通道名不在白名单里,请求根本到不了 DSH——这正是早先设置页报transport failure ... HTTP 404的原因。 - 客户端用相对路径
api/model-relay并对着document.baseURI解析。网关会把<base>设成带前缀的地址,相对路径因此自动带上前缀;写成根绝对路径/api/...会以 origin 为基准解析,前缀就丢了。 - 该端点复用 Connection 的信任校验与浏览器鉴权,所以只有已登录的 DSH 页面能管理密钥,
/v1自身不提供任何密钥管理入口。
配置(可选)
在 profiles/web/cordis.patch.yml 里按 id 覆盖:
- id: dsh-model-relay
inject:
- llm
- webServer
config:
# 挂载点,默认 /v1
path: /v1
# 固定密钥:配了就校验,没配就放行。支持 Authorization: Bearer 和 x-api-key。
# 与设置页创建的密钥同时生效;配置里存在固定密钥时,设置页无法关闭鉴权。
apiKeys:
- sk-your-key
# 只暴露这些 provider,默认全部
# 填 [dsh-model-relay] 可以让外部只看到分组、看不到底层模型。
# 这是「对外」的过滤,不影响 DSH 自己能看到什么。
providers:
- codebuddy
# 裸模型名的优先 provider
defaultProvider: codebuddy
# 跨域响应头,默认 true
cors: true
# 密钥存储路径,默认 <DSH_HOME>/model-relay-keys.json
keysFile: /path/to/keys.json
# 分组存储路径,默认 <DSH_HOME>/model-relay-groups.json
groupsFile: /path/to/groups.json
# 局域网独立监听端口(详见上文"局域网访问");false 或不写即关闭
lanPort: 3081
# 监听地址,默认 0.0.0.0
lanHost: 0.0.0.0
# 统计持久化,默认 true;false 恢复 0.4.x 的纯内存行为
statsPersist: true
# 统计存储路径,默认 <DSH_HOME>/model-relay-stats.json
# statsFile: /path/to/stats.json
# 天级统计保留天数,默认 90(可设 1–365)
# statsRetentionDays: 90
本插件不 patch
connection那一行。早期版本曾用 bundle patch 复写
connection的inject,想把它补成webRuntime+webServer。这个做法是错的:loader patch 是整值替换(dsh-app-boot的applyEntryPatches做的是target[key] = value),不合并,所以它不是"补充"而是"覆盖掉别人写的所有内容"。在真实组合里那一行还带着其他层的要求:
dsh-webgate会加上lanAccessHosts和从ctx.lanAccessHosts读的trustedHosts;connection自己声明inject: ["webServer", "credentials"]并直接读ctx.credentials构造 BrowserAuth。覆盖它会同时丢掉lanAccessHosts和credentials,导致 Connection 起不来——整个/api通道连同 Web 界面一起挂掉,不只是本插件的设置页。结论:
connection由 bundle 栈组合,插件只消费它的服务,不去定义它的依赖。需要 connection RPC 的插件各自声明inject即可,不需要(也不应该)由本插件代为维护那个并集。
边界
- 默认只监听本机。DSH Web 默认绑
127.0.0.1;局域网访问要用lanPort开独立端口,并且一定要先配密钥。 - 密钥管理只走设置页。管理端点挂在 DSH 已鉴权的 connection carrier 上(
/api/model-relay),/v1自身不提供任何密钥管理接口。 - 没有
/v1/embeddings、/v1/images等接口。这个网关只做对话和模型列表;DSH 的llm服务没有 embedding 能力,所以这里不会假装有。 - 图片输入依赖附件服务。
ctx.attachments没挂载时,带图请求会被明确拒绝(400),而不是静默丢图。 - 分组回退只在首字节前有效(见上文"模型分组")。
- 组合分组只能包含普通分组(深度最多一层),且成员必须写成
dsh-model-relay_<分组名>。 - DSH 本体的会话不会经过
/v1。它走的是 DSH 自己的 provider 通道;本插件注册dsh-model-relayprovider 是为了让分组出现在模型选择器里,不是为了把 DSH 的请求绕回自己的 HTTP 端口。所以给/v1开鉴权不会影响 DSH 本体。 - 请求体上限 32 MiB,超出返回 413。
开发
npm run check # 语法检查(含新增的 groups.js / adapter.js)
npm test # 全部测试
单独跑:
npm test # 跑全部 14 个套件,任一失败不影响其余
node test/gateway.test.mjs # 协议翻译 + 路由 + 分组回退 + 设置端点 + 状态码/Retry-After
node test/keys.test.mjs # 密钥存储:哈希、权限、并发、锁定语义
node test/groups.test.mjs # 分组存储:校验、并发、损坏降级、kind、失效引用
node test/nesting.test.mjs # 组合分组 + 三条递归路径的守卫 —— 见下
node test/adapter.test.mjs # 适配器单元测试(用 mock llm)
node test/adapter-real.test.mjs # 适配器对真实 dsh-llm 服务 —— 见下
node test/registration.test.mjs # provider 注册的三件套 + 档位交集是否正确接上
node test/failure-code.test.mjs # 失败码/重试提示能不能活着走到代理循环 —— 见下
node test/verify-table.mjs # 把上面那张失败表重新实测一遍,防止文档和行为漂移
nesting.test.mjs 值得单独说一句:它注册真实的 relay 适配器,并让 resolveModelInfo
对 relay 路由的回答也由那个适配器给出 —— 这正是 DSH 的做法,也正是"能力期"那条递归路径
能成立的原因。用一个"relay provider 返回空列表"的假 llm 是测不出环的:它根本不会重现
那次往返。这是本仓库 mock-blindspot 规则的一个具体案例。
npm test 走 test/run.mjs,每个套件独立进程、各自输出重定向到临时文件(Windows 上管道是命名管道,某些沙箱不允许创建)。故意不用 && 串联:那样一个失败会静默吃掉后面所有套件,而 Windows 上恰好有一个必失败用例,于是 6 个套件里只有 1 个真的跑了。
adapter-real.test.mjs 不能用 mock 替代。 它驱动真实的 LlmService 实例,让 DSH 自己的校验器当裁判。
原因是踩过一次:reasoning.efforts 写成了字符串数组 ['low','medium','high'],而 DSH 期望 {id, name} 对象数组。用 mock 测完全绿——mock 把坏值原样吐回来。真实服务立刻抛 INVALID_MODEL_REASONING,整个 provider 从聊天选择器里消失(但设置页仍显示卡片,因为那条路径不碰适配器)。
同一个盲区后来咬过两次,形状各不相同。 第一次:mock 让适配器抛异常来表示失败,但真实 dsh-llm 不会让适配器异常逃出去——它在自己的边界捕获,转成一个 {type:'finish', reason:{kind:'error'}} 分片(adapterStream / adapterFailureChunk)。回退循环只看 next.done,于是把这个错误分片当成正常结果直接透传,started 也被置为 true——真实运行时的分组回退从来没生效过,而所有 mock 测试全绿。现在 test/gateway.test.mjs 两种形状都测。
第二次:失败码。所有候选都挂掉后网关抛出的 GatewayError 要穿过 DSH 边界,而边界只认 HarnessError 的 code,其余压成 UNKNOWN。dsh-llm-retry 按 failure.code 决定重不重试,于是分组整体失败时一次都不会被重试——但没有任何测试看得见,因为假的 llm 不跑真边界,code 是什么它都原样返回。见 test/failure-code.test.mjs。
规则:任何交给 DSH 消费的结构(listModels / resolveModel 的返回值),都必须用真实服务实例验证。mock 只能证明"我传了我以为对的东西",证明不了"对方接受它"。同理,mock 的失败方式也必须和真实运行时一致——失败是抛出来的还是当值返回的,会决定一整条控制流走不走得到;而失败携带的 code 会被边界重写,所以"错误信息对了"不等于"错误码还在"。
测试用假的 llm 服务和真实的 node:http 服务器驱动路由,验证 OpenAI 协议翻译的往返形状;存储测试跑在真实临时目录上。都不需要登录账号,也不发外部请求。
三个已知的 Windows 上会失败的用例(与本次改动无关,改动前就是这个结果):gateway.test.mjs 的局域网地址过滤(依赖 Linux 的 /proc/net/route)、keys.test.mjs 的两个 POSIX 文件权限用例(Windows 没有 POSIX mode,keys.js 在 win32 上直接跳过检查)。
改了代码后确认一下安装副本。 pnpm 对
file:依赖用的是硬链接(不是拷贝,也不是软链):源文件和node_modules里的副本是同一个 inode。这带来一个反直觉的坑:
- 原地写入(
echo >> file、sed -i之外的直接写)会同步到两边——因为本来就是同一份内容。- 先写临时文件再 rename(
write/edit这类工具、多数编辑器保存时都是这么做的)会悄悄断开硬链接。源文件更新了,副本还是旧内容,而且没有任何报错。所以改完源码后如果行为没变,别怀疑代码——先对一下内容:
diff -r lib/ /绝对路径/profiles/web/node_modules/@leaf233/dsh-model-relay/lib/不一致就重新执行
dsh plugin --profile web add file:/绝对路径/relay-upstream。注意 pnpm 可能报 "Already up to date" 而不重新链接,这种情况先remove再add。想让改动彻底自动生效,可以把依赖换成
link:。
License
Apache-2.0
Related plugins
dsh-routing-suite
yjh051108/dsh-routing-suite
dsh-commandcode-provider
mars-sea/dsh-commandcode-provider
dsh-workbuddy-connect
corrinehu/dsh-workbuddy-connect
dsh-our-free-model
zouyuxuan122/dsh-our-free-model