dsh-model 0.18.1 → 0.19.1

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.en.md CHANGED
@@ -52,12 +52,14 @@ On a dsh-vps server, use `sudo dsh-model setup`. On Windows, run it in a normal
52
52
  |---|---|
53
53
  | **Endpoint** | Local and public addresses, each with a Copy button |
54
54
  | **Sources** | One switch per provider: on connects (and signs in if needed), off pauses and keeps the sign-in. Each row is tagged Free / Free · limited / Paid and shows plan usage or remaining credits |
55
- | **Models** | Pick which models of each source appear in dsh (5 suggested per source by default); the rest stay available through the endpoint |
55
+ | **Models** | Pick which models of each source appear in dsh (5 suggested per source by default); the rest stay available through the endpoint; a "Test" button per model |
56
+ | **Auto switch** | When a model is offered by several sources, dsh gets an "Auto switch (multiple sources)" group that moves to another source when one is rate-limited or failing |
57
+ | **Recent failures** | Failed requests in the last 24 hours: which model, why, and what to do (no conversation content kept) |
56
58
  | **API keys** | OpenAI-style, one per device. Requests, success rate, latency and speed over 24 hours; copy, rotate, revoke |
57
59
  | **Model stats** | Success rate, latency and speed per model |
58
60
  | **About** | Current and latest npm version; one-click Update and Uninstall (on a VPS these need root, so the commands are shown instead) |
59
61
 
60
- If the service is not set up on this machine yet, the page says what is missing and offers a "Set up now" button, so you can also start by installing dsh-model from the dsh plugin marketplace and finish from this page.
62
+ If the service is not set up on this machine yet, the page says what is missing and offers a "Set up now" button with live progress. A "Getting started" guide appears on a fresh install; a banner shows when the plugin was updated but dsh not restarted (one-click restart on dsh-vps); a low-quota notice appears in the corner of any dsh page. so you can also start by installing dsh-model from the dsh plugin marketplace and finish from this page.
61
63
 
62
64
  dsh-model does every sign-in itself. No provider CLI is installed and the server needs no browser:
63
65
 
@@ -74,6 +76,10 @@ If a source is signed in but has no usable plan (say, a Kimi account without Kim
74
76
  |---|---|---|
75
77
  | WorkBuddy / WorkBuddy AI | Free | Credits come with the account |
76
78
  | Ollama / LM Studio | Free | Models running on this machine; they appear once installed and started, no sign-in |
79
+ | Gemini (Google AI Studio), Groq | Free · limited | Paste a free API key; daily / per-minute limits apply |
80
+ | OpenRouter | Free · limited | `:free` models are free (about 50 requests a day) |
81
+ | SiliconFlow | Free · limited | Some small models are free after identity verification |
82
+ | DeepSeek official, any OpenAI-compatible service | Paid | Paste an API key (and a URL for other services) |
77
83
  | Codex | Paid | ChatGPT Plus / Pro |
78
84
  | Kimi | Paid | Kimi Code plan |
79
85
  | Grok | Paid | SuperGrok / X Premium+ |
@@ -92,6 +98,7 @@ dsh-model only connects; it provides no quota of its own.
92
98
  | `sources` | Sources: switch, cost, sign-in state, usage |
93
99
  | `source enable\|disable\|logout <source>` | Turn on (signing in if needed) / off / sign out |
94
100
  | `key list\|add\|revoke\|rotate <name>` | Access keys, one per device |
101
+ | `failures` | Failed requests in the last 24 hours, with advice |
95
102
  | `stats` | Success rate, latency and speed by key, source and model |
96
103
  | `status` / `doctor [--e2e]` | Overview / health checks |
97
104
  | `models [sync]` | List models / sync them to dsh |
package/README.md CHANGED
@@ -52,12 +52,14 @@ dsh-model setup
52
52
  |---|---|
53
53
  | **访问地址** | 本机地址和外网地址,各带「复制」按钮 |
54
54
  | **来源** | 每家一个开关:打开就接入(没登录会弹出登录框),关闭只停用、保留登录。每行标出「免费 / 免费·额度少 / 需付费」,下面显示套餐用量或剩余积分 |
55
- | **选模型** | 每个来源选哪些模型显示在 dsh 里,默认每家推荐 5 个,其余的经统一地址照样可用 |
55
+ | **选模型** | 每个来源选哪些模型显示在 dsh 里,默认每家推荐 5 个,其余的经统一地址照样可用;每个模型旁边有「测一下」 |
56
+ | **自动切换** | 同一个模型在几个来源都有时,dsh 里多一组「自动切换(多来源)」,一个来源限流或出错就自动换另一个 |
57
+ | **最近失败** | 最近 24 小时失败的请求:哪个模型、为什么、该怎么办(不记录对话内容) |
56
58
  | **API key** | OpenAI 格式,每台设备一把。显示 24 小时内的请求数、成功率、延迟和速度,可复制、轮换、吊销 |
57
59
  | **模型统计** | 按模型显示成功率、延迟和速度 |
58
60
  | **关于** | 当前版本和 npm 上的最新版本;「更新」「卸载」一键完成(VPS 上要 root,这里给出命令) |
59
61
 
60
- 还没在这台机器上装好服务时,这一页会说明缺什么,并提供「一键安装」按钮。所以也可以先从 dsh 的插件市场装 dsh-model,再在这一页里点安装。
62
+ 还没在这台机器上装好服务时,这一页会说明缺什么,并提供「一键安装」按钮,安装过程实时显示。刚装好时有「开始使用」向导;插件更新后还没重启 dsh 时页面顶部会提示(dsh-vps 上可一键重启);额度快用完时,dsh 任何页面的右下角都会提醒。所以也可以先从 dsh 的插件市场装 dsh-model,再在这一页里点安装。
61
63
 
62
64
  登录全由 dsh-model 自己完成,服务器上不装各家的 CLI,也不需要浏览器:
63
65
 
@@ -74,6 +76,10 @@ dsh-model setup
74
76
  |---|---|---|
75
77
  | WorkBuddy / WorkBuddy AI | 免费 | 账号自带积分 |
76
78
  | Ollama / LM Studio | 免费 | 本机运行的模型。装好并启动后自动出现,不用登录 |
79
+ | Gemini(Google AI Studio)、Groq | 免费·额度少 | 填免费 API key 即可,每天 / 每分钟有上限 |
80
+ | OpenRouter | 免费·额度少 | 名字带 `:free` 的模型免费(每天约 50 次) |
81
+ | 硅基流动 | 免费·额度少 | 实名认证后部分小模型免费 |
82
+ | DeepSeek 官方、其他 OpenAI 兼容服务 | 需付费 | 填 API key(其他服务还要填地址) |
77
83
  | Codex | 需付费 | ChatGPT Plus / Pro |
78
84
  | Kimi | 需付费 | Kimi Code 套餐 |
79
85
  | Grok | 需付费 | SuperGrok / X Premium+ |
@@ -92,6 +98,7 @@ dsh-model 只负责转接,不提供额度。
92
98
  | `sources` | 来源列表:开关、费用、登录状态、用量 |
93
99
  | `source enable\|disable\|logout <来源>` | 打开(需要时登录)/ 关闭 / 退出登录 |
94
100
  | `key list\|add\|revoke\|rotate <名称>` | 访问 key,每台设备一把 |
101
+ | `failures` | 最近 24 小时失败的请求和建议 |
95
102
  | `stats` | 按 key、来源、模型的成功率、延迟和速度 |
96
103
  | `status` / `doctor [--e2e]` | 总览 / 逐项自检 |
97
104
  | `models [sync]` | 列出模型 / 同步到 dsh |
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ import { supervise } from './service/windows.js';
14
14
  import { opencode } from './commands/opencode.js';
15
15
  import { workbuddy } from './commands/workbuddy.js';
16
16
  import { bridge } from './commands/bridge.js';
17
- import { enableInteractive, source, sources, stats } from './commands/sources.js';
17
+ import { enableInteractive, failures, source, sources, stats } from './commands/sources.js';
18
18
  import { createContext } from './context.js';
19
19
  import { isDshModelError } from './errors.js';
20
20
  import { L, initLang } from './i18n.js';
@@ -68,6 +68,7 @@ function help() {
68
68
  打开(未登录就打印链接登录)/ 关闭(保留登录)/ 退出登录
69
69
  login <来源> / logout <来源> 同 source enable / source logout
70
70
  stats 按 key / 来源 / 模型的成功率、延迟、速度
71
+ failures 最近 24 小时失败的请求,以及该怎么办
71
72
  status 一屏总览
72
73
  doctor [--e2e] [--all] [--model M] 逐项自检;--e2e 实测流式与工具调用
73
74
  connect-dsh [--dry-run] [--force] 写入 / 刷新 dsh 配置
@@ -99,6 +100,7 @@ Usage: dsh-model <command> [options]
99
100
  Turn on (prints a login link if needed) / off (keeps sign-in) / sign out
100
101
  login <source> / logout <source> Same as source enable / source logout
101
102
  stats Success rate, latency and speed by key / source / model
103
+ failures Failed requests in the last 24 hours, and what to do
102
104
  status Overview
103
105
  doctor [--e2e] [--all] [--model M] Health checks; --e2e tests streaming and tool calls
104
106
  connect-dsh [--dry-run] [--force] Write / refresh dsh config
@@ -167,6 +169,8 @@ export async function main(argv) {
167
169
  return await source(ctx, a1, a2, { acceptRisk: o['accept-risk'] });
168
170
  case 'stats':
169
171
  return await stats(ctx);
172
+ case 'failures':
173
+ return await failures(ctx);
170
174
  case 'status':
171
175
  return await status(ctx);
172
176
  case 'doctor':
@@ -129,6 +129,11 @@ export async function adminRun(ctx) {
129
129
  const req = await takeAdminRequest(ctx);
130
130
  if (!req)
131
131
  return 0;
132
+ if (req.action === 'restart-dsh') {
133
+ // dsh-vps 的 dsh 由 dsh-gate 管着:重启它(会断开当前会话,页面会自己重连)
134
+ const r = await run('systemctl', ['restart', 'dsh-gate']);
135
+ return r.code;
136
+ }
132
137
  const log = selfJobLog(ctx, req.action);
133
138
  const out = openSync(log, 'w', 0o600);
134
139
  if (ctx.owner)
@@ -194,3 +194,22 @@ export async function stats(ctx) {
194
194
  info(dim(L('还没有请求记录', 'No requests recorded yet')));
195
195
  return 0;
196
196
  }
197
+ /** dsh-model failures:最近 24 小时失败的请求和建议 */
198
+ export async function failures(ctx) {
199
+ const list = await control(ctx, 'GET', '/failures');
200
+ if (isJsonMode()) {
201
+ printJson(list);
202
+ return 0;
203
+ }
204
+ if (!list.length) {
205
+ ok(L('最近 24 小时没有失败的请求', 'No failed requests in the last 24 hours'));
206
+ return 0;
207
+ }
208
+ for (const f of list) {
209
+ info(`${dim(f.at.replace('T', ' ').slice(0, 19))} ${bold(f.model || '-')} HTTP ${f.status}`);
210
+ info(` ${f.kind === 'rate' || f.kind === 'upstream' ? yellow(f.advice) : red(f.advice)}`);
211
+ if (f.message)
212
+ info(dim(` ${f.message}`));
213
+ }
214
+ return 0;
215
+ }
@@ -9,11 +9,13 @@ import { readFile } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
10
  import { latestVersion, selfJobLog } from '../commands/self.js';
11
11
  import { LOCAL_SERVERS, loadLocalCatalog, refreshLocalCatalog } from '../integrations/local.js';
12
+ import { loadKeyCatalog, refreshKeyCatalogs, removeApiKey, setApiKey } from '../integrations/apikeys.js';
12
13
  import { adminInstalled, adminRequestPath } from '../service/admin.js';
13
14
  import { atomicWrite } from '../util/fs.js';
14
15
  import { lang } from '../i18n.js';
15
16
  import { which } from '../util/exec.js';
16
17
  import { spawnDetached } from '../util/detach.js';
18
+ import { classify, recentFailures } from './failures.js';
17
19
  import { PKG_ROOT, pkgVersion } from '../util/pkg.js';
18
20
  import { randomBytes } from 'node:crypto';
19
21
  import { currentBinary } from '../engine/install.js';
@@ -119,6 +121,9 @@ export class Daemon {
119
121
  }
120
122
  async refreshCatalogs() {
121
123
  let changed = await refreshLocalCatalog(this.ctx.paths.home, this.ctx.owner).catch(() => false);
124
+ const proxy = (await loadAll(this.ctx).catch(() => null))?.config.proxy;
125
+ if (await refreshKeyCatalogs(this.ctx, proxy).catch(() => false))
126
+ changed = true;
122
127
  for (const rt of this.runtimes) {
123
128
  try {
124
129
  if (await refreshCatalog(this.ctx.paths.home, rt, this.ctx.owner))
@@ -177,6 +182,7 @@ export class Daemon {
177
182
  const cats = await loadCatalogs(this.ctx.paths.home);
178
183
  const secrets = await loadSecrets(this.ctx);
179
184
  const local = await loadLocalCatalog(this.ctx.paths.home);
185
+ const keyCat = await loadKeyCatalog(this.ctx.paths.home);
180
186
  // 订阅来源能服务的模型数:管理接口按凭据列模型(只数没停用的)
181
187
  const engineModels = new Map();
182
188
  try {
@@ -222,6 +228,19 @@ export class Daemon {
222
228
  models: c?.signedIn ? c.models.length : 0,
223
229
  };
224
230
  }
231
+ if (def.kind === 'apikey') {
232
+ const has = Boolean(secrets.apikeys?.[def.id]?.key);
233
+ const models = keyCat[def.id]?.models ?? [];
234
+ const free = models.filter((m) => m.free).length;
235
+ return {
236
+ ...base,
237
+ loggedIn: has,
238
+ enabled: has && !disabled.has(def.id),
239
+ ...(has ? { account: def.id === 'custom' ? (secrets.apikeys?.[def.id]?.base ?? '') : redactKey(secrets.apikeys[def.id].key) } : {}),
240
+ ...(has && free ? { detail: L(`其中 ${free} 个免费`, `${free} of them free`) } : {}),
241
+ models: has ? models.length : 0,
242
+ };
243
+ }
225
244
  if (def.kind === 'local') {
226
245
  const st = local[def.id];
227
246
  const srv = LOCAL_SERVERS.find((x) => x.id === def.id);
@@ -264,6 +283,9 @@ export class Daemon {
264
283
  if (!now.loggedIn || !now.models)
265
284
  throw new DshModelError('local_not_running', now.detail ?? L(`${def.label} 没在运行`, `${def.label} is not running`), L(`先装好并启动它:${def.subscribeUrl}`, `Install and start it first: ${def.subscribeUrl}`));
266
285
  }
286
+ if (def.kind === 'apikey' && !state.loggedIn) {
287
+ throw new DshModelError('needs_key', L(`${def.label} 需要先填 API key`, `${def.label} needs an API key first`), def.subscribeUrl ? L(`在 ${def.subscribeUrl} 创建`, `Create one at ${def.subscribeUrl}`) : undefined);
288
+ }
267
289
  if (def.kind === 'opencode' && !state.loggedIn) {
268
290
  throw new DshModelError('needs_key', L('OpenCode Zen 需要 API key:用 opencode/key 提交', 'OpenCode Zen needs an API key: submit it via opencode/key'));
269
291
  }
@@ -308,6 +330,9 @@ export class Daemon {
308
330
  for (const c of credsFor(await m.credentials(), def))
309
331
  await m.deleteCredential(c.name);
310
332
  }
333
+ else if (def.kind === 'apikey') {
334
+ await removeApiKey(this.ctx, def.id);
335
+ }
311
336
  else if (def.kind === 'local') {
312
337
  throw new DshModelError('no_logout', L(`${def.label} 不需要登录;不想用就关掉开关`, `${def.label} has no sign-in; turn the switch off instead`));
313
338
  }
@@ -321,6 +346,17 @@ export class Daemon {
321
346
  }
322
347
  await this.resync();
323
348
  }
349
+ /** 自带 API key 的来源:实测(拉模型列表)通过才保存,并打开它 */
350
+ async setSourceKey(id, key, baseUrl) {
351
+ const def = mustSource(id);
352
+ if (def.kind !== 'apikey')
353
+ throw new DshModelError('not_key_source', L(`${def.label} 不用 API key`, `${def.label} does not take an API key`));
354
+ const all = await loadAll(this.ctx);
355
+ const r = await setApiKey(this.ctx, def.id, key.trim(), baseUrl, all.config.proxy);
356
+ await this.setSourceDisabled(def.id, false);
357
+ await this.resync();
358
+ return r;
359
+ }
324
360
  async setOpencodeKey(key, opts = {}) {
325
361
  validateKeyShape(key);
326
362
  const all = await loadAll(this.ctx);
@@ -438,7 +474,8 @@ export class Daemon {
438
474
  async usage(refresh = false) {
439
475
  if (refresh)
440
476
  await this.refreshUsage();
441
- return [...this.usageCache.values()];
477
+ // 带上来源名:dsh 里的额度提醒直接显示
478
+ return [...this.usageCache.values()].map((u) => ({ ...u, ...(findSource(u.source) ? { label: findSource(u.source).label } : {}) }));
442
479
  }
443
480
  // —— 登录会话 ——
444
481
  session(id) {
@@ -726,6 +763,50 @@ export class Daemon {
726
763
  log_(`daemon: started ${action}${pid ? ` (pid ${pid})` : ''}`);
727
764
  return { started: true, log };
728
765
  }
766
+ /** 「测一下」:经引擎发一句很短的话,看能不能用、多久回复(会用掉一点点额度) */
767
+ async testModel(model) {
768
+ if (!model || model.length > 200)
769
+ throw new DshModelError('bad_model', L('model 不合法', 'invalid model'));
770
+ const all = await loadAll(this.ctx);
771
+ const started = Date.now();
772
+ let res;
773
+ try {
774
+ res = await fetch(`http://127.0.0.1:${all.config.port}/v1/chat/completions`, {
775
+ method: 'POST',
776
+ headers: { Authorization: `Bearer ${dshKey(all.keys)}`, 'Content-Type': 'application/json' },
777
+ body: JSON.stringify({ model, max_tokens: 256, stream: false, messages: [{ role: 'user', content: L('只回复一个字:好', 'Reply with one word: ok') }] }),
778
+ signal: AbortSignal.timeout(90_000),
779
+ });
780
+ }
781
+ catch (error) {
782
+ return { ok: false, ms: Date.now() - started, advice: L('请求没有完成(超时或网络问题)', 'The request did not finish (timeout or network)'), message: String(error.message ?? error) };
783
+ }
784
+ const ms = Date.now() - started;
785
+ const text = await res.text();
786
+ if (res.ok) {
787
+ let reply = '';
788
+ try {
789
+ const m = JSON.parse(text).choices?.[0]?.message;
790
+ reply = (m?.content || (m?.reasoning_content ? L('(只返回了思考过程)', '(only reasoning returned)') : '')).trim().slice(0, 80);
791
+ }
792
+ catch {
793
+ // 不是 JSON 也算通了
794
+ }
795
+ return { ok: true, ms, reply };
796
+ }
797
+ let message = text.slice(0, 300);
798
+ let code;
799
+ try {
800
+ const e = JSON.parse(text).error ?? {};
801
+ code = e.code;
802
+ message = String(e.last_upstream_error || e.message || message).slice(0, 300);
803
+ }
804
+ catch {
805
+ // 原样
806
+ }
807
+ const c = classify({ status: res.status, ...(code ? { code } : {}), message });
808
+ return { ok: false, ms, status: res.status, advice: c.advice, message };
809
+ }
729
810
  /** 这个来源的全部模型,以及哪些显示在 dsh 里 */
730
811
  async sourceModels(id) {
731
812
  const def = mustSource(id);
@@ -837,6 +918,9 @@ export class Daemon {
837
918
  if (method === 'POST')
838
919
  return ok(await this.setSourceModels(seg[1], b.models === null ? null : b.models));
839
920
  }
921
+ if (method === 'POST' && seg[0] === 'apikey' && seg[1]) {
922
+ return ok(await this.setSourceKey(seg[1], String(b.key ?? ''), typeof b.baseUrl === 'string' ? b.baseUrl : undefined));
923
+ }
840
924
  if (method === 'POST' && path === '/opencode/key') {
841
925
  return ok(await this.setOpencodeKey(String(b.key ?? ''), { skipVerify: b.skipVerify === true }));
842
926
  }
@@ -868,6 +952,10 @@ export class Daemon {
868
952
  return ok(await this.endpoints());
869
953
  if (method === 'GET' && path === '/self')
870
954
  return ok(await this.selfInfo());
955
+ if (method === 'GET' && path === '/failures')
956
+ return ok(await recentFailures(this.ctx.paths.logs));
957
+ if (method === 'POST' && path === '/models/test')
958
+ return ok(await this.testModel(String(b.model ?? '')));
871
959
  if (method === 'POST' && path === '/self/update')
872
960
  return ok(await this.startSelfJob('update'));
873
961
  if (method === 'POST' && path === '/self/uninstall')
@@ -0,0 +1,80 @@
1
+ // daemon/failures.ts — 最近失败的请求(设计 §14.12):从引擎写的 error-*.log 里取出时间、模型、状态码和上游的报错
2
+ //
3
+ // 引擎每个失败请求写一个 auth/logs/error-<路径>-<时间>-<id>.log,里面有完整的请求和响应。
4
+ // 这里只取:时间、模型名、状态码、错误码和错误信息——请求内容(对话)一律不读出来、不往页面送。
5
+ // 再按常见原因归类,给出"该怎么办"。
6
+ import { readdir, readFile, stat } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { L } from '../i18n.js';
9
+ const section = (text, name) => {
10
+ const i = text.indexOf(`=== ${name} ===`);
11
+ if (i < 0)
12
+ return '';
13
+ const rest = text.slice(i + name.length + 8);
14
+ const j = rest.indexOf('\n=== ');
15
+ return j < 0 ? rest : rest.slice(0, j);
16
+ };
17
+ /** 解析一个 error-*.log;看不懂返回 null。导出给测试 */
18
+ export function parseErrorLog(text) {
19
+ const info = section(text, 'REQUEST INFO');
20
+ const at = /^Timestamp:\s*(\S+)/m.exec(info)?.[1];
21
+ // 只用正则取 model 字段,不解析、不保留请求体
22
+ const model = /"model"\s*:\s*"([^"]{1,200})"/.exec(section(text, 'REQUEST BODY'))?.[1] ?? '';
23
+ const resp = section(text, 'RESPONSE');
24
+ const status = Number(/^Status:\s*(\d{3})/m.exec(resp)?.[1]);
25
+ if (!at || !status)
26
+ return null;
27
+ // 响应体是最后一个 JSON:{"error":{"code","message","last_upstream_error"}}
28
+ let code;
29
+ let message = '';
30
+ const body = resp.slice(resp.lastIndexOf('\n{') + 1).trim();
31
+ try {
32
+ const e = JSON.parse(body).error ?? {};
33
+ code = e.code ?? e.type;
34
+ message = String(e.last_upstream_error || e.message || '');
35
+ }
36
+ catch {
37
+ message = body.slice(0, 300);
38
+ }
39
+ return { at, model, source: model.includes('/') ? model.split('/')[0] : '', status, ...(code ? { code } : {}), message: message.replace(/\s+/g, ' ').slice(0, 300) };
40
+ }
41
+ /** 按常见原因归类,给出该怎么办 */
42
+ export function classify(f) {
43
+ const m = `${f.code ?? ''} ${f.message}`.toLowerCase();
44
+ if (f.status === 429 || /cooldown|rate|频繁|too many/.test(m)) {
45
+ return { kind: 'rate', advice: L('被限流:请求太频繁。等一会儿再试,或换一个来源的同款模型', 'Rate limited: too many requests. Wait a bit, or use the same model from another source') };
46
+ }
47
+ if (f.status === 402 || /payment|insufficient|quota|balance|credit|余额|额度|subscription|access_terminated/.test(m)) {
48
+ return { kind: 'quota', advice: L('额度用完或没有可用套餐:看一下这个来源的用量,或换一个来源', 'Out of quota or no usable plan: check this source\'s usage, or switch source') };
49
+ }
50
+ if (f.status === 401 || /unauthor|expired|invalid.*(key|token)|登录/.test(m)) {
51
+ return { kind: 'auth', advice: L('登录或 key 失效:在来源里重新登录,或换一把 key', 'Sign-in or key no longer valid: sign in again, or change the key') };
52
+ }
53
+ if (f.status === 404 || /model_not_found|unknown provider|not found|not supported/.test(m)) {
54
+ return { kind: 'model', advice: L('模型不存在或已下线:打开来源列表刷新一下,或换个模型', 'Model missing or retired: refresh the source list, or pick another model') };
55
+ }
56
+ if (f.status >= 500 || /timeout|timed out|econn|network|unavailable/.test(m)) {
57
+ return { kind: 'upstream', advice: L('上游出错或网络不通:稍后再试;如果一直这样,检查出站代理', 'Upstream error or network problem: try again later; if it persists, check the outbound proxy') };
58
+ }
59
+ return { kind: 'other', advice: L('看下面的原始报错', 'See the raw error below') };
60
+ }
61
+ /** 最近 24 小时的失败,新的在前,最多 limit 条 */
62
+ export async function recentFailures(logsDir, limit = 20, sinceMs = 24 * 60 * 60 * 1000) {
63
+ let names = [];
64
+ try {
65
+ names = (await readdir(logsDir)).filter((n) => /^error-.*\.log$/.test(n));
66
+ }
67
+ catch {
68
+ return [];
69
+ }
70
+ const since = Date.now() - sinceMs;
71
+ const withTime = await Promise.all(names.map(async (n) => ({ n, t: (await stat(join(logsDir, n)).catch(() => null))?.mtimeMs ?? 0 })));
72
+ const recent = withTime.filter((x) => x.t >= since).sort((a, b) => b.t - a.t).slice(0, limit);
73
+ const out = [];
74
+ for (const { n } of recent) {
75
+ const p = parseErrorLog(await readFile(join(logsDir, n), 'utf8').catch(() => ''));
76
+ if (p)
77
+ out.push({ ...p, ...classify(p) });
78
+ }
79
+ return out;
80
+ }
@@ -12,7 +12,7 @@ import { readFile } from 'node:fs/promises';
12
12
  import { join } from 'node:path';
13
13
  const TIMEOUT_MS = 15_000;
14
14
  /** 这几家暂不支持(见设计 §14.6) */
15
- export const USAGE_UNSUPPORTED = new Set(['antigravity', 'devin', 'meta', 'opencode', 'ollama', 'lmstudio']);
15
+ export const USAGE_UNSUPPORTED = new Set(['antigravity', 'devin', 'meta', 'opencode', 'ollama', 'lmstudio', 'gemini', 'groq', 'openrouter', 'siliconflow', 'deepseek', 'custom']);
16
16
  async function getJson(url, headers) {
17
17
  const res = await fetch(url, { headers: { Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS) });
18
18
  const text = await res.text();
package/dist/dsh/cli.js CHANGED
@@ -5,7 +5,7 @@ import { join, win32 } from 'node:path';
5
5
  import { VPS_ROOT } from '../context.js';
6
6
  import { DshModelError } from '../errors.js';
7
7
  import { L } from '../i18n.js';
8
- import { run, runInherit, which } from '../util/exec.js';
8
+ import { run, runTee, which } from '../util/exec.js';
9
9
  import { exists } from '../util/fs.js';
10
10
  const MAC_APPS = ['/Applications/DeepSeek Harness.app', join(homedir(), 'Applications/DeepSeek Harness.app')];
11
11
  /** Windows 上 DeepSeek Harness 桌面版可能装在的目录 */
@@ -109,10 +109,14 @@ async function profileStoreDir(ctx, profile) {
109
109
  }
110
110
  /** dsh plugin --profile <p> <pnpm-args...>,输出直接给用户看(pnpm 进度) */
111
111
  export async function dshPlugin(ctx, profile, pnpmArgs, proxy) {
112
+ return (await dshPluginTee(ctx, profile, pnpmArgs, proxy)).code;
113
+ }
114
+ /** 同 dshPlugin,另外返回输出(安装失败时要认出原因) */
115
+ export async function dshPluginTee(ctx, profile, pnpmArgs, proxy) {
112
116
  const cli = await locateDshCli(ctx);
113
117
  const store = await profileStoreDir(ctx, profile);
114
118
  const w = wrap(ctx, cli, ['plugin', '--profile', profile, ...pnpmArgs, ...(store ? [`--config.store-dir=${store}`] : [])], proxy);
115
- return runInherit(w.cmd, w.args, { env: { ...dshEnv(ctx, proxy), ...cli.env } });
119
+ return runTee(w.cmd, w.args, { env: { ...dshEnv(ctx, proxy), ...cli.env } });
116
120
  }
117
121
  /** profile 的 package.json 里有没有这个依赖(有就是用户或 dsh 已经装了) */
118
122
  export async function profileHasDependency(ctx, profile, name) {
@@ -7,6 +7,7 @@ import { bridgeConfigPath, loadCatalogs } from '../bridge/runtime.js';
7
7
  import { loadSecrets } from '../secrets.js';
8
8
  import { readJson } from '../util/fs.js';
9
9
  import { LOCAL_SERVERS, loadLocalCatalog } from '../integrations/local.js';
10
+ import { keyProvider, loadKeyCatalog } from '../integrations/apikeys.js';
10
11
  export const OPENCODE_BASE = 'https://opencode.ai/zen/v1';
11
12
  export const OPENCODE_PREFIX = 'opencode';
12
13
  export function opencodeModelsPath(ctx) {
@@ -68,6 +69,21 @@ export async function loadCompatUpstreams(ctx) {
68
69
  });
69
70
  }
70
71
  }
72
+ // 自带 API key 的来源:有 key、有模型、没被关掉就接进来(走出站代理,不 direct)
73
+ const keyCat = await loadKeyCatalog(ctx.paths.home);
74
+ for (const [id, s] of Object.entries(secrets.apikeys ?? {})) {
75
+ const p = keyProvider(id);
76
+ const models = keyCat[id]?.models ?? [];
77
+ if (!p || !models.length || disabled.has(id))
78
+ continue;
79
+ out.push({
80
+ name: id,
81
+ label: p.label,
82
+ baseUrl: s.base ?? p.base,
83
+ apiKey: s.key,
84
+ models: models.map((m) => ({ name: m.id, alias: `${id}/${m.id}`, displayName: `${p.label} · ${m.id}`, dshName: m.free ? `${m.id} · Free` : m.id, ...(m.free ? { rate: 0 } : {}), group: id, groupLabel: p.label })),
85
+ });
86
+ }
71
87
  // 本机模型服务(Ollama、LM Studio):在运行、有模型、没被关掉就接进来
72
88
  const local = await loadLocalCatalog(ctx.paths.home);
73
89
  for (const s of LOCAL_SERVERS) {
@@ -83,7 +99,41 @@ export async function loadCompatUpstreams(ctx) {
83
99
  models: st.models.map((id) => ({ name: id, alias: `${s.id}/${id}`, displayName: `${s.label} · ${id}`, dshName: `${id} · 本地免费`, rate: 0, group: s.id, groupLabel: `${s.label}(本地)` })),
84
100
  });
85
101
  }
86
- return out;
102
+ return addAutoModels(out);
103
+ }
104
+ export const AUTO_PREFIX = 'auto';
105
+ /**
106
+ * 同名模型自动切换(设计 §14.13):同一个模型名出现在两个以上来源时,在每个来源里再登记一个 auto/<名字>。
107
+ * 引擎把同一个 alias 的几个上游当成候选,一个限流或出错就换下一个(v8.0.13 实测:一个一直 429,请求照样成功)。
108
+ * 订阅来源(引擎原生 OAuth)不参与:计费方式不同,混在一起会让人搞不清用的是哪家。
109
+ */
110
+ export function addAutoModels(upstreams) {
111
+ const where = new Map();
112
+ for (const u of upstreams) {
113
+ for (const name of new Set(u.models.map((m) => m.name)))
114
+ where.set(name, [...(where.get(name) ?? []), u]);
115
+ }
116
+ for (const [name, ups] of where) {
117
+ if (ups.length < 2)
118
+ continue;
119
+ const first = ups[0].models.find((m) => m.name === name);
120
+ const pretty = (first.dshName ?? name).split(' · ')[0];
121
+ const rates = ups.map((u) => u.models.find((m) => m.name === name)?.rate).filter((r) => r !== undefined);
122
+ const sources = ups.map((u) => u.label).join(' / ');
123
+ for (const u of ups) {
124
+ const own = u.models.find((m) => m.name === name);
125
+ u.models.push({
126
+ ...own,
127
+ alias: `${AUTO_PREFIX}/${name}`,
128
+ displayName: `${pretty}(${sources})`,
129
+ dshName: `${pretty} · ${ups.length} 个来源`,
130
+ ...(rates.length ? { rate: Math.min(...rates) } : {}),
131
+ group: AUTO_PREFIX,
132
+ groupLabel: '自动切换(多来源)',
133
+ });
134
+ }
135
+ }
136
+ return upstreams;
87
137
  }
88
138
  /** 渲染成引擎 v8 的 api-keys.openai-compatibility 段 */
89
139
  export function renderCompat(upstreams) {
@@ -0,0 +1,124 @@
1
+ // integrations/apikeys.ts — 自带 API key 的来源(设计 §14.11):Gemini、Groq、OpenRouter、硅基流动、DeepSeek 官方,以及任意 OpenAI 兼容服务
2
+ //
3
+ // 和 OpenCode 一样:key 存 dsh-model 自己的 secrets.json(0600),由引擎作为 openai-compatibility 上游持有;
4
+ // 模型目录取自各家的 GET /models(带 key,能拉到就说明 key 可用,不花 token)。请求走 curl:它认代理,key 经 stdin 传、不进进程参数。
5
+ import { join } from 'node:path';
6
+ import { DshModelError } from '../errors.js';
7
+ import { L } from '../i18n.js';
8
+ import { loadSecrets, saveSecrets } from '../secrets.js';
9
+ import { run } from '../util/exec.js';
10
+ import { readJson, writeJson } from '../util/fs.js';
11
+ export const KEY_PROVIDERS = [
12
+ { id: 'gemini', label: 'Gemini(Google AI Studio)', base: 'https://generativelanguage.googleapis.com/v1beta/openai', keyUrl: 'https://aistudio.google.com/apikey' },
13
+ { id: 'groq', label: 'Groq', base: 'https://api.groq.com/openai/v1', keyUrl: 'https://console.groq.com/keys' },
14
+ { id: 'openrouter', label: 'OpenRouter', base: 'https://openrouter.ai/api/v1', keyUrl: 'https://openrouter.ai/keys' },
15
+ { id: 'siliconflow', label: '硅基流动 SiliconFlow', base: 'https://api.siliconflow.cn/v1', keyUrl: 'https://cloud.siliconflow.cn/account/ak', listQuery: 'type=text&sub_type=chat' },
16
+ { id: 'deepseek', label: 'DeepSeek 官方', base: 'https://api.deepseek.com/v1', keyUrl: 'https://platform.deepseek.com/api_keys' },
17
+ { id: 'custom', label: '其他 OpenAI 兼容服务', base: '', keyUrl: '' },
18
+ ];
19
+ export const keyProvider = (id) => KEY_PROVIDERS.find((p) => p.id === id);
20
+ export const keyCatalogPath = (home) => join(home, 'apikey-models.json');
21
+ export async function loadKeyCatalog(home) {
22
+ return (await readJson(keyCatalogPath(home))) ?? {};
23
+ }
24
+ export function normalizeBase(url) {
25
+ const u = url.trim().replace(/\/+$/, '').replace(/\/chat\/completions$/, '').replace(/\/models$/, '');
26
+ if (!/^https?:\/\/[^/\s]+/i.test(u))
27
+ throw new DshModelError('invalid_base_url', L(`地址不合法:${url}(例:https://api.example.com/v1)`, `Invalid URL: ${url} (e.g. https://api.example.com/v1)`));
28
+ return u;
29
+ }
30
+ /** 解析 GET /models 的返回;导出给测试 */
31
+ export function parseModelList(providerId, body) {
32
+ const data = (body?.data ?? []);
33
+ const out = [];
34
+ for (const m of data) {
35
+ // Gemini 的 OpenAI 兼容接口返回 "models/gemini-2.5-flash",调用时用不带前缀的名字
36
+ const id = String(m.id ?? '').replace(/^models\//, '');
37
+ if (!id)
38
+ continue;
39
+ const pricing = m.pricing;
40
+ const free = id.endsWith(':free') || (pricing ? Number(pricing.prompt) === 0 && Number(pricing.completion) === 0 : false);
41
+ out.push({ id, ...(free ? { free: true } : {}) });
42
+ }
43
+ // OpenRouter 有几百个模型:付费的照样能用,但先把免费的排前面,挑默认时就先挑它们
44
+ return out.sort((a, b) => Number(Boolean(b.free)) - Number(Boolean(a.free)) || a.id.localeCompare(b.id));
45
+ }
46
+ /** 用这把 key 拉模型列表:拉得到就是可用的 key(不花 token) */
47
+ export async function fetchKeyModels(provider, key, base, proxy) {
48
+ const url = `${base}/models${provider.listQuery ? `?${provider.listQuery}` : ''}`;
49
+ const r = await run('curl', ['-s', '-m', '25', '-w', '\n%{http_code}', '-K', '-', ...(proxy ? ['-x', proxy] : []), url], {
50
+ input: `header = "Authorization: Bearer ${key.replace(/"/g, '')}"\n`,
51
+ timeoutMs: 40_000,
52
+ });
53
+ const lines = r.stdout.trimEnd().split('\n');
54
+ const status = Number(lines.pop()) || null;
55
+ const text = lines.join('\n');
56
+ if (status === 401 || status === 403)
57
+ throw new DshModelError('key_rejected', L(`${provider.label} 不认这把 key(HTTP ${status})`, `${provider.label} rejected the key (HTTP ${status})`), provider.keyUrl ? L(`到 ${provider.keyUrl} 重新创建一把`, `Create a new one at ${provider.keyUrl}`) : undefined);
58
+ if (status !== 200) {
59
+ throw new DshModelError('key_check_failed', status ? L(`${provider.label} 返回 HTTP ${status}:${text.slice(0, 160)}`, `${provider.label} returned HTTP ${status}: ${text.slice(0, 160)}`) : L(`连不上 ${base}${proxy ? '' : '(需要代理?在设置里或 dsh-model setup --proxy <地址>)'}`, `Cannot reach ${base}${proxy ? '' : ' (proxy needed? dsh-model setup --proxy <url>)'}`));
60
+ }
61
+ let models = [];
62
+ try {
63
+ models = parseModelList(provider.id, JSON.parse(text));
64
+ }
65
+ catch {
66
+ throw new DshModelError('key_check_failed', L(`${provider.label} 的模型列表看不懂`, `Could not read the ${provider.label} model list`));
67
+ }
68
+ if (!models.length)
69
+ throw new DshModelError('key_no_models', L(`${provider.label} 没有返回任何模型`, `${provider.label} returned no models`));
70
+ return models;
71
+ }
72
+ /** 实测并保存 key、写模型目录 */
73
+ export async function setApiKey(ctx, id, key, baseUrl, proxy) {
74
+ const provider = keyProvider(id);
75
+ if (!provider)
76
+ throw new DshModelError('unknown_source', L(`没有这个来源:${id}`, `No such source: ${id}`));
77
+ if (!key || /\s/.test(key) || key.length < 8)
78
+ throw new DshModelError('invalid_key', L('这看起来不是一把 API key', 'That does not look like an API key'));
79
+ const base = normalizeBase(provider.id === 'custom' ? (baseUrl ?? '') : provider.base);
80
+ const models = await fetchKeyModels(provider, key, base, proxy);
81
+ const secrets = await loadSecrets(ctx);
82
+ secrets.apikeys = { ...secrets.apikeys, [id]: { key, ...(provider.id === 'custom' ? { base } : {}), verifiedAt: new Date().toISOString() } };
83
+ await saveSecrets(ctx, secrets);
84
+ const cat = await loadKeyCatalog(ctx.paths.home);
85
+ cat[id] = { updatedAt: new Date().toISOString(), models };
86
+ await writeJson(keyCatalogPath(ctx.paths.home), cat, { owner: ctx.owner });
87
+ return { models: models.length, free: models.filter((m) => m.free).length };
88
+ }
89
+ export async function removeApiKey(ctx, id) {
90
+ const secrets = await loadSecrets(ctx);
91
+ if (secrets.apikeys?.[id]) {
92
+ delete secrets.apikeys[id];
93
+ await saveSecrets(ctx, secrets);
94
+ }
95
+ const cat = await loadKeyCatalog(ctx.paths.home);
96
+ if (cat[id]) {
97
+ delete cat[id];
98
+ await writeJson(keyCatalogPath(ctx.paths.home), cat, { owner: ctx.owner });
99
+ }
100
+ }
101
+ /** 定期刷新已配置来源的模型目录(模型上新 / 下线);失败的保留上次的。返回是否有变化 */
102
+ export async function refreshKeyCatalogs(ctx, proxy) {
103
+ const secrets = await loadSecrets(ctx);
104
+ const cat = await loadKeyCatalog(ctx.paths.home);
105
+ let changed = false;
106
+ for (const [id, s] of Object.entries(secrets.apikeys ?? {})) {
107
+ const provider = keyProvider(id);
108
+ if (!provider)
109
+ continue;
110
+ try {
111
+ const models = await fetchKeyModels(provider, s.key, s.base ?? provider.base, proxy);
112
+ if (JSON.stringify(models) !== JSON.stringify(cat[id]?.models)) {
113
+ cat[id] = { updatedAt: new Date().toISOString(), models };
114
+ changed = true;
115
+ }
116
+ }
117
+ catch {
118
+ // 网络 / 限流:留着上次的目录
119
+ }
120
+ }
121
+ if (changed)
122
+ await writeJson(keyCatalogPath(ctx.paths.home), cat, { owner: ctx.owner });
123
+ return changed;
124
+ }