DeepSeek Harness Models 页面声明 Provider:从浏览器一步接入 OpenAI 兼容网关的架构实现
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
导读
在 DeepSeek Harness 中,接入一个 OpenAI 兼容网关、自托管模型服务或比内置目录更新的模型,过去意味着打开$DSH_HOME/settings.yaml手写 provider profile——不熟悉 profile 结构就无法完成,而模型上下文窗口过时也只能靠升级 pi-ai 包来解决。本文基于项目已实施的架构笔记(.agents/notes/implemented/architecture/2026-08-04-declaring-a-provider-from-the-models-page.md),拆解"从 Models 页面声明 Provider"这一功能的两大组件——共享模型列表编辑器ModelListEditor与独立的CustomProviderCard创建卡片,并落到packages/client/ui-settings-models、packages/llm/llm-pi-ai的源码与测试,说明端点探测、schema 驱动的协议选择、凭据分离与 Provider ID 不可变等设计决策。读完你将掌握该功能的完整使用路径、底层调用链与设计取舍。
背景:能力早已存在,只是界面没有暴露
在本次改动之前,pi-ai 路由已经被设计为"声明式 provider"而非"目录查找":dsh-llm-pi-ai的 catalog.ts 将内置目录与 profile 自身条目合并,resolveProfiles不再用getBuiltinProviders()校验路由键,且宿主层获得了对"草稿端点"进行 interrogate(探测)的能力(见 2026-08-03-pi-ai-declared-provider-catalog.md 与 2026-08-04-draft-provider-endpoint-interrogation.md)。
但这两层都"没有触达不编辑 YAML 的人":Models 页面仍然只给每个 provider 一个 API key 输入框和一个带 base URL 的折叠区。于是:
- 添加一个网关 = 打开
$DSH_HOME/settings.yaml,且必须懂 profile 形状; - 修正一个过时的上下文窗口 = 同样要改 YAML;
- 能力存在,界面却不暴露。
这篇笔记指出,缺失的两件事"形状并不相同":
| 场景 | 本质 | 需要的界面 |
|---|---|---|
| 编辑既有路由的模型 | 卡片上的一个字段(已存在的卡片) | 行内编辑列表 |
| 声明一条新路由 | 一次创建(route id 尚未确定) | 独立的创建卡片 |
因为 route id 在创建时才被选定,在选定之前"settings 地址"根本不存在——这正是创建流程必须独立成卡的原因。
核心设计一:ModelListEditor——两条流程共享的模型列表编辑器
ModelListEditor(源码)同时服务于"编辑既有路由"与"创建新路由"两条流程。它编辑一个 provider profile 的models数组:
- 一行一个模型,字段为
id(模型 id)、name(显示名)、contextWindow(上下文窗口)、maxTokens(输出上限,与项目语义一致); - 空列表的含义是"提供该路由的内置目录"——行只能被有意添加,绝不会被偷偷写回目录;
- 清空一个可选字段 = 从 profile 中删除该字段,而不是存一个会被 schema 拒绝的空值;
- 非正整数的容量值根本不会落盘。
容量字段的 K/M 编辑词汇
容量以文本编辑,背后是共享的formatCapacity/parseCapacity工具(与 DeepSeek 目录编辑器共用同一套 K/M 词汇,保证两个界面读写一致)。展开行(chevron)会显示两个容量输入,其占位符来自适配器自己的路由级兜底值:contextWindow显示256K、maxTokens显示32K(llm-pi-ai的defaultContextWindow/defaultMaxTokens的人类可读拼写)。注意占位符只是提示而非镜像:页面按 1000 计 K,所以输入256K存的是 256000,而留空则保留适配器的 262144。
探测(Fetch):问的是"表单此刻显示的内容"
ModelListEditor拥有 fetch 动作,ProbeTarget定义了探测目标:
export interface ProbeTarget { settingsNs: string // 回答问题的适配器所属 settings 命名空间 provider?: string // 被编辑的路由;适配器已描述它时可从自身注册表直接回答 baseURL?: string // 表单当前显示的端点 api?: string // 表单选择的 wire 协议 apiKey?: string // 已输入但尚未存储的 key }关键设计:fetch 询问的是表单此刻显示的内容——一个被编辑但未保存的 base URL、一个已输入但未落库的 key。这让"添加 provider"成为一趟流程,而不是"先保存再返回"。
- 探测请求通过
api.llm.discoverModels(settingsNs, request)发出(对应llm-pi-ai的 discovery.ts); - 回复打开一个候选选择器(picker)而非直接写入:已配置的候选默认不勾选,所以采纳选择永远不会覆盖用户修正过的容量;
- 候选采纳时
adopt()只带上端点披露的字段(id/name/contextWindow/maxTokens),按 id 合并——用户已调好的行胜出; - 无法被探测的 provider 是"绕行而非死路":适配器自己的错误消息显示在仍然可手工编辑的行旁边。
底层探测实现(llm-pi-ai 侧)
discovery.ts 中:
- 可读列表的协议只有
openai-completions与openai-responses——它们共享 OpenAIGET /models形状 + Bearer 认证。Azure 虽属 OpenAI 血统但被排除(需要api-key头与api-version查询参数),Codex 走 OAuth,其余协议回答DISCOVERY_UNSUPPORTED,界面回落到手工输入而非把猜错的响应形状报告成"空 provider"; - base URL 按前缀处理而非 URL 解析目标:
listingUrl()做${baseURL.replace(/\/+$/, '')}/models,部署路径如https://gateway.example/openai/v1保留其分段; - 4 MB 响应上限作用于实际读取的字节数:先检查声明的
content-length(礼貌但不可信),再在流式累积中强制封顶,与dsh-web-fetch处理调用方提供 URL 的两阶段形状一致; - 已配置路由的凭据读取只在必要分支发生:
request.provider有内置目录时直接由注册表回答、零网络调用;草稿携带的apiKey优先("正在测试的那个 key"),否则才读存储凭据;无 key 的探测保持未认证,用于依赖 provider 自身环境发现的场景。
核心设计二:CustomProviderCard——声明 pi-ai 不内置的 provider
CustomProviderCard(源码)声明 pi-ai 未随包提供的路由。它必须是独立卡片,因为route id 在这里被选定:
- 一次
settings.mutate把整个 profile 写到providers.<route>; - key 通过
credentials.set单独传送,引用名沿用既有 provider 的<ROUTE>_API_KEY派生规则(deriveKeyRef:大写 + 非字母数字转_+_API_KEY后缀)。
三个"不能默认"的门槛字段
手工声明的路由无法默认三件事:endpoint、protocol、至少一个模型。它们成为创建按钮的 gate——失败时用户还看着该字段,错误就能指名道姓。表单校验包括:
ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/——route id 既是 settings 键又是凭据名的词干,而凭据引用是 POSIX shell 标识符,不能以数字开头,否则会在凭据缝合处抛出一段用户无从处理的裸正则错误;- 路由不能与已声明路由重名(
taken检查 +revision乐观并发控制:卡片打开后其他标签页声明的路由会得到settings-conflict拒绝而非静默覆盖整个 profile); - 模型行复用与编辑器卡片相同的逐行校验器(
validateDeepSeekModels),坏行按位置指名; - key 空白的语义在此是"该路由可能通过 provider 自身环境发现或 OAuth 认证"(对应
keyBlankNew文案),profile 只在确实要存 key 时才记录apiKeyEnv引用。
写路径:先 profile 后凭据
createOnce()的写序是有意的:
settings.mutate写入 profile(含apiKeyEnv引用,仅当本卡要存 key);- 置
committed = true,此后 profile 字段锁定; credentials.set写入 key。
profile 落盘后若 key 写入失败,重试路径直接回到凭据写入——不会因 revision 已被本次写入取代而再次得到settings-conflict。整卡同时持有busy与committed两个状态,保证并发与重试的正确性。
协议选择来自命名空间自己的 schema
协议候选来自llm-pi-ai命名空间自身的 schema,通过页面已获取的 settings descriptor 读取:providers.*.api是适配器supportedProtocols()的 union(见 config.ts 的api: z.union(supportedProtocols())与 provider.ts)。protocolChoices()(store.ts)在 schema 的 union 节点上读出字符串列表:
- 没有新的 wire 字段,客户端也没有硬编码常量;
- 页面提供的选项与适配器接受的选项同源,不可能漂移;
- 协议顺序即 provider 表顺序、稳定不变,第一个即默认值——
openai-completions(网关最常说的协议)排在首位。
声明路由(declared route)的额外字段
目录报告为"已声明"(declared)的路由,编辑器还会到达它为自己命名的两个字段:display name 与协议。二者渲染在端点旁边的折叠区里,协议同样来自 schema 读取:
- 清空 display name = 取消设置;回落值读取组合层(composition layer)——
cordis.yml可能为目录未内置的路由钉住名字,只有什么也没钉住时才回落为 route id; - 协议没有可清除的回落值;
- 目录路由(catalog route)两者都没有:名字默认取自目录条目,且其每个模型各自携带协议,路由级协议只会覆盖它们全部。
因为一次 apply 可以重命名路由,保存通知以刷新后目录报告的名字为准,而非卡片打开时捕获的目标名。
核心设计三:Provider ID 为什么不可编辑
CustomProviderCard上唯一固定不变的字段是Provider ID,这不是因为缺少控件:
- 它是
providers.<route>字典键——改它是一次"移动"而非"编辑",而编辑器正是通过settingsPath寻址的,改名会让这个路径失效; - 它被本命名空间之外引用:
agent-default-model存有provider字符串,每个会话日志的request/header都记录着一个——重命名会让页面看不见的引用者悄悄失去意义; - 它是派生凭据引用的词干:页面只能写 key、永远读不回 key,所以它无法把
OLD_API_KEY移成NEW_API_KEY——重命名要么孤立已存 key,要么让 profile 指向旧名下的引用。
因此"声明新路由 + 删除旧路由"是诚实的替代方案,而页面已经同时提供了这两半(removeProviderProfile先删凭据再删 profile,两步均可安全重试)。
备选方案与拒绝理由(决策记录)
笔记的 Alternatives considered 记录了六个被拒方案,理解它们能更好把握最终形态:
| 备选方案 | 被拒原因 |
|---|---|
用ProviderEditor加字段声明 | 编辑器按settingsPath寻址,被命名的路由还没有路径;逐键重算路径会重挂卡片丢失草稿 |
| 为协议列表加 wire 字段 | settings schema 已跨 wire 且已含 union,第二份拷贝可能与第一份不一致 |
| 允许编辑 Provider ID、页面执行移动 | 凭据无法随行(页面只持脱敏 descriptor,从不持有值);其他命名空间与会话日志中的引用没有改名路径 |
| 所有 pi-ai 路由都提供协议 + inherit 选项 | 无消费者需求;误选会悄悄重指整条路由的全部模型;settings.yaml仍可表达有意的重指 |
| 针对已存 profile 而非实时表单探测 | 最需要探测的恰恰是"什么都还没存"的流程;端点被编辑的表单会悄悄探测旧端点 |
| 采纳的候选直接写入列表 | 更少点击,但 fetch 会覆盖用户修正过的容量,且只披露 id 的列表会用"无"替换真实数字 |
影响与代价
收益
- 网关、自托管服务器、比安装目录更新的模型,无需离开浏览器即可配置;
- 端点自身(能提供时)供应模型 id,用户从候选中选择而非手工抄写文档;
- 页面新增两个组件和一个共享列表编辑器;编辑器卡片的 pi-ai 折叠区从两个字段扩展为一个列表,声明路由还多了名字与协议。
代价(原文如实记录)
- 只有 pi-ai 路由可手工声明:
llm-pi-ai是唯一 profile 描述"整个 provider"的命名空间,llm-deepseek路由仍然是组合层事实; - 探测只覆盖 OpenAI 兼容端点:说其他协议的网关会报告"无法被询问",模型需要手工输入;
- 页面在 fetch 期间于组件状态中持有一枚 key:与
credentials.set已有的暴露面相同,且不超出卡片生命周期; - 配置生效仍以
settings.yaml为唯一事实源,模型列表的"新"程度等于最近一次编辑。
测试如何验证这套设计
组件级:provider-form.client.spec.tsx
provider-form.client.spec.tsx 通过脚本化的 wire 面驱动渲染页面,覆盖:
- 行的添加、编辑、删除;
- 清空可选字段会离开 profile、非整数容量永不进入 profile;
- 探测携带编辑后的端点、未保存的 key 与 profile 的协议;
- picker 的默认选择、切换、取消,以及"采纳保留已调优行";
- 空结果、被拒、传输拒绝三条路径;
- 创建写入一个 profile 加一条凭据;
- 创建按钮的每个 gate;
- 只读姿态。
此外还有若干针对性断言:
protocolChoices对"声明 union 的 schema"与"不声明 union 的 schema"分别覆盖;- 样式 gate 读取包自身源码,任何
<select>若不带.selectInput而只带.input即失败(否则保留的 OS 箭头会贴死在select.input施加的 240px 上限内); - 编辑器字段清单按路由种类断言——目录路由止步于 key 与端点,声明路由还带协议;
- 协议编辑以单个
api路径 op 行进、重命名以单个displayNameop 行进、清空名字是 unset 而非存储适配器拒绝的空串、声明 profile 未命名协议时不选第一个选项而是什么都不选。
端到端:models-settings.e2e.ts
apps/web/tests/models-settings.e2e.ts 通过真实 wire 重新打开声明路由,捕获卡片,并断言:
- 选择的协议与新名字都到达
settings.yaml; - 行在重命名后重新注册。
该 e2e 是"零模型调用"场景——配置是纯 settings/credentials/llm 域流量,无 fixture,且适配器注册表为空时任何越界流都会大声失败。测试选用minimax-cn作为被测 provider,避免开发者真实的ANTHROPIC/OPENAI环境变量遮蔽派生引用。
如何上手验证
- 启动 Web 应用(参见 docs/development.md 与 docs/user/guide 的运行说明);
- 打开 Models 设置页,点击自定义添加(custom add)进入
CustomProviderCard; - 填写 Provider ID(小写字母开头、
-连接的合法路由键,如acme-gateway)、display name、base URL,选择协议(默认openai-completions),输入 API key(可留空以走 provider 原生认证); - 点击Fetch models让端点自报模型 id,在 picker 中勾选要采纳的候选,或直接手工添加模型行并填写容量;
- 点击Create——profile 落入
providers.<route>,key 落入派生引用<ROUTE>_API_KEY; - 回到编辑器卡片,可继续修正 display name 与协议;保存通知会按刷新后的目录报告命名路由。
配置的最终落点是$DSH_HOME/settings.yaml:声明路由必须写明api、baseURL与非空models列表,这正是设计上"不能默认的三件事"的持久化形态。
总结
"从 Models 页面声明 Provider"把一条原本只有 YAML 专家能走的路,压缩进了浏览器的两张卡片里:ModelListEditor以"空列表即内置目录"的语义安全地编辑模型数组,并对表单当前状态发起端点探测、以候选 picker 形式交还用户;CustomProviderCard以"create 即独立卡片"的姿态一次写入 profile 与凭据,用三个必填门槛替代加载期校验,让失败直接点名字段。协议的选项与适配器接受的协议同源于同一份 schema,杜绝漂移;Provider ID 因牵涉 settings 路径、外部引用与派生凭据而刻意不可变。结合provider-form.client.spec.tsx的组件级覆盖与models-settings.e2e.ts的端到端验证,这一设计在可配置性、一致性与安全性之间给出了可复盘的取舍样本。
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考