dsh-provider-hub 0.2.0 → 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
- 内置 **39 家服务商预设**(OpenAI 兼容 / Anthropic Messages / OpenAI Responses 三种协议),覆盖国际与国内的普通 API 与 **订阅渠道**(百炼 Token Plan、智谱 Coding、Kimi Coding、MiniMax Coding Plan、小米 MiMo、OpenCode Zen 等),另有「自定义服务商」入口可接入任意兼容端点;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
 
@@ -46,6 +46,8 @@ OpenAI、OpenRouter、Anthropic、Google Gemini、xAI Grok、Mistral、Groq、To
46
46
 
47
47
  关于内置:DSH 自带 DeepSeek 官方适配器(`llm-deepseek`),其目录里的 `stepfun` 指向普通付费端点 `api.stepfun.com/v1`——**Step Plan 订阅端点(`step_plan/v1`)没有内置条目,因此本插件提供该预设**;其余与本插件重合的服务商由 DSH 原生目录覆盖的那些默认折叠在「显示 DSH 已内置」开关后。已配置过的 Key(包括来自环境变量的)会被识别,留空输入框即可直接启用。
48
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
+
49
51
  ## WorkBuddy(腾讯)适配
50
52
 
51
53
  同一份预设目录也能写入 WorkBuddy 的本地自定义模型文件(`~/.workbuddy/models.json`,明文 JSON,应用启动时读取):
@@ -66,6 +68,24 @@ node tools/workbuddy.mjs add-custom --model gpt-5.1 --url https://api.openai.com
66
68
  - Key 来源三选一:`--key`、`--key-env NAME`、`--key-from-dsh REF`(复用 DSH 凭据服务里的 Key)
67
69
  - WorkBuddy 的模型按 ID 全局唯一:国内/国际预设共用模型 ID 时,**目录里靠前的一家生效**;带 Key 的 `add` 是显式安装、必定覆盖;`add-all` 不会覆盖已配置 Key 的条目。想换另一家先 `remove <另一家>` 再 `add <目标家> --key ...`
68
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
+
69
89
  ## 目录维护
70
90
 
71
91
  - 模型清单与逐模型兼容开关可从任意权威目录刷新:`node tools/refresh-catalog.mjs --catalog <catalog.json>`(先 `--dry` 审阅);路由级只保留全部模型一致的开关,不一致的由模型自己携带 `compat`
@@ -83,7 +103,11 @@ node tools/workbuddy.mjs add-custom --model gpt-5.1 --url https://api.openai.com
83
103
 
84
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`.
85
105
 
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.
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.
87
111
 
88
112
  ### Install
89
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
+ }
package/core/providers.js CHANGED
@@ -466,10 +466,20 @@ export const PRESETS = [
466
466
  api: "openai-responses",
467
467
  compat: {"supportsStore":false,"supportsDeveloperRole":false,"maxTokensField":"max_tokens"},
468
468
  models: [
469
- { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", contextWindow: 1050000, maxTokens: 128000, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"} },
470
- { id: "kimi-k3", name: "Kimi K3", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: {"max":"max"} },
471
- { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", contextWindow: 1048576, maxTokens: 128000, input: ['text'] },
472
- { id: "muse-spark-1.2-contributor", name: "Muse Spark 1.2 Contributor", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: {"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
469
+ { id: "mimo-v2.6-pro", name: "MiMo-V2.6-Pro", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'] },
470
+ { id: "mimo-v2.6-flash", name: "MiMo-V2.6-Flash", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'] },
471
+ { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", contextWindow: 1050000, maxTokens: 128000, input: ['text', 'image'], reasoningEfforts: {"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"} },
472
+ { id: "kimi-k2.6", name: "Kimi K2.6", contextWindow: 262144, maxTokens: 65536, input: ['text', 'image'] },
473
+ { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", contextWindow: 262144, maxTokens: 262144, input: ['text', 'image'] },
474
+ { id: "minimax-m3", name: "MiniMax-M3", contextWindow: 1000000, maxTokens: 131072, input: ['text', 'image'] },
475
+ { id: "minimax-m2.7", name: "MiniMax-M2.7", contextWindow: 204800, maxTokens: 131072, input: ['text'] },
476
+ { id: "grok-4.6", name: "Grok 4.6", contextWindow: 500000, maxTokens: 500000, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
477
+ { id: "grok-4.7", name: "Grok 4.7", contextWindow: 500000, maxTokens: 500000, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
478
+ { id: "deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", contextWindow: 1000000, maxTokens: 384000, input: ['text', 'image'], reasoningEfforts: {"low":"low","high":"high","max":"max"} },
479
+ { id: "qwen3.8-max", name: "Qwen3.8 Max", contextWindow: 1000000, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","xhigh":"xhigh"} },
480
+ { id: "glm-5.3", name: "GLM-5.3", contextWindow: 1000000, maxTokens: 131072, input: ['text'], reasoningEfforts: {"low":"low","high":"high","max":"max"} },
481
+ { id: "longcat-2.0", name: "LongCat-2.0", contextWindow: 1000000, maxTokens: 131072, input: ['text'] },
482
+ { id: "hy4-preview", name: "Hy4 preview", contextWindow: 1024000, maxTokens: 64000, input: ['text'], reasoningEfforts: {"off":"none","high":"high"} },
473
483
  ],
474
484
  },
475
485
  {
@@ -481,10 +491,18 @@ export const PRESETS = [
481
491
  baseURL: "https://opencode.ai/zen/v1",
482
492
  api: "openai-responses",
483
493
  models: [
494
+ { id: "gpt-6-astra", name: "GPT-6 Astra", contextWindow: 1050000, maxTokens: 128000, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"} },
495
+ { id: "gpt-6-sol", name: "GPT-6 Sol", contextWindow: 1050000, maxTokens: 128000, input: ['text', 'image'], reasoningEfforts: {"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"} },
496
+ { id: "gpt-6-luna", name: "GPT-6 Luna", contextWindow: 1050000, maxTokens: 128000, input: ['text', 'image'], reasoningEfforts: {"off":"none","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"} },
484
497
  { id: "gpt-5.4-pro", name: "GPT-5.4 Pro", contextWindow: 1050000, maxTokens: 128000, input: ['text', 'image'], reasoningEfforts: {"medium":"medium","high":"high","xhigh":"xhigh"} },
485
- { id: "gpt-5.5", name: "GPT-5.5", contextWindow: 1050000, maxTokens: 128000, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
486
498
  { id: "gpt-5.5-pro", name: "GPT-5.5 Pro", contextWindow: 1050000, maxTokens: 128000, input: ['text', 'image'], reasoningEfforts: {"medium":"medium","high":"high","xhigh":"xhigh"} },
487
- { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", contextWindow: 1050000, maxTokens: 128000, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"} },
499
+ { id: "gpt-5.2-codex", name: "GPT-5.2 Codex", contextWindow: 400000, maxTokens: 128000, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
500
+ { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", contextWindow: 200000, maxTokens: 64000, input: ['text', 'image'] },
501
+ { id: "mimo-v2.6-flash-free", name: "MiMo-V2.6-Flash Free", contextWindow: 200000, maxTokens: 32000, input: ['text', 'image'] },
502
+ { id: "mimo-v2-pro-free", name: "MiMo V2 Pro Free", contextWindow: 1048576, maxTokens: 64000, input: ['text'] },
503
+ { id: "kimi-k2.6", name: "Kimi K2.6", contextWindow: 262144, maxTokens: 65536, input: ['text', 'image'] },
504
+ { id: "grok-4.7", name: "Grok 4.7 (30% Off)", contextWindow: 500000, maxTokens: 500000, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
505
+ { id: "glm-5.3-flash", name: "GLM-5.3-Flash", contextWindow: 1000000, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: {"low":"low","high":"high","max":"max"} },
488
506
  ],
489
507
  },
490
508
  {
@@ -561,9 +579,12 @@ export const PRESETS = [
561
579
  api: "openai-completions",
562
580
  compat: {"thinkingFormat":"deepseek","requiresReasoningContentOnAssistantMessages":true},
563
581
  models: [
582
+ { id: "mimo-v2.6-pro", name: "MiMo-V2.6-Pro", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'] },
583
+ { id: "mimo-v2.6-pro-ultraspeed", name: "MiMo-V2.6-Pro-UltraSpeed", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'] },
584
+ { id: "mimo-v2.6-flash", name: "MiMo-V2.6-Flash", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'] },
564
585
  { id: "mimo-v2.5", name: "MiMo-V2.5", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'] },
565
586
  { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextWindow: 1048576, maxTokens: 131072, input: ['text'] },
566
- { id: "mimo-v2.5-pro-ultraspeed", name: "MiMo-V2.5-Pro-UltraSpeed", contextWindow: 1048576, maxTokens: 131072, input: ['text'] },
587
+ { id: "mimo-v2-omni", name: "MiMo-V2-Omni", contextWindow: 262144, maxTokens: 131072, input: ['text', 'image'] },
567
588
  ],
568
589
  },
569
590
  {
@@ -646,6 +667,76 @@ export const PRESETS = [
646
667
  { id: "LongCat-2.0", name: "LongCat 2.0", contextWindow: 1000000, maxTokens: 131072, input: ['text'] },
647
668
  ],
648
669
  },
670
+ {
671
+ id: 'commandcode',
672
+ name: 'Command Code GOAT',
673
+ docs: 'https://commandcode.ai/models',
674
+ keyUrl: 'https://commandcode.ai/models',
675
+ env: 'COMMANDCODE_API_KEY',
676
+ baseURL: 'https://api.commandcode.ai/provider/v1',
677
+ api: 'openai-completions',
678
+ models: [
679
+ { id: "gpt-6-luna", name: "GPT-6 Luna", contextWindow: 1050000, maxTokens: 128000, input: ['text', 'image'], reasoningEfforts: {"off":null,"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"} },
680
+ { id: "xiaomi/mimo-v2.6-flash", name: "MiMo V2.6 Flash", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: {"off":null,"high":"high"} },
681
+ { id: "xiaomi/mimo-v2.6-pro", name: "MiMo V2.6 Pro", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: {"off":null,"high":"high"} },
682
+ { id: "xiaomi/mimo-v2.6-pro-ultraspeed", name: "MiMo V2.6 Pro UltraSpeed", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: {"off":null,"high":"high"} },
683
+ { id: "xai/grok-4.7", name: "Grok 4.7", contextWindow: 500000, maxTokens: 500000, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
684
+ { id: "stepfun/Step-5-Preview", name: "Step 5 Preview", contextWindow: 1024000, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","high":"high"} },
685
+ { id: "z-ai/glm-5.3-flashx", name: "GLM-5.3 FlashX", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: {"low":"low","high":"high","max":"max"} },
686
+ { id: "Qwen/Qwen3.8-Omni-Flash", name: "Qwen 3.8 Omni Flash", contextWindow: 991808, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: {"off":null,"high":"high"} },
687
+ { id: "deepseek/deepseek-v4.1-flash", name: "DeepSeek V4.1 Flash", contextWindow: 1048576, maxTokens: 384000, input: ['text', 'image'], reasoningEfforts: {"off":null,"low":"low","high":"high","xhigh":"xhigh","max":"max"} },
688
+ { id: "inclusionai/ling-3.0-flash-sante:free", name: "Ling 3.0 Flash Sante(免费)", contextWindow: 262144, maxTokens: 32768, input: ['text'], reasoningEfforts: {"off":null,"high":"high"} },
689
+ { id: "google/gemini-3.8-flash", name: "Gemini 3.8 Flash", contextWindow: 1048576, maxTokens: 65536, input: ['text', 'image'], reasoningEfforts: {"minimal":"minimal","low":"low","medium":"medium","high":"high"} },
690
+ { id: "meta/muse-spark-1.3", name: "Muse Spark 1.3", contextWindow: 1048576, maxTokens: 943718, input: ['text', 'image'], reasoningEfforts: {"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
691
+ { id: "meta/muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor", contextWindow: 1048576, maxTokens: 943718, input: ['text', 'image'], reasoningEfforts: {"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
692
+ { id: "Qwen/Qwen3.8-Max-0902", name: "Qwen 3.8 Max 0902", contextWindow: 991808, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: {"off":null,"low":"low","medium":"medium","high":"high","max":"max"} },
693
+ { id: "tencent/hy4-preview", name: "Tencent Hy4 Preview", contextWindow: 1048576, maxTokens: 64000, input: ['text'], reasoningEfforts: {"off":null,"low":"low","high":"high"} },
694
+ { id: "z-ai/glm-5.3-flash", name: "GLM-5.3 Flash", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: {"low":"low","high":"high","max":"max"} },
695
+ { id: "meituan/LongCat-2.0", name: "LongCat 2.0", contextWindow: 1048756, maxTokens: 262144, input: ['text'] },
696
+ { id: "Qwen/Qwen3.8-Flash", name: "Qwen 3.8 Flash", contextWindow: 1000000, maxTokens: 131072, input: ['text', 'image'] },
697
+ { id: "deepseek/deepseek-v4-flash-fast", name: "DeepSeek V4 Flash Fast", contextWindow: 1000000, maxTokens: 131072, input: ['text'] }, // 未匹配到缓存
698
+ { id: "deepseek/deepseek-v4-flash-vision-exp", name: "DeepSeek V4 Flash Vision (exp)", contextWindow: 1048576, maxTokens: 384000, input: ['text', 'image'], reasoningEfforts: {"off":null,"low":"low","high":"high","max":"max"} },
699
+ { id: "zai-org/GLM-5.3", name: "GLM-5.3", contextWindow: 1048576, maxTokens: 131072, input: ['text'], reasoningEfforts: {"low":"low","high":"high","max":"max"} },
700
+ { id: "Qwen/Qwen3.8-27B", name: "Qwen 3.8 27B", contextWindow: 262144, maxTokens: 32768, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","xhigh":"xhigh"} },
701
+ { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro", contextWindow: 1048576, maxTokens: 16384, input: ['text'], reasoningEfforts: {"low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
702
+ { id: "google/gemini-3.7-flash", name: "Gemini 3.7 Flash", contextWindow: 1048576, maxTokens: 65536, input: ['text', 'image'], reasoningEfforts: {"minimal":"minimal","low":"low","medium":"medium","high":"high"} },
703
+ { id: "xai/grok-4.6", name: "Grok 4.6", contextWindow: 500000, maxTokens: 500000, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
704
+ { id: "meta/muse-spark-1.2", name: "Muse Spark 1.2", contextWindow: 1000000, maxTokens: 65536, input: ['text', 'image'], reasoningEfforts: {"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
705
+ { id: "meta/muse-spark-1.2-contributor", name: "Muse Spark 1.2 Contributor", contextWindow: 1000000, maxTokens: 65536, input: ['text', 'image'], reasoningEfforts: {"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
706
+ { id: "Qwen/Qwen3.8-Max", name: "Qwen 3.8 Max", contextWindow: 256000, maxTokens: 131072, input: ['text', 'image'] },
707
+ { id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash", contextWindow: 1048576, maxTokens: 16384, input: ['text'], reasoningEfforts: {"low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
708
+ { id: "thinkingmachines/inkling-small", name: "Inkling Small", contextWindow: 524288, maxTokens: 1048576, input: ['text', 'image'] },
709
+ { id: "Qwen/Qwen3.7-Flash", name: "Qwen 3.7 Flash", contextWindow: 991808, maxTokens: 65536, input: ['text', 'image'], reasoningEfforts: {"off":null,"high":"high"} },
710
+ { id: "poolside/laguna-s-2.1-free", name: "Laguna S 2.1(免费)", contextWindow: 256000, maxTokens: 32768, input: ['text'] },
711
+ { id: "thinkingmachines/inkling", name: "Inkling", contextWindow: 524288, maxTokens: 1048576, input: ['text', 'image'] },
712
+ { id: "moonshotai/Kimi-K3", name: "Kimi K3", contextWindow: 1048576, maxTokens: 131072, input: ['text', 'image'], reasoningEfforts: {"low":"low","high":"high","max":"max"} },
713
+ { id: "gpt-5.6-luna", name: "GPT-5.6 Luna", contextWindow: 1050000, maxTokens: 128000, input: ['text', 'image'], reasoningEfforts: {"off":null,"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"} },
714
+ { id: "gpt-5.6-sol", name: "GPT-5.6 Sol", contextWindow: 1050000, maxTokens: 128000, input: ['text', 'image'], reasoningEfforts: {"off":null,"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"} },
715
+ { id: "xai/grok-4.5", name: "Grok 4.5", contextWindow: 500000, maxTokens: 450000, input: ['text', 'image'], reasoningEfforts: {"low":"low","medium":"medium","high":"high"} },
716
+ { id: "tencent/hy3-paid", name: "Tencent Hy3", contextWindow: 262144, maxTokens: 131072, input: ['text'] }, // 未匹配到缓存
717
+ { id: "zai-org/GLM-5.2-Fast", name: "GLM-5.2 Fast", contextWindow: 1000000, maxTokens: 131072, input: ['text'] },
718
+ { id: "zai-org/GLM-5.2", name: "GLM-5.2", contextWindow: 1048576, maxTokens: 32768, input: ['text'], reasoningEfforts: {"low":"low","medium":"medium","high":"high","xhigh":"xhigh"} },
719
+ { id: "moonshotai/Kimi-K2.7-Code-Highspeed", name: "Kimi K2.7 Code HighSpeed", contextWindow: 262144, maxTokens: 65536, input: ['text', 'image'] },
720
+ { id: "moonshotai/Kimi-K2.7-Code", name: "Kimi K2.7 Code", contextWindow: 262144, maxTokens: 262144, input: ['text', 'image'] },
721
+ { id: "nvidia/nemotron-3-ultra-550b-a55b", name: "Nemotron 3 Ultra", contextWindow: 1000000, maxTokens: 65536, input: ['text'] },
722
+ { id: "MiniMaxAI/MiniMax-M3", name: "MiniMax M3", contextWindow: 524288, maxTokens: 512000, input: ['text', 'image'] },
723
+ { id: "Qwen/Qwen3.7-Plus", name: "Qwen 3.7 Plus", contextWindow: 991808, maxTokens: 65536, input: ['text', 'image'], reasoningEfforts: {"off":null,"high":"high"} },
724
+ { id: "stepfun/Step-3.7-Flash", name: "Step 3.7 Flash", contextWindow: 262144, maxTokens: 256000, input: ['text', 'image'] },
725
+ { id: "xiaomi/mimo-v2.5", name: "MiMo V2.5", contextWindow: 262144, maxTokens: 16384, input: ['text', 'image'] },
726
+ { id: "xiaomi/mimo-v2.5-pro", name: "MiMo V2.5 Pro", contextWindow: 1048576, maxTokens: 16384, input: ['text', 'image'] },
727
+ { id: "Qwen/Qwen3.7-Max", name: "Qwen 3.7 Max", contextWindow: 256000, maxTokens: 65536, input: ['text'] },
728
+ { id: "stepfun/Step-3.5-Flash", name: "Step 3.5 Flash", contextWindow: 262144, maxTokens: 65536, input: ['text'], reasoningEfforts: {"low":"low","medium":"medium","high":"high"} },
729
+ { id: "zai-org/GLM-5.1", name: "GLM-5.1", contextWindow: 202752, maxTokens: 16384, input: ['text'] },
730
+ { id: "MiniMaxAI/MiniMax-M2.7", name: "MiniMax M2.7", contextWindow: 196608, maxTokens: 131072, input: ['text'] },
731
+ { id: "Qwen/Qwen3.6-Max-Preview", name: "Qwen 3.6 Max Preview", contextWindow: 245760, maxTokens: 65536, input: ['text'] },
732
+ { id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus", contextWindow: 991808, maxTokens: 65536, input: ['text', 'image'] },
733
+ { id: "moonshotai/Kimi-K2.6", name: "Kimi K2.6", contextWindow: 262144, maxTokens: 16384, input: ['text', 'image'] },
734
+ { id: "zai-org/GLM-5", name: "GLM-5", contextWindow: 202752, maxTokens: 16384, input: ['text'] },
735
+ { id: "moonshotai/Kimi-K2.5", name: "Kimi K2.5", contextWindow: 262144, maxTokens: 32768, input: ['text', 'image'] },
736
+ { id: "MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5", contextWindow: 196608, maxTokens: 131072, input: ['text'] },
737
+ ],
738
+ },
739
+
649
740
  ]
650
741
  // --- end preset catalog ---
651
742
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-provider-hub",
3
- "version": "0.2.0",
4
- "description": "只填 Key 的服务商预设:为 DSH 一键写入官方 llm-pi-ai 路由与凭据(插件),并可写入腾讯 WorkBuddy 的本地 models.jsonCLI);核心为纯 JS,可被更多应用的外壳复用。",
3
+ "version": "0.3.0",
4
+ "description": "只填 Key 的服务商预设:为 DSH 一键写入官方 llm-pi-ai 路由与凭据(插件),并可写入腾讯 WorkBuddy 的本地 models.json 与 OpenCode 配置(CLI);核心为纯 JS,可被更多应用的外壳复用。",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
@@ -57,6 +57,7 @@
57
57
  }
58
58
  },
59
59
  "bin": {
60
- "provider-hub-workbuddy": "tools/workbuddy.mjs"
60
+ "provider-hub-workbuddy": "tools/workbuddy.mjs",
61
+ "provider-hub-opencode": "tools/opencode.mjs"
61
62
  }
62
63
  }
package/src/client.js CHANGED
@@ -131,19 +131,19 @@ window.__ModuleLoader__.load({
131
131
  title: { fontWeight: 600, fontSize: 14 },
132
132
  hint: { opacity: 0.7, lineHeight: 1.5, marginBottom: 10 },
133
133
  section: { margin: '12px 0 6px', fontWeight: 600, opacity: 0.85 },
134
- row: { display: 'flex', alignItems: 'center', gap: 8, padding: '8px 0', borderTop: '1px solid var(--dsw-alias-border-l3, #f0f0f0)' },
135
- name: { minWidth: 150, fontWeight: 500 },
134
+ row: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', padding: '8px 0', borderTop: '1px solid var(--dsw-alias-border-l3, #f0f0f0)' },
135
+ name: { flex: '1 1 200px', minWidth: 0, fontWeight: 500 },
136
136
  sub: { opacity: 0.6, fontSize: 12 },
137
137
  grow: { flex: 1, minWidth: 0 },
138
- input: { flex: 1, minWidth: 160, padding: '7px 9px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2, #d7dbe0)', background: 'transparent', color: 'inherit', fontSize: 13 },
139
- button: { padding: '7px 14px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2, #d7dbe0)', background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: 13 },
140
- primary: { padding: '7px 16px', borderRadius: 8, border: '1px solid transparent', background: 'var(--dsw-alias-interactive-bg-hover, #2f6fec)', color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 600 },
141
- danger: { padding: '5px 10px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2, #d7dbe0)', background: 'transparent', color: 'var(--dsw-alias-state-error-primary, #c0392b)', cursor: 'pointer', fontSize: 12 },
138
+ input: { flex: '1 1 160px', minWidth: 120, padding: '7px 9px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2, #d7dbe0)', background: 'transparent', color: 'inherit', fontSize: 13 },
139
+ button: { flex: '0 0 auto', whiteSpace: 'nowrap', padding: '7px 14px', borderRadius: 8, border: '1px solid var(--dsw-alias-border-l2, #d7dbe0)', background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: 13 },
140
+ primary: { flex: '0 0 auto', whiteSpace: 'nowrap', padding: '7px 16px', borderRadius: 8, border: '1px solid transparent', background: '#2f6fec', color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 600 },
141
+ danger: { flex: '0 0 auto', whiteSpace: 'nowrap', padding: '5px 10px', borderRadius: 8, border: '1px solid rgba(217, 45, 32, 0.45)', background: 'transparent', color: '#d92d20', cursor: 'pointer', fontSize: 12 },
142
142
  badge: { fontSize: 11, padding: '2px 7px', borderRadius: 999, border: '1px solid var(--dsw-alias-border-l2, #d7dbe0)', opacity: 0.85 },
143
- linkButton: { background: 'none', border: 'none', color: 'var(--dsw-alias-link, #2f6fec)', cursor: 'pointer', fontSize: 12, padding: 0 },
144
- link: { color: 'var(--dsw-alias-link, #2f6fec)', textDecoration: 'none', fontSize: 12 },
145
- error: { color: 'var(--dsw-alias-state-error-primary, #c0392b)', margin: '6px 0' },
146
- notice: { color: 'var(--dsw-alias-state-success-primary, #1d9a4a)', margin: '6px 0' },
143
+ linkButton: { background: 'none', border: 'none', color: '#2f6fec', cursor: 'pointer', fontSize: 12, padding: 0, flex: '0 0 auto', whiteSpace: 'nowrap' },
144
+ link: { color: '#2f6fec', textDecoration: 'none', fontSize: 12 },
145
+ error: { color: '#d92d20', margin: '6px 0' },
146
+ notice: { color: '#1d9a4a', margin: '6px 0' },
147
147
  form: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginTop: 8 },
148
148
  field: { display: 'flex', flexDirection: 'column', gap: 4 },
149
149
  }
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+ // dsh-provider-hub - OpenCode installer.
3
+ //
4
+ // Writes provider presets into OpenCode's config
5
+ // (`~/.config/opencode/opencode.json` or `.jsonc`), so an OpenCode user can
6
+ // use the same curated catalog the DSH plugin and the WorkBuddy CLI serve.
7
+ //
8
+ // Keys go to the provider's `options.apiKey` (the same place the existing
9
+ // custom providers use); models carry the metadata OpenCode renders
10
+ // (name / limit / reasoning / tool_call / attachment). Routes OpenCode does
11
+ // not ship in its models.dev catalog get the right npm SDK automatically,
12
+ // decided from the local models.dev cache (`~/.cache/opencode/models.json`).
13
+ //
14
+ // Usage:
15
+ // node tools/opencode.mjs list
16
+ // node tools/opencode.mjs add <presetId> --key <key> [--models a,b]
17
+ // node tools/opencode.mjs add <presetId> --key-from-dsh STEPFUN_API_KEY
18
+ // node tools/opencode.mjs add-all [--prune]
19
+ // node tools/opencode.mjs remove <presetId>
20
+ // node tools/opencode.mjs add-custom --model <id> --url <base> --key <key> [--name <display>]
21
+ //
22
+ // Global: --file <path> overrides the config location (tests).
23
+
24
+ import fs from 'node:fs'
25
+ import os from 'node:os'
26
+ import path from 'node:path'
27
+
28
+ import { PRESETS, findPreset } from '../core/providers.js'
29
+ import {
30
+ buildOpencodeProvider,
31
+ installedPresets,
32
+ mergeOpencodeConfig,
33
+ pruneOpencodeConfig,
34
+ providerError,
35
+ removeOpencodeProvider,
36
+ } from '../core/adapters/opencode.js'
37
+
38
+ const HOME = os.homedir()
39
+ const CONFIG_DIR = path.join(HOME, '.config', 'opencode')
40
+ const CANDIDATES = [path.join(CONFIG_DIR, 'opencode.jsonc'), path.join(CONFIG_DIR, 'opencode.json')]
41
+ const MODELS_DEV_CACHE = path.join(HOME, '.cache', 'opencode', 'models.json')
42
+ const DSH_CREDENTIALS = path.join(HOME, '.dsh', '.credentials.yaml')
43
+
44
+ const NPM_BY_PROTOCOL = {
45
+ 'openai-completions': '@ai-sdk/openai-compatible',
46
+ 'openai-responses': '@ai-sdk/openai',
47
+ 'anthropic-messages': '@ai-sdk/anthropic',
48
+ }
49
+
50
+ // --- args ---
51
+
52
+ function parseArgs(argv) {
53
+ const args = { _: [], flags: {} }
54
+ for (let i = 0; i < argv.length; i += 1) {
55
+ const token = argv[i]
56
+ if (!token.startsWith('--')) {
57
+ args._.push(token)
58
+ continue
59
+ }
60
+ const name = token.slice(2)
61
+ const next = argv[i + 1]
62
+ if (next === undefined || next.startsWith('--')) args.flags[name] = true
63
+ else {
64
+ args.flags[name] = next
65
+ i += 1
66
+ }
67
+ }
68
+ return args
69
+ }
70
+
71
+ // --- config io (JSONC tolerant) ---
72
+
73
+ function resolveConfigPath(override) {
74
+ if (typeof override === 'string') return override
75
+ for (const candidate of CANDIDATES) if (fs.existsSync(candidate)) return candidate
76
+ return CANDIDATES[1]
77
+ }
78
+
79
+ function stripJsonc(text) {
80
+ let out = ''
81
+ let inString = false
82
+ let inLine = false
83
+ let inBlock = false
84
+ for (let i = 0; i < text.length; i += 1) {
85
+ const char = text[i]
86
+ const next = text[i + 1]
87
+ if (inLine) {
88
+ if (char === '\n') {
89
+ inLine = false
90
+ out += char
91
+ }
92
+ continue
93
+ }
94
+ if (inBlock) {
95
+ if (char === '*' && next === '/') {
96
+ inBlock = false
97
+ i += 1
98
+ }
99
+ continue
100
+ }
101
+ if (inString) {
102
+ out += char
103
+ if (char === '\\') {
104
+ out += next ?? ''
105
+ i += 1
106
+ } else if (char === '"') inString = false
107
+ continue
108
+ }
109
+ if (char === '"') {
110
+ inString = true
111
+ out += char
112
+ continue
113
+ }
114
+ if (char === '/' && next === '/') {
115
+ inLine = true
116
+ i += 1
117
+ continue
118
+ }
119
+ if (char === '/' && next === '*') {
120
+ inBlock = true
121
+ i += 1
122
+ continue
123
+ }
124
+ out += char
125
+ }
126
+ return out.replace(/,(\s*[}\]])/g, '$1')
127
+ }
128
+
129
+ function readConfig(file) {
130
+ let raw
131
+ try {
132
+ raw = fs.readFileSync(file, 'utf8')
133
+ } catch (error) {
134
+ if (error && error.code === 'ENOENT') return { config: {}, existed: false, comments: false }
135
+ throw error
136
+ }
137
+ const comments = /^\s*\/\//m.test(raw) || /\/\*/.test(raw)
138
+ const text = stripJsonc(raw)
139
+ let parsed
140
+ try {
141
+ parsed = text.trim() === '' ? {} : JSON.parse(text)
142
+ } catch (error) {
143
+ throw new Error(`${file} 不是合法 JSON(C):${String((error && error.message) || error)}`)
144
+ }
145
+ return { config: parsed, existed: true, comments }
146
+ }
147
+
148
+ function writeConfig(file, config) {
149
+ fs.mkdirSync(path.dirname(file), { recursive: true })
150
+ if (fs.existsSync(file) && !fs.existsSync(`${file}.orig-backup`)) fs.copyFileSync(file, `${file}.orig-backup`)
151
+ const tmp = `${file}.tmp-${process.pid}`
152
+ fs.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, 'utf8')
153
+ fs.renameSync(tmp, file)
154
+ }
155
+
156
+ // --- helpers ---
157
+
158
+ function modelsDevKnown() {
159
+ try {
160
+ const cache = JSON.parse(fs.readFileSync(MODELS_DEV_CACHE, 'utf8'))
161
+ return new Set(Object.keys(cache))
162
+ } catch {
163
+ return new Set()
164
+ }
165
+ }
166
+
167
+ function npmFor(preset, known) {
168
+ if (known.has(preset.id)) return undefined
169
+ return NPM_BY_PROTOCOL[preset.api]
170
+ }
171
+
172
+ function resolveKey(flags) {
173
+ if (typeof flags.key === 'string') return flags.key.trim()
174
+ if (typeof flags['key-env'] === 'string') {
175
+ const value = process.env[flags['key-env']]
176
+ if (!value) throw new Error(`环境变量 ${flags['key-env']} 为空`)
177
+ return value.trim()
178
+ }
179
+ if (typeof flags['key-from-dsh'] === 'string') {
180
+ const raw = fs.readFileSync(DSH_CREDENTIALS, 'utf8')
181
+ const match = raw.match(new RegExp(`^\\s+${flags['key-from-dsh'].replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:\\s*(\\S+)\\s*$`, 'm'))
182
+ if (!match) throw new Error(`DSH 凭据里没有 ${flags['key-from-dsh']}`)
183
+ return match[1]
184
+ }
185
+ throw new Error('缺少 Key:用 --key <key>、--key-env <NAME> 或 --key-from-dsh <REF>')
186
+ }
187
+
188
+ function report(file, config, extra) {
189
+ const providers = Object.keys(config.provider ?? {})
190
+ console.log(JSON.stringify({ file, providers: providers.length, ...extra }, null, 2))
191
+ console.log('\n注意:OpenCode 在启动时读取配置,改完重启应用(或重新运行 CLI)后生效。')
192
+ }
193
+
194
+ // --- commands ---
195
+
196
+ function commandList(file) {
197
+ const { config } = readConfig(file)
198
+ const installed = installedPresets(config, PRESETS)
199
+ console.log(`OpenCode 配置: ${file}`)
200
+ console.log(`现有 provider: ${Object.keys(config.provider ?? {}).length}\n`)
201
+ for (const preset of PRESETS) {
202
+ const count = installed.get(preset.id)
203
+ console.log(` ${count === undefined ? ' ' : '已装'} ${preset.id.padEnd(28)} ${preset.name} (${preset.models.length} 个模型)`)
204
+ }
205
+ const foreign = Object.keys(config.provider ?? {}).filter((route) => !PRESETS.some((preset) => preset.id === route))
206
+ if (foreign.length) console.log('\n其它 provider(不由本工具管理):', foreign.join(', '))
207
+ }
208
+
209
+ function commandAdd(file, args) {
210
+ const preset = findPreset(args._[1])
211
+ if (!preset) throw new Error(`未知预设:${args._[1]}(用 list 查看可用项)`)
212
+ const key = resolveKey(args.flags)
213
+ const modelIds = typeof args.flags.models === 'string' ? args.flags.models.split(',').map((id) => id.trim()).filter(Boolean) : undefined
214
+ const known = modelsDevKnown()
215
+ const entry = buildOpencodeProvider(preset, { key, modelIds, npm: npmFor(preset, known) })
216
+ const error = providerError(entry[preset.id])
217
+ if (error) throw new Error(error)
218
+ const { config } = readConfig(file)
219
+ const next = mergeOpencodeConfig(config, entry)
220
+ writeConfig(file, next)
221
+ report(file, next, { preset: preset.id, models: Object.keys(entry[preset.id].models).length })
222
+ }
223
+
224
+ function commandAddAll(file, args) {
225
+ const known = modelsDevKnown()
226
+ const { config } = readConfig(file)
227
+ let next = config
228
+ let total = 0
229
+ for (const preset of PRESETS) {
230
+ const entry = buildOpencodeProvider(preset, { npm: npmFor(preset, known) })
231
+ next = mergeOpencodeConfig(next, entry)
232
+ total += Object.keys(entry[preset.id].models).length
233
+ }
234
+ let pruned = 0
235
+ if (args.flags.prune === true) {
236
+ const result = pruneOpencodeConfig(next, PRESETS)
237
+ next = result.config
238
+ pruned = result.pruned
239
+ }
240
+ writeConfig(file, next)
241
+ const keyless = PRESETS.filter((preset) => {
242
+ const provider = next.provider?.[preset.id]
243
+ return provider && !(typeof provider.options?.apiKey === 'string' && provider.options.apiKey !== '')
244
+ }).length
245
+ report(file, next, { presets: PRESETS.length, models: total, pruned, providersWithoutKey: keyless })
246
+ if (keyless > 0) console.log(`\n有 ${keyless} 个 provider 还没填 API Key:跑 add <presetId> --key <key> 单独补(已有 Key 的不受影响)。`)
247
+ }
248
+
249
+ function commandRemove(file, args) {
250
+ const preset = findPreset(args._[1])
251
+ if (!preset) throw new Error(`未知预设:${args._[1]}`)
252
+ const { config } = readConfig(file)
253
+ if (!config.provider?.[preset.id]) throw new Error(`配置里没有 ${preset.id}`)
254
+ const next = removeOpencodeProvider(config, preset.id)
255
+ writeConfig(file, next)
256
+ report(file, next, { removed: preset.id })
257
+ }
258
+
259
+ function commandAddCustom(file, args) {
260
+ const flags = args.flags
261
+ const modelId = typeof flags.model === 'string' ? flags.model.trim() : ''
262
+ if (modelId === '') throw new Error('缺少 --model <模型 ID>')
263
+ const baseURL = typeof flags.url === 'string' ? flags.url.trim() : ''
264
+ const route = typeof flags.route === 'string' && flags.route.trim() !== '' ? flags.route.trim() : modelId.replace(/[^a-z0-9]+/gi, '-').toLowerCase().slice(0, 32)
265
+ const protocol = typeof flags.protocol === 'string' ? flags.protocol : 'openai-completions'
266
+ const key = resolveKey(flags)
267
+ const preset = {
268
+ id: route,
269
+ name: typeof flags.name === 'string' && flags.name.trim() !== '' ? flags.name.trim() : route,
270
+ baseURL,
271
+ api: protocol,
272
+ models: [{ id: modelId, name: typeof flags['model-name'] === 'string' && flags['model-name'].trim() !== '' ? flags['model-name'].trim() : modelId, input: flags['no-vision'] === true ? ['text'] : ['text', 'image'] }],
273
+ }
274
+ const entry = buildOpencodeProvider(preset, {
275
+ key,
276
+ npm: NPM_BY_PROTOCOL[protocol],
277
+ reasoning: flags.reasoning === true ? true : flags['no-reasoning'] === true ? false : undefined,
278
+ })
279
+ const error = providerError(entry[route])
280
+ if (error) throw new Error(error)
281
+ const { config } = readConfig(file)
282
+ const next = mergeOpencodeConfig(config, entry)
283
+ writeConfig(file, next)
284
+ report(file, next, { custom: route, model: modelId })
285
+ }
286
+
287
+ // --- main ---
288
+
289
+ function main() {
290
+ const args = parseArgs(process.argv.slice(2))
291
+ const file = resolveConfigPath(args.flags.file)
292
+ const command = args._[0]
293
+ try {
294
+ if (command === 'list' || command === undefined) commandList(file)
295
+ else if (command === 'add') commandAdd(file, args)
296
+ else if (command === 'add-all') commandAddAll(file, args)
297
+ else if (command === 'remove') commandRemove(file, args)
298
+ else if (command === 'add-custom') commandAddCustom(file, args)
299
+ else throw new Error(`未知命令:${command}(list / add / add-all / remove / add-custom)`)
300
+ } catch (error) {
301
+ console.error(`错误:${String((error && error.message) || error)}`)
302
+ process.exitCode = 1
303
+ }
304
+ }
305
+
306
+ main()