dsh-provider-hub 0.1.1 → 0.3.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 +53 -4
- package/core/adapters/opencode.js +137 -0
- package/core/adapters/workbuddy.js +179 -0
- package/core/profile.js +17 -1
- package/core/providers.js +604 -224
- package/package.json +10 -4
- package/src/client.js +10 -10
- package/tools/opencode.mjs +306 -0
- 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
|
+
内置 **40 家服务商预设**(OpenAI 兼容 / Anthropic Messages / OpenAI Responses 三种协议),覆盖国际与国内的普通 API 与 **订阅渠道**(百炼 Token Plan、智谱 Coding、Kimi Coding、MiniMax Coding Plan、小米 MiMo、OpenCode Zen、**Command Code GOAT** 等),另有「自定义服务商」入口可接入任意兼容端点;DSH 已内置的服务商默认折叠、不重复展示。
|
|
8
8
|
|
|
9
9
|
## 安装
|
|
10
10
|
|
|
@@ -42,9 +42,54 @@ 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
|
+
**Command Code GOAT** 预设收录了 GOAT 计划的 58 个模型(GPT-6 Luna、MiMo V2.6 全系、Kimi K3、Grok 4.7、Qwen 3.8、GLM-5.3、DeepSeek V4 系列、Gemini 3.8 Flash 等,含 `poolside/laguna-s-2.1-free`、`inclusionai/ling-3.0-flash-sante:free` 两个免费模型);需 Pro/Max 计划的高级模型(GPT-6 Astra/Sol、Claude 系列等)不在其中。
|
|
50
|
+
|
|
51
|
+
## WorkBuddy(腾讯)适配
|
|
52
|
+
|
|
53
|
+
同一份预设目录也能写入 WorkBuddy 的本地自定义模型文件(`~/.workbuddy/models.json`,明文 JSON,应用启动时读取):
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
node tools/workbuddy.mjs list # 查看预设与 WorkBuddy 里的已装状态
|
|
57
|
+
node tools/workbuddy.mjs add-all # 目录内全部预设:无 Key 的先装上,用时在 WorkBuddy 里补
|
|
58
|
+
node tools/workbuddy.mjs add stepfun-step-plan --key-from-dsh STEPFUN_API_KEY
|
|
59
|
+
node tools/workbuddy.mjs add openrouter --key <key> --models deepseek/deepseek-chat
|
|
60
|
+
node tools/workbuddy.mjs remove stepfun-step-plan
|
|
61
|
+
node tools/workbuddy.mjs add-custom --model gpt-5.1 --url https://api.openai.com/v1 --key sk-...
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- 条目按 WorkBuddy 的校验规则(`isValidLocalCustomModel`)生成,写入前本地校验、临时文件+rename 原子替换
|
|
65
|
+
- **写入后需重启 WorkBuddy**(CLI 会在应用运行中时提示)
|
|
66
|
+
- 应用会把本地模型标记为 `custom-local:` 前缀 + `custom` tag,实际请求前还原原始模型 ID
|
|
67
|
+
- `useCustomProtocol: false`(默认)=应用自动在 URL 后追加 `/chat/completions`;需要完整 URL 时加 `--full-url`
|
|
68
|
+
- Key 来源三选一:`--key`、`--key-env NAME`、`--key-from-dsh REF`(复用 DSH 凭据服务里的 Key)
|
|
69
|
+
- WorkBuddy 的模型按 ID 全局唯一:国内/国际预设共用模型 ID 时,**目录里靠前的一家生效**;带 Key 的 `add` 是显式安装、必定覆盖;`add-all` 不会覆盖已配置 Key 的条目。想换另一家先 `remove <另一家>` 再 `add <目标家> --key ...`
|
|
70
|
+
|
|
71
|
+
## OpenCode 适配
|
|
72
|
+
|
|
73
|
+
同一份预设目录也能写入 OpenCode 的配置(`~/.config/opencode/opencode.json` 或 `.jsonc`,自动备份):
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
node tools/opencode.mjs list # 查看预设与 OpenCode 里的已装状态
|
|
77
|
+
node tools/opencode.mjs add commandcode --key-from-dsh COMMANDCODE_API_KEY
|
|
78
|
+
node tools/opencode.mjs add openrouter --key <key> --models deepseek/deepseek-chat
|
|
79
|
+
node tools/opencode.mjs add-all [--prune] # 全部预设(无 Key 的先用占位)
|
|
80
|
+
node tools/opencode.mjs remove commandcode
|
|
81
|
+
node tools/opencode.mjs add-custom --route myapi --model gpt-5.1 --url https://api.openai.com/v1 --key sk-... [--reasoning] [--model-name "GPT-5.1"]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- Key 存进 provider 的 `options.apiKey`;模型带 OpenCode 需要的元数据(`name` / `limit` / `reasoning` / `tool_call` / `attachment`)
|
|
85
|
+
- OpenCode 的 models.dev 目录里没有的路由自动补上正确的 npm SDK(依据本地 `~/.cache/opencode/models.json` 判定协议)
|
|
86
|
+
- 改完配置**重启 OpenCode 应用**(命令行 `opencode run` 每次启动即读)
|
|
87
|
+
- Key 来源三选一:`--key`、`--key-env NAME`、`--key-from-dsh REF`
|
|
88
|
+
|
|
89
|
+
## 目录维护
|
|
90
|
+
|
|
91
|
+
- 模型清单与逐模型兼容开关可从任意权威目录刷新:`node tools/refresh-catalog.mjs --catalog <catalog.json>`(先 `--dry` 审阅);路由级只保留全部模型一致的开关,不一致的由模型自己携带 `compat`
|
|
92
|
+
- WorkBuddy 端用 `node tools/workbuddy.mjs add-all --prune` 同步:装入全部预设并清掉目录里已不存在的旧条目
|
|
48
93
|
|
|
49
94
|
## 注意
|
|
50
95
|
|
|
@@ -58,7 +103,11 @@ OpenAI、OpenRouter、Anthropic、Google Gemini(OpenAI 兼容)、xAI Grok、
|
|
|
58
103
|
|
|
59
104
|
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
105
|
|
|
61
|
-
|
|
106
|
+
40 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.
|
|
107
|
+
|
|
108
|
+
The **Command Code GOAT** preset carries all 58 models of the GOAT plan (GPT-6 Luna, the MiMo V2.6 family, Kimi K3, Grok 4.7, Qwen 3.8, GLM-5.3, DeepSeek V4 series, Gemini 3.8 Flash, plus the free `poolside/laguna-s-2.1-free` and `inclusionai/ling-3.0-flash-sante:free`). Pro/Max-only models (GPT-6 Astra/Sol, the Claude family) are excluded.
|
|
109
|
+
|
|
110
|
+
The same catalog ships two CLIs: `provider-hub-workbuddy` writes WorkBuddy's local `models.json`, `provider-hub-opencode` writes OpenCode's config; both accept `--key-from-dsh <REF>` to reuse a key already stored in DSH.
|
|
62
111
|
|
|
63
112
|
### Install
|
|
64
113
|
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// dsh-provider-hub - OpenCode adapter core (pure).
|
|
2
|
+
//
|
|
3
|
+
// OpenCode (sst/opencode, desktop app + CLI) reads `~/.config/opencode/opencode.json`
|
|
4
|
+
// (or `.jsonc`) and keeps provider keys either in `options.apiKey` or in its
|
|
5
|
+
// auth store (`~/.local/share/opencode/auth.json`, keyed by provider id).
|
|
6
|
+
// A custom provider entry is:
|
|
7
|
+
//
|
|
8
|
+
// "provider": {
|
|
9
|
+
// "<route>": {
|
|
10
|
+
// "npm": "@ai-sdk/openai-compatible", // only when OpenCode does not
|
|
11
|
+
// "api": "https://…", // know the route from models.dev
|
|
12
|
+
// "options": { "apiKey": "…" },
|
|
13
|
+
// "models": {
|
|
14
|
+
// "<model id>": { "name", "attachment", "reasoning", "tool_call",
|
|
15
|
+
// "temperature", "limit": { context, input, output },
|
|
16
|
+
// "cost", "interleaved", "modalities", "variants" }
|
|
17
|
+
// }
|
|
18
|
+
// }
|
|
19
|
+
// }
|
|
20
|
+
//
|
|
21
|
+
// The builders here are pure; the CLI decides `npm` (from the local models.dev
|
|
22
|
+
// cache) and performs the file I/O.
|
|
23
|
+
|
|
24
|
+
/** Validate the pieces OpenCode needs from a provider entry. */
|
|
25
|
+
export function providerError(entry) {
|
|
26
|
+
if (!entry || typeof entry !== 'object') return 'provider entry 必须是对象'
|
|
27
|
+
if (typeof entry.api !== 'string' || entry.api.trim() === '') return 'api(Base URL)不能为空'
|
|
28
|
+
try {
|
|
29
|
+
const url = new URL(entry.api)
|
|
30
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return 'api 必须是 http(s)'
|
|
31
|
+
} catch {
|
|
32
|
+
return 'api 不是合法 URL'
|
|
33
|
+
}
|
|
34
|
+
if (!entry.models || typeof entry.models !== 'object' || Object.keys(entry.models).length === 0) return '至少需要一个模型'
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build the OpenCode provider entry for one preset.
|
|
40
|
+
* @param preset - catalog preset (`id`, `name`, `baseURL`, `models`).
|
|
41
|
+
* @param options - `{ key?, apiKey?, npm?, modelIds? }`; `npm` is set by the
|
|
42
|
+
* caller when OpenCode does not ship the route in its models.dev catalog.
|
|
43
|
+
* @returns a `{ [route]: provider }` object ready to merge into the config.
|
|
44
|
+
*/
|
|
45
|
+
export function buildOpencodeProvider(preset, options = {}) {
|
|
46
|
+
const apiKey = typeof options.key === 'string' && options.key.trim() !== '' ? options.key.trim() : options.apiKey
|
|
47
|
+
const wanted = Array.isArray(options.modelIds) && options.modelIds.length > 0 ? new Set(options.modelIds) : undefined
|
|
48
|
+
const models = {}
|
|
49
|
+
for (const model of preset.models) {
|
|
50
|
+
if (wanted !== undefined && !wanted.has(model.id)) continue
|
|
51
|
+
const config = {
|
|
52
|
+
name: model.name ?? model.id,
|
|
53
|
+
attachment: Array.isArray(model.input) && model.input.includes('image'),
|
|
54
|
+
reasoning: options.reasoning ?? Boolean(model.reasoningEfforts),
|
|
55
|
+
temperature: true,
|
|
56
|
+
tool_call: true,
|
|
57
|
+
}
|
|
58
|
+
if (typeof model.contextWindow === 'number') {
|
|
59
|
+
config.limit = { context: model.contextWindow, input: model.contextWindow }
|
|
60
|
+
if (typeof model.maxTokens === 'number') config.limit.output = model.maxTokens
|
|
61
|
+
}
|
|
62
|
+
models[model.id] = config
|
|
63
|
+
}
|
|
64
|
+
const provider = { name: preset.name }
|
|
65
|
+
if (options.npm) provider.npm = options.npm
|
|
66
|
+
provider.api = preset.baseURL
|
|
67
|
+
if (apiKey !== undefined) provider.options = { apiKey }
|
|
68
|
+
provider.models = models
|
|
69
|
+
return { [preset.id]: provider }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Merge provider entries into an OpenCode config, replacing same-route models
|
|
74
|
+
* in place while keeping every other key (including user edits on fields this
|
|
75
|
+
* hub does not own).
|
|
76
|
+
* @param config - parsed OpenCode config.
|
|
77
|
+
* @param entries - `{ [route]: provider }` from {@link buildOpencodeProvider}.
|
|
78
|
+
* @returns a new config object.
|
|
79
|
+
*/
|
|
80
|
+
export function mergeOpencodeConfig(config, entries) {
|
|
81
|
+
const next = config && typeof config === 'object' ? JSON.parse(JSON.stringify(config)) : {}
|
|
82
|
+
next.provider = next.provider && typeof next.provider === 'object' ? next.provider : {}
|
|
83
|
+
for (const [route, provider] of Object.entries(entries)) {
|
|
84
|
+
const existing = next.provider[route] && typeof next.provider[route] === 'object' ? next.provider[route] : {}
|
|
85
|
+
const merged = { ...existing, ...provider }
|
|
86
|
+
merged.options = { ...(existing.options ?? {}), ...(provider.options ?? {}) }
|
|
87
|
+
if (existing.models || provider.models) {
|
|
88
|
+
merged.models = { ...(existing.models ?? {}) }
|
|
89
|
+
for (const [id, model] of Object.entries(provider.models ?? {})) {
|
|
90
|
+
merged.models[id] = { ...(existing.models?.[id] ?? {}), ...model }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
next.provider[route] = merged
|
|
94
|
+
}
|
|
95
|
+
return next
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Remove one provider route; returns a new config. */
|
|
99
|
+
export function removeOpencodeProvider(config, route) {
|
|
100
|
+
const next = config && typeof config === 'object' ? JSON.parse(JSON.stringify(config)) : {}
|
|
101
|
+
if (next.provider && typeof next.provider === 'object') delete next.provider[route]
|
|
102
|
+
return next
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Drop model ids that left the catalog for every preset the config already
|
|
107
|
+
* has (matched by api URL), keeping routes and models this hub does not own.
|
|
108
|
+
* @returns `{ config, pruned }`.
|
|
109
|
+
*/
|
|
110
|
+
export function pruneOpencodeConfig(config, presets) {
|
|
111
|
+
const next = config && typeof config === 'object' ? JSON.parse(JSON.stringify(config)) : {}
|
|
112
|
+
let pruned = 0
|
|
113
|
+
for (const preset of presets) {
|
|
114
|
+
const provider = next.provider?.[preset.id]
|
|
115
|
+
if (!provider || provider.api !== preset.baseURL || !provider.models) continue
|
|
116
|
+
const ids = new Set(preset.models.map((model) => model.id))
|
|
117
|
+
for (const id of Object.keys(provider.models)) {
|
|
118
|
+
if (!ids.has(id)) {
|
|
119
|
+
delete provider.models[id]
|
|
120
|
+
pruned += 1
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { config: next, pruned }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Which catalog presets already exist in an OpenCode config (by route id). */
|
|
128
|
+
export function installedPresets(config, presets) {
|
|
129
|
+
const routes = new Map()
|
|
130
|
+
for (const preset of presets) {
|
|
131
|
+
const provider = config?.provider?.[preset.id]
|
|
132
|
+
if (provider && typeof provider === 'object') {
|
|
133
|
+
routes.set(preset.id, Object.keys(provider.models ?? {}).length)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return routes
|
|
137
|
+
}
|
|
@@ -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
|
}
|