dsh-provider-hub 0.1.1 → 0.2.0
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 +29 -4
- package/core/adapters/workbuddy.js +179 -0
- package/core/profile.js +17 -1
- package/core/providers.js +514 -225
- package/package.json +9 -4
- package/tools/refresh-catalog.mjs +268 -0
- package/tools/workbuddy.mjs +264 -0
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
为 DSH 扩大可用的服务商:**选一个服务商,粘贴 API Key,点启用**——插件把官方 `llm-pi-ai` 路由与凭据一次性写好,立即生效、无需重启、无需手改 `settings.yaml`。
|
|
6
6
|
|
|
7
|
-
内置
|
|
7
|
+
内置 **39 家服务商预设**(OpenAI 兼容 / Anthropic Messages / OpenAI Responses 三种协议),覆盖国际与国内的普通 API 与 **订阅渠道**(百炼 Token Plan、智谱 Coding、Kimi Coding、MiniMax Coding Plan、小米 MiMo、OpenCode Zen 等),另有「自定义服务商」入口可接入任意兼容端点;DSH 已内置的服务商默认折叠、不重复展示。
|
|
8
8
|
|
|
9
9
|
## 安装
|
|
10
10
|
|
|
@@ -42,9 +42,34 @@ dsh plugin --profile web add link:/path/to/dsh-provider-hub
|
|
|
42
42
|
|
|
43
43
|
## 预设清单
|
|
44
44
|
|
|
45
|
-
OpenAI、OpenRouter、Anthropic、Google Gemini
|
|
45
|
+
OpenAI、OpenRouter、Anthropic、Google Gemini、xAI Grok、Mistral、Groq、Together、Fireworks、DeepInfra、Novita、Cerebras、NVIDIA NIM、Baseten、HuggingFace Router、**Vercel AI Gateway**、**OpenCode Zen / Zen Go**、硅基流动、月之暗面 Kimi(国内/国际)、**Kimi Coding Plan**、智谱 GLM(国内)、**智谱 Coding Plan**、Z.ai GLM、**MiniMax Coding Plan(国际/国内)**、MiniMax(国内)、**阿里云百炼 Token Plan(国内/国际)**、阿里云百炼 Qwen、百度千帆、腾讯混元、火山方舟(豆包)、**小米 MiMo**、**Ant Ling**、**LongCat(美团)**、**StepFun Step Plan(国内/国际)**。
|
|
46
46
|
|
|
47
|
-
关于内置:DSH 自带 DeepSeek 官方适配器(`llm-deepseek`),其目录里的 `stepfun` 指向普通付费端点 `api.stepfun.com/v1`——**Step Plan 订阅端点(`step_plan/v1
|
|
47
|
+
关于内置:DSH 自带 DeepSeek 官方适配器(`llm-deepseek`),其目录里的 `stepfun` 指向普通付费端点 `api.stepfun.com/v1`——**Step Plan 订阅端点(`step_plan/v1`)没有内置条目,因此本插件提供该预设**;其余与本插件重合的服务商由 DSH 原生目录覆盖的那些默认折叠在「显示 DSH 已内置」开关后。已配置过的 Key(包括来自环境变量的)会被识别,留空输入框即可直接启用。
|
|
48
|
+
|
|
49
|
+
## WorkBuddy(腾讯)适配
|
|
50
|
+
|
|
51
|
+
同一份预设目录也能写入 WorkBuddy 的本地自定义模型文件(`~/.workbuddy/models.json`,明文 JSON,应用启动时读取):
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
node tools/workbuddy.mjs list # 查看预设与 WorkBuddy 里的已装状态
|
|
55
|
+
node tools/workbuddy.mjs add-all # 目录内全部预设:无 Key 的先装上,用时在 WorkBuddy 里补
|
|
56
|
+
node tools/workbuddy.mjs add stepfun-step-plan --key-from-dsh STEPFUN_API_KEY
|
|
57
|
+
node tools/workbuddy.mjs add openrouter --key <key> --models deepseek/deepseek-chat
|
|
58
|
+
node tools/workbuddy.mjs remove stepfun-step-plan
|
|
59
|
+
node tools/workbuddy.mjs add-custom --model gpt-5.1 --url https://api.openai.com/v1 --key sk-...
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- 条目按 WorkBuddy 的校验规则(`isValidLocalCustomModel`)生成,写入前本地校验、临时文件+rename 原子替换
|
|
63
|
+
- **写入后需重启 WorkBuddy**(CLI 会在应用运行中时提示)
|
|
64
|
+
- 应用会把本地模型标记为 `custom-local:` 前缀 + `custom` tag,实际请求前还原原始模型 ID
|
|
65
|
+
- `useCustomProtocol: false`(默认)=应用自动在 URL 后追加 `/chat/completions`;需要完整 URL 时加 `--full-url`
|
|
66
|
+
- Key 来源三选一:`--key`、`--key-env NAME`、`--key-from-dsh REF`(复用 DSH 凭据服务里的 Key)
|
|
67
|
+
- WorkBuddy 的模型按 ID 全局唯一:国内/国际预设共用模型 ID 时,**目录里靠前的一家生效**;带 Key 的 `add` 是显式安装、必定覆盖;`add-all` 不会覆盖已配置 Key 的条目。想换另一家先 `remove <另一家>` 再 `add <目标家> --key ...`
|
|
68
|
+
|
|
69
|
+
## 目录维护
|
|
70
|
+
|
|
71
|
+
- 模型清单与逐模型兼容开关可从任意权威目录刷新:`node tools/refresh-catalog.mjs --catalog <catalog.json>`(先 `--dry` 审阅);路由级只保留全部模型一致的开关,不一致的由模型自己携带 `compat`
|
|
72
|
+
- WorkBuddy 端用 `node tools/workbuddy.mjs add-all --prune` 同步:装入全部预设并清掉目录里已不存在的旧条目
|
|
48
73
|
|
|
49
74
|
## 注意
|
|
50
75
|
|
|
@@ -58,7 +83,7 @@ OpenAI、OpenRouter、Anthropic、Google Gemini(OpenAI 兼容)、xAI Grok、
|
|
|
58
83
|
|
|
59
84
|
Expand the providers DSH can use: **pick a provider, paste the API key, press Enable**. The plugin writes the official `llm-pi-ai` route and its credential in one step - effective immediately, no restart, no hand-editing `settings.yaml`.
|
|
60
85
|
|
|
61
|
-
|
|
86
|
+
39 built-in presets (OpenAI-compatible, Anthropic Messages and OpenAI Responses protocols) plus a custom-endpoint form for any compatible gateway. Presets your target app already ships natively are collapsed by default, never deleted.
|
|
62
87
|
|
|
63
88
|
### Install
|
|
64
89
|
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// dsh-provider-hub - WorkBuddy adapter core (pure).
|
|
2
|
+
//
|
|
3
|
+
// Tencent WorkBuddy (com.tencent.workbuddy.mac) reads local custom models from
|
|
4
|
+
// `<configDir>/models.json` -- a plain JSON array, no field encryption, only a
|
|
5
|
+
// file lock and atomic write around it. The app validates every entry with the
|
|
6
|
+
// equivalent of `isValidLocalCustomModel` and DROPS invalid entries with a
|
|
7
|
+
// warning, so the builders here emit exactly that shape:
|
|
8
|
+
//
|
|
9
|
+
// * `id` is required (the model id sent to the endpoint);
|
|
10
|
+
// * `name` / `vendor` / `url` / `apiKey` are strings;
|
|
11
|
+
// * `maxInputTokens` / `maxOutputTokens` / `temperature` are numbers;
|
|
12
|
+
// * `supportsToolCall` / `supportsImages` / `supportsReasoning` /
|
|
13
|
+
// `onlyReasoning` / `useCustomProtocol` are booleans;
|
|
14
|
+
// * `reasoning` is an object (`{ defaultEffort, supportedEfforts, canDisableThinking }`).
|
|
15
|
+
//
|
|
16
|
+
// `useCustomProtocol: false` means the app appends `/chat/completions` to the
|
|
17
|
+
// URL (the normal OpenAI-compatible case); `true` sends the URL verbatim.
|
|
18
|
+
// Entries are addressed by their URL for install/removal bookkeeping: the app
|
|
19
|
+
// keys by id (one entry per id globally), so a preset owns exactly the entries
|
|
20
|
+
// whose `url` equals its base URL.
|
|
21
|
+
|
|
22
|
+
/** The separator between a preset's display name and a model name in `name`. */
|
|
23
|
+
const NAME_SEPARATOR = ' · '
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Mirror of the app's `isValidLocalCustomModel`: would WorkBuddy keep this entry?
|
|
27
|
+
* @param entry - candidate entry.
|
|
28
|
+
* @returns true when the app would load it.
|
|
29
|
+
*/
|
|
30
|
+
export function isValidWorkbuddyEntry(entry) {
|
|
31
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false
|
|
32
|
+
if (typeof entry.id !== 'string' || entry.id.trim() === '') return false
|
|
33
|
+
const stringOrAbsent = (value) => value === undefined || typeof value === 'string'
|
|
34
|
+
const numberOrAbsent = (value) => {
|
|
35
|
+
if (value === undefined) return true
|
|
36
|
+
if (typeof value === 'number') return Number.isFinite(value)
|
|
37
|
+
// The app coerces numeric strings (`coerceOptionalNumber`), so a numeric
|
|
38
|
+
// string is accepted here too -- kept for validator fidelity only.
|
|
39
|
+
if (typeof value === 'string' && value.trim() !== '') return Number.isFinite(Number(value))
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
const booleanOrAbsent = (value) => value === undefined || typeof value === 'boolean'
|
|
43
|
+
if (!['name', 'vendor', 'url', 'apiKey'].every((field) => stringOrAbsent(entry[field]))) return false
|
|
44
|
+
if (!['maxInputTokens', 'maxOutputTokens', 'temperature'].every((field) => numberOrAbsent(entry[field]))) return false
|
|
45
|
+
if (!['supportsToolCall', 'supportsImages', 'supportsReasoning', 'onlyReasoning', 'useCustomProtocol'].every((field) => booleanOrAbsent(entry[field]))) return false
|
|
46
|
+
if (entry.reasoning !== undefined && (typeof entry.reasoning !== 'object' || entry.reasoning === null || Array.isArray(entry.reasoning))) return false
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Normalize one http(s) base URL the way the app's connectivity test does. */
|
|
51
|
+
export function baseUrlError(value) {
|
|
52
|
+
if (typeof value !== 'string' || value.trim() === '') return 'url 不能为空'
|
|
53
|
+
let url
|
|
54
|
+
try {
|
|
55
|
+
url = new URL(value.trim())
|
|
56
|
+
} catch {
|
|
57
|
+
return 'url 格式不合法'
|
|
58
|
+
}
|
|
59
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'url 仅支持 HTTP 或 HTTPS'
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Build the WorkBuddy entry for one catalog model.
|
|
65
|
+
* @param preset - catalog preset (`id`, `name`, `baseURL`, `models`).
|
|
66
|
+
* @param model - one model entry from the preset.
|
|
67
|
+
* @param options - `{ key?, apiKey? }`.
|
|
68
|
+
* @returns a models.json entry.
|
|
69
|
+
*/
|
|
70
|
+
export function buildWorkbuddyEntry(preset, model, options = {}) {
|
|
71
|
+
const apiKey = typeof options.key === 'string' && options.key.trim() !== '' ? options.key.trim() : options.apiKey
|
|
72
|
+
const efforts = model.reasoningEfforts && typeof model.reasoningEfforts === 'object' ? Object.keys(model.reasoningEfforts) : []
|
|
73
|
+
const reasoning = {}
|
|
74
|
+
if (efforts.length > 0) {
|
|
75
|
+
reasoning.supportedEfforts = efforts
|
|
76
|
+
if (efforts.includes('low')) reasoning.defaultEffort = 'low'
|
|
77
|
+
if (efforts.includes('medium') && reasoning.defaultEffort === undefined) reasoning.defaultEffort = 'medium'
|
|
78
|
+
}
|
|
79
|
+
const entry = {
|
|
80
|
+
id: model.id,
|
|
81
|
+
name: `${preset.name}${NAME_SEPARATOR}${model.name ?? model.id}`,
|
|
82
|
+
vendor: preset.name,
|
|
83
|
+
url: preset.baseURL,
|
|
84
|
+
apiKey,
|
|
85
|
+
supportsToolCall: true,
|
|
86
|
+
supportsImages: Array.isArray(model.input) && model.input.includes('image'),
|
|
87
|
+
supportsReasoning: Boolean(model.reasoningEfforts),
|
|
88
|
+
useCustomProtocol: false,
|
|
89
|
+
}
|
|
90
|
+
if (typeof model.contextWindow === 'number') entry.maxInputTokens = model.contextWindow
|
|
91
|
+
if (typeof model.maxTokens === 'number') entry.maxOutputTokens = model.maxTokens
|
|
92
|
+
if (Object.keys(reasoning).length > 0) entry.reasoning = reasoning
|
|
93
|
+
for (const key of Object.keys(entry)) if (entry[key] === undefined) delete entry[key]
|
|
94
|
+
return entry
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Build every entry one preset contributes.
|
|
99
|
+
* @param preset - catalog preset.
|
|
100
|
+
* @param options - `{ key?, apiKey?, modelIds? }` (`modelIds` limits the set).
|
|
101
|
+
* @returns entries, in catalog order.
|
|
102
|
+
*/
|
|
103
|
+
export function buildWorkbuddyEntries(preset, options = {}) {
|
|
104
|
+
const wanted = Array.isArray(options.modelIds) && options.modelIds.length > 0 ? new Set(options.modelIds) : undefined
|
|
105
|
+
return preset.models
|
|
106
|
+
.filter((model) => wanted === undefined || wanted.has(model.id))
|
|
107
|
+
.map((model) => buildWorkbuddyEntry(preset, model, options))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Upsert entries into a WorkBuddy models list, by entry id (the app's own key).
|
|
112
|
+
* An existing entry for the same id is replaced -- exactly what the app's
|
|
113
|
+
* `configSaveLocalCustomModel` does -- so a re-add updates in place.
|
|
114
|
+
* @param list - current models.json array.
|
|
115
|
+
* @param entries - entries to write.
|
|
116
|
+
* @returns `{ models, added, updated }`.
|
|
117
|
+
*/
|
|
118
|
+
export function upsertWorkbuddyModels(list, entries) {
|
|
119
|
+
const models = Array.isArray(list) ? list.map((entry) => ({ ...entry })) : []
|
|
120
|
+
let added = 0
|
|
121
|
+
let updated = 0
|
|
122
|
+
let kept = 0
|
|
123
|
+
for (const entry of entries) {
|
|
124
|
+
if (!isValidWorkbuddyEntry(entry)) throw new Error(`拒绝写入非法条目:${JSON.stringify(entry).slice(0, 120)}`)
|
|
125
|
+
const index = models.findIndex((candidate) => candidate.id === entry.id)
|
|
126
|
+
if (index < 0) {
|
|
127
|
+
models.push(entry)
|
|
128
|
+
added += 1
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
const existing = models[index]
|
|
132
|
+
const incomingHasKey = typeof entry.apiKey === 'string' && entry.apiKey !== ''
|
|
133
|
+
const existingHasKey = typeof existing.apiKey === 'string' && existing.apiKey !== ''
|
|
134
|
+
// WorkBuddy keys models by id globally, so a catalog pass can meet an id
|
|
135
|
+
// twice (CN vs global presets share model ids) or meet an entry the user
|
|
136
|
+
// already configured. Rules: a keyless add never displaces a configured
|
|
137
|
+
// entry; among keyless duplicates the first catalog entry wins; a keyed
|
|
138
|
+
// add always wins (that is an explicit per-provider install).
|
|
139
|
+
if (!incomingHasKey && existingHasKey) {
|
|
140
|
+
kept += 1
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (!incomingHasKey && existing.url !== entry.url) {
|
|
144
|
+
kept += 1
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
models[index] = { ...existing, ...entry }
|
|
148
|
+
updated += 1
|
|
149
|
+
}
|
|
150
|
+
return { models, added, updated, kept }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Remove every entry that a preset owns (matched by base URL).
|
|
155
|
+
* @param list - current models.json array.
|
|
156
|
+
* @param preset - catalog preset.
|
|
157
|
+
* @returns `{ models, removed }`.
|
|
158
|
+
*/
|
|
159
|
+
export function removeWorkbuddyModels(list, preset) {
|
|
160
|
+
const models = Array.isArray(list) ? list : []
|
|
161
|
+
const kept = models.filter((entry) => entry?.url !== preset.baseURL)
|
|
162
|
+
return { models: kept, removed: models.length - kept.length }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Which presets already have entries in the WorkBuddy models list.
|
|
167
|
+
* @param list - current models.json array.
|
|
168
|
+
* @param presets - catalog presets.
|
|
169
|
+
* @returns Map presetId -> model count.
|
|
170
|
+
*/
|
|
171
|
+
export function installedPresets(list, presets) {
|
|
172
|
+
const models = Array.isArray(list) ? list : []
|
|
173
|
+
const counts = new Map()
|
|
174
|
+
for (const preset of presets) {
|
|
175
|
+
const count = models.filter((entry) => entry?.url === preset.baseURL).length
|
|
176
|
+
if (count > 0) counts.set(preset.id, count)
|
|
177
|
+
}
|
|
178
|
+
return counts
|
|
179
|
+
}
|
package/core/profile.js
CHANGED
|
@@ -67,6 +67,10 @@ export function sanitizeModel(entry) {
|
|
|
67
67
|
maxTokens: Number.isFinite(entry.maxTokens) && entry.maxTokens > 0 ? Math.floor(entry.maxTokens) : undefined,
|
|
68
68
|
input: Array.isArray(entry.input) && entry.input.length > 0 ? [...entry.input] : undefined,
|
|
69
69
|
reasoningEfforts: entry.reasoningEfforts,
|
|
70
|
+
// Per-model wire switches: when models on one route disagree (Kimi's k3 vs
|
|
71
|
+
// k2.x thinking formats), the model entry carries its own compat and wins
|
|
72
|
+
// over the route consensus.
|
|
73
|
+
compat: entry.compat && typeof entry.compat === 'object' && !Array.isArray(entry.compat) ? { ...entry.compat } : undefined,
|
|
70
74
|
})
|
|
71
75
|
return model
|
|
72
76
|
}
|
|
@@ -133,12 +137,24 @@ export function buildProfile(input) {
|
|
|
133
137
|
: route
|
|
134
138
|
const env = preset ? preset.env : customEnvName(route)
|
|
135
139
|
|
|
140
|
+
// Route-level compat: the hub's safe defaults for OpenAI-compatible
|
|
141
|
+
// endpoints, overridden by the preset's own authoritative switches (learned
|
|
142
|
+
// from the served catalogs, e.g. thinkingFormat qwen/zai/deepseek/ant-ling),
|
|
143
|
+
// then by any explicit caller override. anthropic-messages routes carry their
|
|
144
|
+
// own switch set and no OpenAI defaults; openai-responses carries none here.
|
|
145
|
+
const presetCompat = preset && preset.compat && typeof preset.compat === 'object' ? preset.compat : undefined
|
|
146
|
+
const callerCompat = input && input.compat && typeof input.compat === 'object' ? input.compat : undefined
|
|
147
|
+
let compat
|
|
148
|
+
if (api === 'openai-completions') compat = { ...OPENAI_COMPAT, ...(presetCompat ?? {}), ...(callerCompat ?? {}) }
|
|
149
|
+
else if (api === 'anthropic-messages') compat = { ...(presetCompat ?? {}), ...(callerCompat ?? {}) }
|
|
150
|
+
if (compat !== undefined && Object.keys(compat).length === 0) compat = undefined
|
|
151
|
+
|
|
136
152
|
return compact({
|
|
137
153
|
displayName,
|
|
138
154
|
api,
|
|
139
155
|
baseURL,
|
|
140
156
|
apiKeyEnv: env,
|
|
141
|
-
compat
|
|
157
|
+
compat,
|
|
142
158
|
models,
|
|
143
159
|
})
|
|
144
160
|
}
|