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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  为 DSH 扩大可用的服务商:**选一个服务商,粘贴 API Key,点启用**——插件把官方 `llm-pi-ai` 路由与凭据一次性写好,立即生效、无需重启、无需手改 `settings.yaml`。
6
6
 
7
- 内置 23 家预设(OpenAI 兼容协议 + Anthropic Messages 协议),另有「自定义服务商」入口可接入任意 OpenAI 兼容端点;DSH 已内置的服务商默认折叠、不重复展示。
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(OpenAI 兼容)、xAI Grok、Mistral、Groq、Together、Fireworks、DeepInfra、Novita、硅基流动、月之暗面 Kimi(国内/国际)、智谱 GLM(国内)、Z.ai GLM(国际)、MiniMax、阿里云百炼 Qwen、百度千帆、腾讯混元、火山方舟(豆包)、**StepFun Step Plan(国内/国际)**。
45
+ OpenAI、OpenRouter、Anthropic、Google GeminixAI 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`)没有内置条目,因此本插件提供该预设**;其余与本插件重合的服务商(openai、openrouter 等 13 个)由 DSH 原生目录覆盖,默认折叠在「显示 DSH 已内置」开关后。已配置过的 Key(包括来自环境变量的)会被识别,留空输入框即可直接启用。
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
- 23 built-in presets (OpenAI-compatible and Anthropic Messages protocols) plus a custom-endpoint form for any OpenAI-compatible gateway. Presets your target app already ships natively are collapsed by default, never deleted.
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: api === 'openai-completions' ? { ...OPENAI_COMPAT, ...(input.compat ?? {}) } : undefined,
157
+ compat,
142
158
  models,
143
159
  })
144
160
  }