dsh-model 0.21.5 → 0.22.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
@@ -28,8 +28,8 @@ dsh-model turns them into **one address and one kind of key**:
28
28
  - 🔌 **Connect once, use everywhere**: every model shows up in dsh, grouped by source; any other tool uses the same address and key.
29
29
  - 🆓 **Get the most from free tiers**: WorkBuddy credits, free Gemini / Groq / OpenRouter keys and local models; every source is tagged Free / Free · limited / Paid.
30
30
  - 📅 **Daily check-in, credits claimed for you**: WorkBuddy (China), Qoder and Trae are checked in automatically every day.
31
- - 🔁 **Rate limits are not the end**: when a model is offered by several sources, requests move to another one when the first is limited.
32
- - 📊 **Know where you stand**: plan usage, remaining credits, success rate and speed per model, recent failures with advice, and a notice when quota runs low.
31
+ - 🔁 **Rate limits are not the end**: build a combo (say Codex → Kimi → WorkBuddy) and requests move to the next model on a rate limit or used-up quota; a model offered by several sources also switches automatically.
32
+ - 📊 **Know where you stand**: plan usage with reset countdowns, remaining credits and when they expire, success rate and speed per model, recent failures with advice, and a notice when quota runs low.
33
33
  - 🔒 **Secure by default**: a key is always required, one per device, revocable at any time; every sign-in happens on your own machine.
34
34
  - 🖥️ **Laptop or server**: Windows, macOS, Linux; on a VPS (with [dsh-vps](https://github.com/AIcivilization/dsh-vps)) it opens an https address you can use from anywhere.
35
35
  - 🧹 **Installs in one command, uninstalls cleanly**: dsh's config is restored byte for byte.
@@ -57,8 +57,9 @@ On a dsh-vps server, use `sudo dsh-model setup`. On Windows, run it in a normal
57
57
  | Section | What it holds |
58
58
  |---|---|
59
59
  | **Endpoint** | Local and public addresses, each with a Copy button |
60
- | **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 |
60
+ | **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 with reset countdowns, or remaining credits and the next batch to expire; a used-up plan shows when it comes back |
61
61
  | **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 |
62
+ | **Combos** | Put several models in order and it becomes one model in dsh: the first is used, and the next takes over on a rate limit, used-up quota or error. Other tools use the model name `combo/<id>` |
62
63
  | **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 |
63
64
  | **Daily check-in** | WorkBuddy (China), Qoder and Trae are checked in automatically every day (or with one click); the card shows what you got today |
64
65
  | **Recent failures** | Failed requests in the last 24 hours: which model, why, and what to do (no conversation content kept) |
@@ -106,6 +107,7 @@ dsh-model only connects; it provides no quota of its own.
106
107
  | `source enable\|disable\|logout <source>` | Turn on (signing in if needed) / off / sign out |
107
108
  | `key list\|add\|revoke\|rotate <name>` | Access keys, one per device |
108
109
  | `checkin` | Check in now (WorkBuddy China, Qoder, Trae; automatic daily by default) |
110
+ | `combo list\|add <name> <model…>\|remove <id>` | Combos: try several models in order |
109
111
  | `failures` | Failed requests in the last 24 hours, with advice |
110
112
  | `stats` | Success rate, latency and speed by key, source and model |
111
113
  | `status` / `doctor [--e2e]` | Overview / health checks |
package/README.md CHANGED
@@ -28,8 +28,8 @@ dsh-model 把它们合成**一个地址、一种 key**:
28
28
  - 🔌 **一处接入,处处可用**:dsh 里自动出现全部模型,按来源分组;其他软件填上同一个地址和 key 就行。
29
29
  - 🆓 **把免费的用足**:WorkBuddy 积分、Gemini / Groq / OpenRouter 免费 key、本机模型都能接;每个来源标出「免费 / 免费·额度少 / 需付费」。
30
30
  - 📅 **每日签到,自动领积分**:WorkBuddy 国内版、Qoder、Trae 每天自动签到,不用记。
31
- - 🔁 **不怕限流**:同一个模型在几个来源都有时自动切换,一个限流就换另一个。
32
- - 📊 **心里有数**:套餐用量、剩余积分、每个模型的成功率和速度、最近失败的原因和建议;额度快用完时提醒。
31
+ - 🔁 **不怕限流**:自己排一个组合(如 Codex → Kimi → WorkBuddy),一个限流或额度用完就自动换下一个;同一个模型在几个来源都有时也会自动切换。
32
+ - 📊 **心里有数**:套餐用量和重置倒计时、剩余积分和到期时间、每个模型的成功率和速度、最近失败的原因和建议;额度快用完时提醒。
33
33
  - 🔒 **默认安全**:始终要求 key,每台设备一把,可随时吊销;登录全在本机完成,不经过第三方。
34
34
  - 🖥️ **电脑和服务器都行**:Windows、macOS、Linux;装在 VPS 上(配合 [dsh-vps](https://github.com/AIcivilization/dsh-vps))自动开一个 https 地址,电脑、手机随处用。
35
35
  - 🧹 **装得上,卸得净**:一条命令安装;卸载时把 dsh 的配置逐字节还原。
@@ -57,8 +57,9 @@ dsh-model setup
57
57
  | 区块 | 内容 |
58
58
  |---|---|
59
59
  | **访问地址** | 本机地址和外网地址,各带「复制」按钮 |
60
- | **来源** | 每家一个开关:打开就接入(没登录会弹出登录框),关闭只停用、保留登录。每行标出「免费 / 免费·额度少 / 需付费」,下面显示套餐用量或剩余积分 |
60
+ | **来源** | 每家一个开关:打开就接入(没登录会弹出登录框),关闭只停用、保留登录。每行标出「免费 / 免费·额度少 / 需付费」,下面显示套餐用量和重置倒计时,或剩余积分和最近到期的一批;额度用完会标出多久后恢复 |
61
61
  | **选模型** | 每个来源选哪些模型显示在 dsh 里,默认每家推荐 5 个,其余的经统一地址照样可用;每个模型旁边有「测一下」 |
62
+ | **组合切换** | 把几个模型按顺序排成一个组合,在 dsh 里就是一个模型:先用第一个,限流、额度用完或出错就换下一个。其他软件用模型名 `combo/<id>` |
62
63
  | **自动切换** | 同一个模型在几个来源都有时,dsh 里多一组「自动切换(多来源)」,一个来源限流或出错就自动换另一个 |
63
64
  | **每日签到** | WorkBuddy 国内版、Qoder、Trae 每天自动签到(也可一键签到),卡片显示今天领了多少 |
64
65
  | **最近失败** | 最近 24 小时失败的请求:哪个模型、为什么、该怎么办(不记录对话内容) |
@@ -106,6 +107,7 @@ dsh-model 只负责转接,不提供额度。
106
107
  | `source enable\|disable\|logout <来源>` | 打开(需要时登录)/ 关闭 / 退出登录 |
107
108
  | `key list\|add\|revoke\|rotate <名称>` | 访问 key,每台设备一把 |
108
109
  | `checkin` | 现在签到一次(WorkBuddy 国内版、Qoder、Trae;默认每天自动签) |
110
+ | `combo list\|add <名称> <模型…>\|remove <id>` | 组合切换:按顺序试几个模型 |
109
111
  | `failures` | 最近 24 小时失败的请求和建议 |
110
112
  | `stats` | 按 key、来源、模型的成功率、延迟和速度 |
111
113
  | `status` / `doctor [--e2e]` | 总览 / 逐项自检 |
@@ -106,6 +106,28 @@ export function createBridge(options) {
106
106
  return oaiError(res, 404, 'not_found', 'refresh not supported');
107
107
  return json(res, 200, { ok: true, result: await options.onRefresh() });
108
108
  }
109
+ if (url.startsWith('/combo/v1/') && options.onCombo) {
110
+ if (req.method === 'GET' && url === '/combo/v1/models') {
111
+ const ids = (await options.comboIds?.()) ?? [];
112
+ return json(res, 200, { object: 'list', data: ids.map((id) => ({ id, object: 'model', created: 0, owned_by: 'dsh-model-combo' })) });
113
+ }
114
+ if (req.method === 'POST' && url === '/combo/v1/chat/completions') {
115
+ const raw = (await readBody(req)).toString('utf8');
116
+ let id = '';
117
+ try {
118
+ id = String(JSON.parse(raw).model ?? '');
119
+ }
120
+ catch {
121
+ return oaiError(res, 400, 'invalid_json', 'request body is not JSON');
122
+ }
123
+ const controller = new AbortController();
124
+ res.on('close', () => controller.abort());
125
+ if (!(await options.onCombo(id.replace(/^combo\//, ''), raw, res, controller.signal)))
126
+ return oaiError(res, 404, 'no_combo', `no such combo: ${id}`);
127
+ return;
128
+ }
129
+ return oaiError(res, 404, 'not_found', `no such route: ${req.method} ${url}`);
130
+ }
109
131
  const m = /^\/([a-z]+)\/v1\/(models|chat\/completions)$/.exec(url);
110
132
  const variant = m ? options.variants.find((v) => v.key === m[1]) : undefined;
111
133
  if (!m || !variant)
@@ -12,6 +12,16 @@
12
12
  import { appUserAgent, resolveAppVersion } from './app-version.js';
13
13
  import { chatUserAgent, fallbackChatIdentity, resolveChatIdentity } from './client-identity.js';
14
14
  import { PROBE_MAX_TOKENS, PROBE_PROMPT } from './probe.js';
15
+ /** "2026-10-18 01:24:08"(北京时间)→ ISO;认不出就 undefined */
16
+ export function beijingTimeToIso(value) {
17
+ if (typeof value !== 'string')
18
+ return undefined;
19
+ const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/.exec(value.trim());
20
+ if (!m)
21
+ return undefined;
22
+ const t = Date.parse(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}+08:00`);
23
+ return Number.isFinite(t) ? new Date(t).toISOString() : undefined;
24
+ }
15
25
  const CN_CHAT_BASE = 'https://copilot.tencent.com';
16
26
  const CN_BILLING_BASE = 'https://www.codebuddy.cn';
17
27
  const GLOBAL_BASE = 'https://www.workbuddy.ai';
@@ -724,10 +734,12 @@ export class WorkBuddyUpstreamClient {
724
734
  if (remain < 0)
725
735
  remain = 0;
726
736
  total += remain;
737
+ const cycleEndAt = beijingTimeToIso(account['CycleEndTime']);
727
738
  accounts.push({
728
739
  packageName: typeof account['PackageName'] === 'string' ? account['PackageName'] : '(unnamed)',
729
740
  remain,
730
741
  size: size > 0 ? size : numberField('CapacitySize'),
742
+ ...(cycleEndAt ? { cycleEndAt, renews: numberField('RemainCycles') > 0 } : {}),
731
743
  });
732
744
  }
733
745
  return { total, accounts };
package/dist/cli.js CHANGED
@@ -14,6 +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 { combo } from './commands/combo.js';
17
18
  import { checkin, enableInteractive, failures, source, sources, stats } from './commands/sources.js';
18
19
  import { createContext } from './context.js';
19
20
  import { isDshModelError } from './errors.js';
@@ -70,6 +71,7 @@ function help() {
70
71
  stats 按 key / 来源 / 模型的成功率、延迟、速度
71
72
  failures 最近 24 小时失败的请求,以及该怎么办
72
73
  checkin 现在签到一次(WorkBuddy 国内版、Qoder、Trae;默认每天自动签)
74
+ combo [list|add <名称> <模型…>|remove <id>] 组合切换:按顺序试几个模型,一个不行换下一个
73
75
  status 一屏总览
74
76
  doctor [--e2e] [--all] [--model M] 逐项自检;--e2e 实测流式与工具调用
75
77
  connect-dsh [--dry-run] [--force] 写入 / 刷新 dsh 配置
@@ -103,6 +105,7 @@ Usage: dsh-model <command> [options]
103
105
  stats Success rate, latency and speed by key / source / model
104
106
  failures Failed requests in the last 24 hours, and what to do
105
107
  checkin Check in now (WorkBuddy China, Qoder, Trae; automatic daily by default)
108
+ combo [list|add <name> <model…>|remove <id>] Combos: try several models in order, moving on when one fails
106
109
  status Overview
107
110
  doctor [--e2e] [--all] [--model M] Health checks; --e2e tests streaming and tool calls
108
111
  connect-dsh [--dry-run] [--force] Write / refresh dsh config
@@ -131,7 +134,7 @@ export async function main(argv) {
131
134
  fail(String(error.message));
132
135
  return 2;
133
136
  }
134
- const { values: o, positionals: [cmd, a1, a2] } = parsed;
137
+ const { values: o, positionals: [cmd, a1, a2, ...rest] } = parsed;
135
138
  initLang(o.lang);
136
139
  setJsonMode(Boolean(o.json));
137
140
  if (o.version) {
@@ -175,6 +178,8 @@ export async function main(argv) {
175
178
  return await failures(ctx);
176
179
  case 'checkin':
177
180
  return await checkin(ctx);
181
+ case 'combo':
182
+ return await combo(ctx, a1, [a2, ...rest].filter((x) => x !== undefined));
178
183
  case 'status':
179
184
  return await status(ctx);
180
185
  case 'doctor':
package/dist/combo.js ADDED
@@ -0,0 +1,121 @@
1
+ // combo.ts — 自定义组合切换(设计 §14.16)
2
+ //
3
+ // 一个组合 = 按顺序排好的几个模型(任意来源,订阅也行)。在 dsh 和其他软件里它就是一个模型 combo/<id>:
4
+ // 请求先发给第一个;限流、额度用完、出错就换下一个,直到有一个成功。已经开始输出之后不再切换。
5
+ // 引擎把 combo/<id> 转给守护进程(openai-compatibility 上游),守护进程再逐个调引擎里的真实模型。
6
+ import { Readable } from 'node:stream';
7
+ import { DshModelError } from './errors.js';
8
+ import { L } from './i18n.js';
9
+ export const COMBO_PREFIX = 'combo';
10
+ export const MAX_COMBO_MODELS = 8;
11
+ /** 名字 → id:英文数字保留,其余去掉;剩不下就用 c1、c2… */
12
+ export function comboId(name, taken) {
13
+ const base = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
14
+ if (base && !taken.includes(base))
15
+ return base;
16
+ for (let i = 1;; i++) {
17
+ const id = base ? `${base}-${i}` : `c${i}`;
18
+ if (!taken.includes(id))
19
+ return id;
20
+ }
21
+ }
22
+ /** 校验并规整一个组合;available 给了就检查模型都还在 */
23
+ export function normalizeCombo(input, available) {
24
+ const name = String(input.name ?? '').trim();
25
+ if (!name || name.length > 30)
26
+ throw new DshModelError('bad_combo', L('组合要有名字(最多 30 个字)', 'A combo needs a name (up to 30 characters)'));
27
+ const raw = Array.isArray(input.models) ? input.models.map((m) => String(m).trim()).filter(Boolean) : [];
28
+ const models = [...new Set(raw)];
29
+ if (models.length < 2)
30
+ throw new DshModelError('bad_combo', L('组合至少要两个模型', 'A combo needs at least two models'));
31
+ if (models.length > MAX_COMBO_MODELS)
32
+ throw new DshModelError('bad_combo', L(`组合最多 ${MAX_COMBO_MODELS} 个模型`, `A combo holds up to ${MAX_COMBO_MODELS} models`));
33
+ const nested = models.find((m) => m.startsWith(`${COMBO_PREFIX}/`));
34
+ if (nested)
35
+ throw new DshModelError('bad_combo', L(`组合里不能再放组合(${nested})`, `A combo cannot contain another combo (${nested})`));
36
+ if (available) {
37
+ const have = new Set(available);
38
+ const missing = models.filter((m) => !have.has(m));
39
+ if (missing.length)
40
+ throw new DshModelError('bad_combo', L(`这些模型现在用不了:${missing.join('、')}`, `These models are not available now: ${missing.join(', ')}`));
41
+ }
42
+ return { name, models };
43
+ }
44
+ /** 这次失败值得换下一个吗:请求本身写错了(400 且不是额度/长度问题)换了也一样,直接返回 */
45
+ export function shouldFallBack(status, text) {
46
+ if (status !== 400 && status !== 422)
47
+ return true;
48
+ return /quota|rate|limit|credit|context|too long|too many tokens|maximum|exceed|overload|unavailable|not supported|unsupported/i.test(text);
49
+ }
50
+ /** 按顺序试组合里的模型,把第一个成功的响应原样转回客户端 */
51
+ export async function runCombo(run) {
52
+ const { res, combo } = run;
53
+ const log = run.log ?? (() => { });
54
+ let body;
55
+ try {
56
+ body = JSON.parse(run.raw);
57
+ }
58
+ catch {
59
+ return reply(res, 400, { error: { message: 'request body is not JSON', type: 'invalid_json', code: 'invalid_json' } });
60
+ }
61
+ const tried = [];
62
+ let last;
63
+ for (const model of combo.models) {
64
+ if (run.signal.aborted)
65
+ return;
66
+ let upstream;
67
+ try {
68
+ upstream = await fetch(`http://127.0.0.1:${run.port}/v1/chat/completions`, {
69
+ method: 'POST',
70
+ headers: { Authorization: `Bearer ${run.key}`, 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ ...body, model }),
72
+ signal: run.signal,
73
+ });
74
+ }
75
+ catch (error) {
76
+ if (run.signal.aborted)
77
+ return;
78
+ last = { status: 502, text: String(error.message ?? error) };
79
+ tried.push(`${model}: ${last.text.slice(0, 120)}`);
80
+ continue;
81
+ }
82
+ if (upstream.ok && upstream.body) {
83
+ if (tried.length)
84
+ log(`combo ${combo.id}: ${model} answered after ${tried.length} failed (${tried.join(' | ')})`);
85
+ res.writeHead(upstream.status, {
86
+ 'Content-Type': upstream.headers.get('content-type') ?? 'application/json',
87
+ 'Cache-Control': 'no-cache',
88
+ 'X-Dsh-Model-Combo-Model': model,
89
+ });
90
+ const stream = Readable.fromWeb(upstream.body);
91
+ stream.on('error', (error) => {
92
+ log(`combo ${combo.id}: ${model} stream failed mid-flight: ${String(error)}`);
93
+ if (res.writable)
94
+ res.end();
95
+ });
96
+ stream.pipe(res);
97
+ return;
98
+ }
99
+ const text = await upstream.text().catch(() => '');
100
+ last = { status: upstream.status, text };
101
+ tried.push(`${model}: HTTP ${upstream.status}`);
102
+ if (!shouldFallBack(upstream.status, text))
103
+ break;
104
+ }
105
+ log(`combo ${combo.id}: all failed (${tried.join(' | ')})`);
106
+ const message = L(`组合「${combo.name}」里的模型都失败了:${tried.join(';')}`, `Every model in combo "${combo.name}" failed: ${tried.join('; ')}`);
107
+ let detail;
108
+ try {
109
+ detail = last ? JSON.parse(last.text) : undefined;
110
+ }
111
+ catch {
112
+ detail = undefined;
113
+ }
114
+ const upstreamError = detail?.error;
115
+ reply(res, last?.status && last.status >= 400 ? last.status : 502, { error: { message, type: 'combo_failed', code: 'combo_failed', ...(upstreamError ? { last: upstreamError } : {}) } });
116
+ }
117
+ function reply(res, status, body) {
118
+ const payload = JSON.stringify(body);
119
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) });
120
+ res.end(payload);
121
+ }
@@ -4,6 +4,7 @@
4
4
  import { createBridge } from '../bridge/server.js';
5
5
  import { loadBridgeConfig } from '../bridge/runtime.js';
6
6
  import { Daemon } from '../daemon/core.js';
7
+ import { loadConfig } from '../state.js';
7
8
  import { DshModelError } from '../errors.js';
8
9
  import { L } from '../i18n.js';
9
10
  import { info, isJsonMode, printJson } from '../util/output.js';
@@ -54,6 +55,8 @@ async function run(ctx) {
54
55
  return { changed };
55
56
  },
56
57
  onControl: (method, path, body, query) => daemon.handle(method, path, body, query),
58
+ onCombo: (id, raw, res, signal) => daemon.runCombo(id, raw, res, signal, log),
59
+ comboIds: async () => ((await loadConfig(ctx)).combos ?? []).map((c) => c.id),
57
60
  });
58
61
  await server.ready;
59
62
  log(`daemon: listening on 127.0.0.1:${cfg.port}`);
@@ -0,0 +1,41 @@
1
+ // commands/combo.ts — dsh-model combo:自定义组合切换(按顺序试几个模型,一个不行换下一个)
2
+ import { control } from '../daemon/client.js';
3
+ import { DshModelError } from '../errors.js';
4
+ import { L } from '../i18n.js';
5
+ import { bold, dim, info, isJsonMode, ok, printJson } from '../util/output.js';
6
+ export async function combo(ctx, sub, args) {
7
+ if (!sub || sub === 'list') {
8
+ const r = await control(ctx, 'GET', '/combos');
9
+ if (isJsonMode()) {
10
+ printJson(r.combos);
11
+ return 0;
12
+ }
13
+ if (!r.combos.length) {
14
+ info(L('还没有组合。例:dsh-model combo add 主力 <模型1> <模型2> …(模型名见 dsh-model models)', 'No combos yet. Example: dsh-model combo add main <model1> <model2> … (model names: dsh-model models)'));
15
+ return 0;
16
+ }
17
+ for (const c of r.combos)
18
+ info(`${bold(`combo/${c.id}`)} ${c.name}\n ${dim(c.models.join(' → '))}`);
19
+ return 0;
20
+ }
21
+ if (sub === 'add') {
22
+ const [name, ...models] = args;
23
+ if (!name || models.length < 2)
24
+ throw new DshModelError('missing_arg', L('用法:dsh-model combo add <名称> <模型1> <模型2> …', 'Usage: dsh-model combo add <name> <model1> <model2> …'));
25
+ const c = await control(ctx, 'POST', '/combos', { name, models });
26
+ if (isJsonMode())
27
+ printJson(c);
28
+ else
29
+ ok(L(`已建组合 combo/${c.id}:${c.models.join(' → ')}`, `Combo combo/${c.id} created: ${c.models.join(' → ')}`));
30
+ return 0;
31
+ }
32
+ if (sub === 'remove') {
33
+ const id = args[0]?.replace(/^combo\//, '');
34
+ if (!id)
35
+ throw new DshModelError('missing_arg', L('用法:dsh-model combo remove <组合 id>', 'Usage: dsh-model combo remove <combo id>'));
36
+ await control(ctx, 'DELETE', `/combos/${encodeURIComponent(id)}`);
37
+ ok(L(`已删除组合 combo/${id}`, `Combo combo/${id} removed`));
38
+ return 0;
39
+ }
40
+ throw new DshModelError('unknown_command', L(`未知子命令:combo ${sub}`, `Unknown subcommand: combo ${sub}`));
41
+ }
@@ -43,6 +43,7 @@ import { redactKey } from '../util/redact.js';
43
43
  import { Stats } from './stats.js';
44
44
  import { USAGE_UNSUPPORTED, probeEngineSource, fetchEngineUsage, fetchWorkbuddyUsage, usageFilePath } from './usage.js';
45
45
  import { writeJson } from '../util/fs.js';
46
+ import { comboId, normalizeCombo, runCombo } from '../combo.js';
46
47
  const LOGIN_POLL_MS = 2000;
47
48
  const LOGIN_TIMEOUT_MS = 15 * 60 * 1000;
48
49
  const USAGE_POLL_MS = 5000;
@@ -910,6 +911,61 @@ export class Daemon {
910
911
  await this.resync();
911
912
  return { selected: models === null ? -1 : new Set(models).size };
912
913
  }
914
+ /** 组合切换:现有组合,以及能放进组合的模型(按来源分组,顺序同 dsh 下拉框) */
915
+ async combos() {
916
+ const all = await loadAll(this.ctx);
917
+ const cands = await dshCandidates(this.ctx, all);
918
+ const models = [...cands.values()]
919
+ .filter((c) => c.source !== 'combo')
920
+ .flatMap((c) => c.models.map((m) => ({ id: m.id, name: m.name, group: c.source, groupLabel: c.label })));
921
+ return { combos: all.config.combos ?? [], models };
922
+ }
923
+ /** 新建(id 为 null)或修改一个组合 */
924
+ async saveCombo(id, input) {
925
+ const { models: avail } = await this.combos();
926
+ const c = normalizeCombo(input, avail.map((m) => m.id));
927
+ const saved = await withLock(this.ctx, async () => {
928
+ const all = await loadAll(this.ctx);
929
+ const list = [...(all.config.combos ?? [])];
930
+ let combo;
931
+ if (id) {
932
+ const i = list.findIndex((x) => x.id === id);
933
+ if (i < 0)
934
+ throw new DshModelError('no_combo', L(`没有这个组合:${id}`, `No such combo: ${id}`));
935
+ combo = { id, ...c };
936
+ list[i] = combo;
937
+ }
938
+ else {
939
+ combo = { id: comboId(c.name, list.map((x) => x.id)), ...c };
940
+ list.push(combo);
941
+ }
942
+ all.config.combos = list;
943
+ await saveAll(this.ctx, all);
944
+ return combo;
945
+ });
946
+ await this.resync();
947
+ return saved;
948
+ }
949
+ async deleteCombo(id) {
950
+ await withLock(this.ctx, async () => {
951
+ const all = await loadAll(this.ctx);
952
+ const list = (all.config.combos ?? []).filter((x) => x.id !== id);
953
+ if (list.length === (all.config.combos ?? []).length)
954
+ throw new DshModelError('no_combo', L(`没有这个组合:${id}`, `No such combo: ${id}`));
955
+ all.config.combos = list.length ? list : undefined;
956
+ await saveAll(this.ctx, all);
957
+ });
958
+ await this.resync();
959
+ }
960
+ /** 引擎转来的 combo/<id> 请求:按顺序逐个试 */
961
+ async runCombo(id, raw, res, signal, log) {
962
+ const all = await loadAll(this.ctx);
963
+ const combo = all.config.combos?.find((c) => c.id === id);
964
+ if (!combo)
965
+ return false;
966
+ await runCombo({ port: all.config.port, key: dshKey(all.keys), combo, raw, res, signal, ...(log ? { log } : {}) });
967
+ return true;
968
+ }
913
969
  /** 管理页"复制"用:取一把未吊销 key 的完整值(只经同源、带页面 token 的插件路由转发) */
914
970
  async revealKey(name) {
915
971
  const entry = (await loadKeys(this.ctx)).keys.find((k) => k.name === name && !k.revokedAt);
@@ -978,6 +1034,14 @@ export class Daemon {
978
1034
  }
979
1035
  return ok(await this.sources());
980
1036
  }
1037
+ if (method === 'GET' && path === '/combos')
1038
+ return ok(await this.combos());
1039
+ if (method === 'POST' && path === '/combos')
1040
+ return ok(await this.saveCombo(null, b));
1041
+ if (seg[0] === 'combos' && seg[1] && method === 'PUT')
1042
+ return ok(await this.saveCombo(seg[1], b));
1043
+ if (seg[0] === 'combos' && seg[1] && method === 'DELETE')
1044
+ return ok(await this.deleteCombo(seg[1]).then(() => ({ deleted: true })));
981
1045
  if (seg[0] === 'sources' && seg[1] && method === 'POST') {
982
1046
  if (seg[2] === 'enable')
983
1047
  return ok(await this.enable(seg[1], { acceptRisk: b.acceptRisk === true }));
@@ -10,6 +10,7 @@
10
10
  // 一律只读:不刷新、不改写任何凭据文件。查询失败只在那一行显示"用量暂不可用"。
11
11
  import { readFile } from 'node:fs/promises';
12
12
  import { join } from 'node:path';
13
+ import { beijingTimeToIso } from '../bridge/workbuddy/upstream.js';
13
14
  const TIMEOUT_MS = 15_000;
14
15
  /** 这几家暂不支持(见设计 §14.6) */
15
16
  export const USAGE_UNSUPPORTED = new Set(['antigravity', 'devin', 'meta', 'opencode', 'ollama', 'lmstudio', 'gemini', 'groq', 'openrouter', 'siliconflow', 'deepseek', 'custom']);
@@ -165,12 +166,35 @@ export async function fetchWorkbuddyUsage(rt) {
165
166
  const credits = await rt.wbClient.fetchCredits(credential);
166
167
  const unlimited = credits.accounts.some((a) => a.unlimited);
167
168
  const size = credits.accounts.reduce((s, a) => s + (Number.isFinite(a.size) ? a.size : 0), 0);
169
+ const next = nextCreditChange(credits.accounts, credits.cycleResetTime);
168
170
  return {
169
171
  windows: [],
170
- credits: { remaining: credits.total, ...(size > 0 ? { total: size } : {}), ...(unlimited ? { unlimited: true } : {}), label: 'credits' },
172
+ credits: { remaining: credits.total, ...(size > 0 ? { total: size } : {}), ...(unlimited ? { unlimited: true } : {}), label: 'credits', ...(next ? { next } : {}) },
171
173
  ...(credits.accounts.length ? { plan: packageSummary(credits.accounts.map((a) => a.packageName)) } : {}),
172
174
  };
173
175
  }
176
+ /**
177
+ * 最近一批要变动的积分:还有剩余的包里,周期最早结束的那个时间点;同一天(24 小时内)结束的合在一起算。
178
+ * 作废的优先于重置的提醒(作废了就真没了)。企业版只有一个周期重置时间。
179
+ */
180
+ export function nextCreditChange(accounts, cycleResetTime, now = Date.now()) {
181
+ const live = accounts.filter((a) => a.remain > 0 && a.cycleEndAt && Date.parse(a.cycleEndAt) > now);
182
+ const pick = (list) => {
183
+ if (!list.length)
184
+ return undefined;
185
+ const first = Math.min(...list.map((a) => Date.parse(a.cycleEndAt)));
186
+ const amount = list.filter((a) => Date.parse(a.cycleEndAt) - first < 86_400_000).reduce((s, a) => s + a.remain, 0);
187
+ return { amount: Math.round(amount * 100) / 100, at: new Date(first).toISOString() };
188
+ };
189
+ const expiring = pick(live.filter((a) => !a.renews));
190
+ if (expiring)
191
+ return { ...expiring, renews: false };
192
+ const renewing = pick(live.filter((a) => a.renews));
193
+ if (renewing)
194
+ return { ...renewing, renews: true };
195
+ const reset = cycleResetTime ? (beijingTimeToIso(cycleResetTime) ?? (Number.isFinite(Date.parse(cycleResetTime)) ? new Date(Date.parse(cycleResetTime)).toISOString() : undefined)) : undefined;
196
+ return reset && Date.parse(reset) > now ? { amount: 0, at: reset, renews: true } : undefined;
197
+ }
174
198
  /** 套餐名去重计数:"个人版 + 加量包 ×38"(账号下常有几十个同名加量包,实测) */
175
199
  export function packageSummary(names) {
176
200
  const counts = new Map();
@@ -52,6 +52,12 @@ async function checkLocation(state, location, force) {
52
52
  return warnings;
53
53
  }
54
54
  /** 写入 / 移除一组项。所有权记在 state.dsh.ownedProviders / ownedRefs */
55
+ /**
56
+ * 上次安装留下的东西(台账丢了,例如卸载没走完、或手动删过 ~/.dsh-model):
57
+ * dsh-model 格式的 key(dshm_ + 43 位),或指向 DSH_MODEL_API_KEY 的 dsh-model* provider。直接接管,不让用户去手动删。
58
+ */
59
+ export const isLeftoverKey = (value) => /^dshm_[A-Za-z0-9_-]{43}$/.test(value);
60
+ const isLeftoverProvider = (id, spec) => id.startsWith('dsh-model') && spec?.apiKeyEnv === KEY_REF;
55
61
  export async function applyDsh(ctx, state, items, opts) {
56
62
  const location = await locateDsh(ctx, opts.profile);
57
63
  const warnings = await checkLocation(state, location, opts.force);
@@ -72,7 +78,12 @@ export async function applyDsh(ctx, state, items, opts) {
72
78
  continue;
73
79
  }
74
80
  if (existing !== undefined && !ownedRefs.has(name) && !opts.isOwnRef?.(name, existing)) {
75
- throw new DshModelError('credential_ref_conflict', L(`dsh 凭据里已有 ${name},且不是 dsh-model 写的`, `dsh credentials already contain ${name}, not written by dsh-model`), L('不会覆盖它。要让 dsh-model 接管,请先在 dsh 设置里删掉它再重试', 'It will not be overwritten. To let dsh-model manage it, remove it in dsh settings and retry'));
81
+ if (name === KEY_REF && isLeftoverKey(existing)) {
82
+ warnings.push(L(`接管了上次安装留下的 ${name}(换成这次的 key)`, `Took over ${name} left by an earlier install (replaced with this install's key)`));
83
+ }
84
+ else {
85
+ throw new DshModelError('credential_ref_conflict', L(`dsh 凭据里已有 ${name},且不是 dsh-model 写的`, `dsh credentials already contain ${name}, not written by dsh-model`), L('不会覆盖它。要让 dsh-model 接管,请先在 dsh 设置里删掉它再重试', 'It will not be overwritten. To let dsh-model manage it, remove it in dsh settings and retry'));
86
+ }
76
87
  }
77
88
  credAfter = upsertRef(credAfter === '' ? null : credAfter, value, name);
78
89
  ownedRefs.add(name);
@@ -88,7 +99,11 @@ export async function applyDsh(ctx, state, items, opts) {
88
99
  }
89
100
  continue;
90
101
  }
91
- if (!ownedProviders.has(id) && readProvider(patchAfter === '' ? null : patchAfter, id)) {
102
+ const existingProvider = ownedProviders.has(id) ? null : readProvider(patchAfter === '' ? null : patchAfter, id);
103
+ if (existingProvider && isLeftoverProvider(id, existingProvider)) {
104
+ warnings.push(L(`接管了上次安装留下的 provider ${id}`, `Took over provider ${id} left by an earlier install`));
105
+ }
106
+ else if (existingProvider) {
92
107
  throw new DshModelError('provider_conflict', L(`dsh 里已有 provider ${id},且不是 dsh-model 写的`, `dsh already has a provider ${id}, not written by dsh-model`), L('不会覆盖它', 'It will not be overwritten'));
93
108
  }
94
109
  const r = upsertProvider(patchAfter === '' ? null : patchAfter, id, spec);
@@ -8,6 +8,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
10
  import { keyProvider, loadKeyCatalog } from '../integrations/apikeys.js';
11
+ import { COMBO_PREFIX } from '../combo.js';
11
12
  export const OPENCODE_BASE = 'https://opencode.ai/zen/v1';
12
13
  export const OPENCODE_PREFIX = 'opencode';
13
14
  export function opencodeModelsPath(ctx) {
@@ -16,7 +17,8 @@ export function opencodeModelsPath(ctx) {
16
17
  export async function loadCompatUpstreams(ctx) {
17
18
  const out = [];
18
19
  const secrets = await loadSecrets(ctx);
19
- const disabled = new Set((await readJson(join(ctx.paths.home, 'config.json')))?.disabledSources ?? []);
20
+ const cfg = await readJson(join(ctx.paths.home, 'config.json'));
21
+ const disabled = new Set(cfg?.disabledSources ?? []);
20
22
  if (secrets.opencode?.key && !disabled.has('opencode')) {
21
23
  const list = await readJson(opencodeModelsPath(ctx));
22
24
  const models = (list?.models ?? []).map((m) => ({
@@ -99,7 +101,34 @@ export async function loadCompatUpstreams(ctx) {
99
101
  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}(本地)` })),
100
102
  });
101
103
  }
102
- return addAutoModels(out);
104
+ addAutoModels(out);
105
+ // 组合切换:守护进程按顺序逐个调引擎里的真实模型
106
+ if (bridge && cfg?.combos?.length)
107
+ out.push(comboUpstream(cfg.combos, bridge, compatModelIndex(out)));
108
+ return out;
109
+ }
110
+ export function comboUpstream(combos, bridge, meta) {
111
+ return {
112
+ name: COMBO_PREFIX,
113
+ label: '组合切换',
114
+ baseUrl: `http://127.0.0.1:${bridge.port}/${COMBO_PREFIX}/v1`,
115
+ apiKey: bridge.secret,
116
+ direct: true,
117
+ models: combos.map((c) => {
118
+ // 上下文取成员里最小的(知道的才算),免得 dsh 按大的塞内容、换到小的就超长
119
+ const ctxs = c.models.map((m) => meta.get(m)?.contextWindow).filter((n) => typeof n === 'number' && n > 0);
120
+ return {
121
+ name: c.id,
122
+ alias: `${COMBO_PREFIX}/${c.id}`,
123
+ displayName: `${c.name}(${c.models.join(' → ')})`,
124
+ dshName: `${c.name} · ${c.models.length} 个模型轮换`,
125
+ group: COMBO_PREFIX,
126
+ groupLabel: '组合切换',
127
+ ...(ctxs.length ? { contextWindow: Math.min(...ctxs) } : {}),
128
+ image: c.models.every((m) => meta.get(m)?.image === true),
129
+ };
130
+ }),
131
+ };
103
132
  }
104
133
  export const AUTO_PREFIX = 'auto';
105
134
  /**
package/dist/ops.js CHANGED
@@ -94,7 +94,8 @@ export async function syncModels(ctx, all, opts = {}) {
94
94
  // 每个来源只把挑中的模型写进 dsh(管理页勾选,或默认挑选);统一端点照常提供全部
95
95
  const groups = [];
96
96
  for (const c of candidates.values()) {
97
- const picked = new Set(pickedFor(c.models.map((m) => ({ id: m.id, name: m.name, rate: m.rate })), all.config.dshModels?.[c.source]));
97
+ // 组合是自己建的:全部显示
98
+ const picked = new Set(c.source === 'combo' ? c.models.map((m) => m.id) : pickedFor(c.models.map((m) => ({ id: m.id, name: m.name, rate: m.rate })), all.config.dshModels?.[c.source]));
98
99
  const models = c.models.filter((m) => picked.has(m.id)).map(({ rate: _r, ...m }) => m);
99
100
  if (models.length)
100
101
  groups.push({ providerId: c.providerId, displayName: c.label, models });
package/dist/sources.js CHANGED
@@ -44,4 +44,4 @@ export function paymentRequired(cred) {
44
44
  return Array.isArray(cooldowns) && cooldowns.some((c) => c?.reason === 'payment_required');
45
45
  }
46
46
  /** 引擎里作为 openai-compatibility 上游的来源:模型名前缀就是来源 id(同步时据此核对引擎是否已热重载) */
47
- export const COMPAT_PREFIXES = [...SOURCES.filter((s) => s.kind !== 'engine').map((s) => `${s.variant ?? s.id}/`), 'auto/'];
47
+ export const COMPAT_PREFIXES = [...SOURCES.filter((s) => s.kind !== 'engine').map((s) => `${s.variant ?? s.id}/`), 'auto/', 'combo/'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-model",
3
- "version": "0.21.5",
3
+ "version": "0.22.1",
4
4
  "description": "One OpenAI-compatible address for all your models: subscriptions (Codex, Kimi…), free tiers (WorkBuddy, Gemini, Groq, OpenRouter), local models (Ollama, LM Studio), daily check-in credits, auto failover. Works with DeepSeek Harness (dsh) and any OpenAI-compatible tool. 一个地址用上你所有的模型。",
5
5
  "keywords": [
6
6
  "dsh-plugin",
package/plugin/client.js CHANGED
@@ -106,16 +106,32 @@ window.__ModuleLoader__.load({
106
106
  h('span', { style: { display: 'block', width: `${p}%`, height: '100%', background: color } }))
107
107
  }
108
108
 
109
- const until = (iso) => {
110
- if (!iso) return ''
111
- const ms = Date.parse(iso) - Date.now()
112
- if (!(ms > 0)) return L('即将重置', 'resetting')
113
- const m = Math.round(ms / 60000)
109
+ /** 距离 iso 还有多久:"2 天 3 小时" / "3 小时 5 分" / "12 分";已过去返回 '' */
110
+ const dur = (iso) => {
111
+ const ms = iso ? Date.parse(iso) - Date.now() : NaN
112
+ if (!(ms > 0)) return ''
113
+ const m = Math.max(1, Math.round(ms / 60000))
114
114
  const d = Math.floor(m / 1440)
115
115
  const hh = Math.floor((m % 1440) / 60)
116
116
  const mm = m % 60
117
- const t = d ? L(`${d} 天 ${hh} 小时`, `${d}d ${hh}h`) : hh ? L(`${hh} 小时 ${mm} 分`, `${hh}h ${mm}m`) : L(`${mm} 分`, `${mm}m`)
118
- return L(`${t}后重置`, `resets in ${t}`)
117
+ return d ? L(`${d} 天 ${hh} 小时`, `${d}d ${hh}h`) : hh ? L(`${hh} 小时 ${mm} 分`, `${hh}h ${mm}m`) : L(`${mm} 分`, `${mm}m`)
118
+ }
119
+ const until = (iso) => {
120
+ if (!iso) return ''
121
+ const t = dur(iso)
122
+ return t ? L(`${t}后重置`, `resets in ${t}`) : L('即将重置', 'resetting')
123
+ }
124
+ /** 额度用完了吗:用完的窗口里最晚恢复的那个时间(全部恢复才能用);积分用完也算 */
125
+ const exhausted = (usage) => {
126
+ if (!usage || usage.noAccess) return null
127
+ const full = (usage.windows || []).filter((w) => w.usedPercent != null && w.usedPercent >= 100)
128
+ if (full.length) {
129
+ const at = full.map((w) => w.resetAt).filter(Boolean).sort().pop()
130
+ return { at }
131
+ }
132
+ const c = usage.credits
133
+ if (c && !c.unlimited && c.remaining <= 0) return { at: c.next && c.next.renews ? c.next.at : undefined }
134
+ return null
119
135
  }
120
136
  const winLabel = (w) => ({ '5h': L('5 小时', '5 hours'), '7d': L('每周', 'Weekly'), '30d': L('每月', 'Monthly'), quota: L('总额度', 'Quota'), 'Opus 7d': L('Opus 每周', 'Opus weekly'), 'Sonnet 7d': L('Sonnet 每周', 'Sonnet weekly') }[w.label] || w.label)
121
137
  const pct = (x) => (x == null ? '-' : `${Math.round(x * 100)}%`)
@@ -150,7 +166,11 @@ window.__ModuleLoader__.load({
150
166
  lines.push(h('div', { key: 'credits', style: { ...S.line, minHeight: 22, fontSize: 12 } },
151
167
  h('span', { style: { width: 72, color: C.sub } }, L('积分', 'Credits')),
152
168
  c.total ? h(Bar, { pct: 100 - (c.remaining / c.total) * 100 }) : null,
153
- h('span', null, c.unlimited ? L('不限量', 'Unlimited') : L(`剩余 ${c.remaining.toLocaleString()}${c.total ? ` / ${c.total.toLocaleString()}` : ''}`, `${c.remaining.toLocaleString()} left${c.total ? ` of ${c.total.toLocaleString()}` : ''}`))))
169
+ h('span', null, c.unlimited ? L('不限量', 'Unlimited') : L(`剩余 ${c.remaining.toLocaleString()}${c.total ? ` / ${c.total.toLocaleString()}` : ''}`, `${c.remaining.toLocaleString()} left${c.total ? ` of ${c.total.toLocaleString()}` : ''}`)),
170
+ c.next && dur(c.next.at) ? h('span', { style: { color: !c.next.renews && Date.parse(c.next.at) - Date.now() < 3 * 86400000 ? C.warn : C.faint } },
171
+ c.next.renews
172
+ ? (c.next.amount ? L(`${c.next.amount.toLocaleString()} 积分 ${dur(c.next.at)}后重置`, `${c.next.amount.toLocaleString()} reset in ${dur(c.next.at)}`) : L(`${dur(c.next.at)}后重置`, `resets in ${dur(c.next.at)}`))
173
+ : L(`${c.next.amount.toLocaleString()} 积分 ${dur(c.next.at)}后到期,先用这些`, `${c.next.amount.toLocaleString()} expire in ${dur(c.next.at)}; use them first`)) : null))
154
174
  }
155
175
  if (usage.error) lines.push(h('div', { key: 'err', style: { fontSize: 12, color: C.warn, marginTop: 4 } }, `${L('用量暂不可用', 'Usage unavailable')}:${usage.error}`))
156
176
  return h('div', { style: { marginTop: 4, paddingLeft: 46 } }, lines)
@@ -456,6 +476,12 @@ window.__ModuleLoader__.load({
456
476
  h('span', { style: S.label }, s.label),
457
477
  h(PriceTag, { pricing: s.pricing }),
458
478
  s.risky ? h('span', { style: { fontSize: 11, color: C.err, marginLeft: 6, border: `1px solid ${C.err}`, borderRadius: 4, padding: '0 4px' } }, L('高风险', 'High risk')) : null,
479
+ (() => {
480
+ const x = s.enabled ? exhausted(s.usage) : null
481
+ if (!x) return null
482
+ const t = dur(x.at)
483
+ return h('span', { style: { fontSize: 11, color: C.err, marginLeft: 6, border: `1px solid ${C.err}`, borderRadius: 4, padding: '0 4px' } }, t ? L(`已用完 · ${t}后恢复`, `Used up · back in ${t}`) : L('已用完', 'Used up'))
484
+ })(),
459
485
  h('span', { style: { ...S.meta, marginLeft: 8 } },
460
486
  s.loggedIn ? (s.account || '') : s.kind === 'local' ? L('未运行', 'Not running') : L('未登录', 'Signed out'),
461
487
  s.models ? ` · ${L(`${s.models} 个模型`, `${s.models} models`)}` : '',
@@ -556,6 +582,90 @@ window.__ModuleLoader__.load({
556
582
  upstream: () => L('上游或网络问题', 'upstream or network'),
557
583
  other: () => L('其他原因', 'other causes'),
558
584
  }
585
+ /** 组合切换:几个模型按顺序排好,在 dsh 里就是一个模型;一个不行自动换下一个 */
586
+ function CombosCard() {
587
+ const [data, setData] = useState(null)
588
+ const [editing, setEditing] = useState(null)
589
+ const [err, setErr] = useState('')
590
+ const load = useCallback(() => api('GET', '/combos').then((d) => { setData(d); setErr('') }, (e) => setErr(e.message)), [])
591
+ useEffect(() => { void load() }, [load])
592
+ if (!data) return err ? h('div', { style: S.section }, h('div', { style: { ...S.h } }, L('组合切换', 'Combos')), h('div', { style: { fontSize: 12, color: C.err } }, err)) : null
593
+ const byId = new Map(data.models.map((m) => [m.id, m]))
594
+ const pretty = (id) => { const m = byId.get(id); return m ? `${m.groupLabel} · ${m.name.split(' · ')[0]}` : id }
595
+ const remove = async (c) => {
596
+ if (!window.confirm(L(`删除组合「${c.name}」?(它会从 dsh 里消失)`, `Delete combo "${c.name}"? (it disappears from dsh)`))) return
597
+ try { await api('DELETE', `/combos/${c.id}`); await load() } catch (e) { setErr(e.message) }
598
+ }
599
+ const canBuild = data.models.length >= 2
600
+ return h('div', { style: S.section },
601
+ h('div', { style: S.line },
602
+ h('div', { style: { ...S.h, ...S.grow, margin: 0 } }, L('组合切换', 'Combos')),
603
+ canBuild ? h('button', { type: 'button', style: S.btn, onClick: () => setEditing('new') }, L('新建组合', 'New combo')) : null),
604
+ h('p', { style: S.note }, L('把几个模型按顺序排好,它在 dsh 里就是一个模型(「组合切换」分组):先用第一个,限流、额度用完或出错就自动换下一个;已经开始回答后不再换。其他软件用模型名 combo/<id>。', 'Put a few models in order and it becomes one model in dsh (the "Combos" group): the first one is used, and on a rate limit, used-up quota or error the next one takes over; once an answer has started it stays put. Other tools use the model name combo/<id>.')),
605
+ err ? h('div', { style: { fontSize: 12, color: C.err, margin: '0 0 8px' } }, err) : null,
606
+ data.combos.length
607
+ ? h('div', { style: S.card }, data.combos.map((c, i) => h('div', { key: c.id, style: i ? S.row : S.rowFirst },
608
+ h('div', { style: S.line },
609
+ h('div', { style: S.grow },
610
+ h('span', { style: S.label }, c.name),
611
+ h('span', { style: { ...S.meta, ...S.mono, marginLeft: 8 } }, `combo/${c.id}`)),
612
+ h(CopyButton, { get: `combo/${c.id}`, label: L('复制模型名', 'Copy name') }),
613
+ h('button', { type: 'button', style: S.btn, onClick: () => setEditing(c) }, L('编辑', 'Edit')),
614
+ h('button', { type: 'button', style: S.btn, onClick: () => void remove(c) }, L('删除', 'Delete'))),
615
+ h('div', { style: { ...S.meta, marginTop: 4, lineHeight: 1.7 } }, c.models.map((m, j) => h('span', { key: m },
616
+ j ? ' → ' : '',
617
+ h('span', { title: byId.has(m) ? m : L(`${m}:现在用不了(来源关了或没登录),会被跳过`, `${m}: not available now (source off or signed out); it is skipped`), style: byId.has(m) ? undefined : { color: C.err, textDecoration: 'line-through' } }, pretty(m))))))))
618
+ : h('div', { style: S.meta }, canBuild ? L('还没有组合。例如「主力」:Codex → Kimi → WorkBuddy,订阅额度用完自动换到免费的。', 'No combos yet. For example "Main": Codex → Kimi → WorkBuddy, moving to a free source when the subscription quota runs out.') : L('至少接入两个模型后才能建组合。', 'Connect at least two models to build a combo.')),
619
+ editing ? h(ComboDialog, { combo: editing === 'new' ? null : editing, models: data.models, onClose: (saved) => { setEditing(null); if (saved) void load() } }) : null)
620
+ }
621
+
622
+ function ComboDialog({ combo, models, onClose }) {
623
+ const [name, setName] = useState(combo ? combo.name : '')
624
+ const [list, setList] = useState(combo ? combo.models : [])
625
+ const [busy, setBusy] = useState(false)
626
+ const [msg, setMsg] = useState('')
627
+ const byId = new Map(models.map((m) => [m.id, m]))
628
+ const groups = []
629
+ for (const m of models) {
630
+ let g = groups.find((x) => x.label === m.groupLabel)
631
+ if (!g) groups.push((g = { label: m.groupLabel, models: [] }))
632
+ g.models.push(m)
633
+ }
634
+ const MAX = 8
635
+ const move = (i, d) => { const n = [...list]; const [x] = n.splice(i, 1); n.splice(i + d, 0, x); setList(n) }
636
+ const save = async () => {
637
+ setBusy(true)
638
+ setMsg('')
639
+ try {
640
+ if (combo) await api('PUT', `/combos/${combo.id}`, { name: name.trim(), models: list })
641
+ else await api('POST', '/combos', { name: name.trim(), models: list })
642
+ onClose(true)
643
+ } catch (e) {
644
+ setMsg(e.message)
645
+ setBusy(false)
646
+ }
647
+ }
648
+ return h(Modal, { title: combo ? L('编辑组合', 'Edit combo') : L('新建组合', 'New combo'), onClose: () => onClose(false) },
649
+ h('div', { style: { ...S.line, marginBottom: 10 } },
650
+ h('input', { style: { ...S.input, ...S.grow }, placeholder: L('名字,如:主力', 'Name, e.g. Main'), maxLength: 30, value: name, onChange: (e) => setName(e.target.value), 'aria-label': L('组合名字', 'Combo name') })),
651
+ h('div', { style: { ...S.meta, marginBottom: 6 } }, L(`按尝试顺序排(第一个最先用),2–${MAX} 个:`, `In the order to try (the first goes first), 2–${MAX} models:`)),
652
+ list.length ? h('div', { style: { ...S.card, marginBottom: 10 } }, list.map((id, i) => h('div', { key: id, style: { ...(i ? S.row : S.rowFirst), padding: '6px 10px' } },
653
+ h('div', { style: S.line },
654
+ h('span', { style: { ...S.meta, width: 18 } }, `${i + 1}.`),
655
+ h('span', { style: { ...S.grow, fontSize: 13 } }, byId.has(id) ? `${byId.get(id).groupLabel} · ${byId.get(id).name}` : id),
656
+ h('button', { type: 'button', style: S.btn, disabled: i === 0, onClick: () => move(i, -1), 'aria-label': L('上移', 'Move up') }, '↑'),
657
+ h('button', { type: 'button', style: S.btn, disabled: i === list.length - 1, onClick: () => move(i, 1), 'aria-label': L('下移', 'Move down') }, '↓'),
658
+ h('button', { type: 'button', style: S.btn, onClick: () => setList(list.filter((x) => x !== id)), 'aria-label': L('移除', 'Remove') }, '✕'))))) : null,
659
+ list.length < MAX ? h('div', { style: { ...S.line, marginBottom: 12 } },
660
+ h('select', { style: { ...S.input, ...S.grow }, value: '', 'aria-label': L('加一个模型', 'Add a model'), onChange: (e) => { const v = e.target.value; if (v && !list.includes(v)) setList([...list, v]) } },
661
+ h('option', { value: '' }, L('加一个模型…', 'Add a model…')),
662
+ groups.map((g) => h('optgroup', { key: g.label, label: g.label }, g.models.filter((m) => !list.includes(m.id)).map((m) => h('option', { key: m.id, value: m.id }, m.name)))))) : null,
663
+ h('div', { style: S.line },
664
+ msg ? h('span', { style: { ...S.grow, fontSize: 12, color: C.err } }, msg) : h('span', { style: S.grow }),
665
+ h('button', { type: 'button', style: S.btn, onClick: () => onClose(false) }, L('取消', 'Cancel')),
666
+ h('button', { type: 'button', style: S.btnPrimary, disabled: busy || !name.trim() || list.length < 2, onClick: () => void save() }, busy ? L('保存中…', 'Saving…') : L('保存', 'Save'))))
667
+ }
668
+
559
669
  function FailuresCard({ failures }) {
560
670
  const [open, setOpen] = useState(false)
561
671
  const [ack, setAck] = useState(readAck)
@@ -883,7 +993,7 @@ window.__ModuleLoader__.load({
883
993
  }
884
994
  return h(Modal, { title: L('添加 Qoder 签到', 'Add Qoder check-in'), onClose },
885
995
  h('p', { style: S.note },
886
- L('登录 Qoder 后,在「账号 → 集成」里创建一个个人访问令牌(Personal Access Token,以 pt- 开头,只在创建时显示一次),粘贴到这里。只用来每天领签到积分,保存在本机。', 'Sign in to Qoder and create a Personal Access Token under Account → Integrations (it starts with pt- and is shown only once), then paste it here. It is only used to claim the daily credits, and stays on this machine.'),
996
+ L('登录 Qoder 网站,在「设置 → API 令牌」(有的版本叫「账号 → 集成」)里点「创建令牌」,复制生成的个人访问令牌(以 pt- 开头,只显示一次),粘贴到这里。只用来每天领签到积分,保存在本机。', 'Sign in on the Qoder website, open Settings → API Tokens (Account → Integrations in some versions), click Create token, then paste the personal access token here (it starts with pt- and is shown only once). It is only used to claim the daily credits, and stays on this machine.'),
887
997
  h('a', { href: region === 'cn' ? 'https://qoder.com.cn/account/integrations' : 'https://qoder.com/account/integrations', target: '_blank', rel: 'noopener noreferrer', style: { color: C.accent, marginLeft: 6 } }, region === 'cn' ? L('去 Qoder 国内版创建 ↗', 'Create on Qoder China ↗') : L('去 Qoder 国际版创建 ↗', 'Create on Qoder ↗'))),
888
998
  h('div', { style: { ...S.line, marginBottom: 8 } },
889
999
  ['cn', 'intl'].map((r) => h('label', { key: r, style: { ...S.meta, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' } },
@@ -1060,6 +1170,7 @@ window.__ModuleLoader__.load({
1060
1170
  err ? h('div', { style: { fontSize: 12, color: C.err, marginBottom: 12 } }, err) : null,
1061
1171
  h(EndpointCard, { endpoints: data.endpoints }),
1062
1172
  h(SourcesCard, { sources: data.sources, reload: () => load(false), onLogin: (s, session) => setLogin({ source: s, session }), onKey: (s) => setKeyDialog(s || true) }),
1173
+ h(CombosCard, null),
1063
1174
  h(KeysCard, { keys: data.keys, reload: () => load(false) }),
1064
1175
  h(ModelsCard, { stats: data.stats }),
1065
1176
  h(FailuresCard, { failures: data.failures }),
package/plugin/index.js CHANGED
@@ -66,6 +66,10 @@ const ALLOWED = [
66
66
  ['POST', /^\/checkin(\/auto|\/qoder)?$/],
67
67
  ['DELETE', /^\/checkin\/qoder$/],
68
68
  ['POST', /^\/models\/test$/],
69
+ ['GET', /^\/combos$/],
70
+ ['POST', /^\/combos$/],
71
+ ['PUT', /^\/combos\/[a-z0-9-]{1,40}$/],
72
+ ['DELETE', /^\/combos\/[a-z0-9-]{1,40}$/],
69
73
  ['POST', /^\/self\/(update|uninstall)$/],
70
74
  ['GET', /^\/sources\/[a-z-]{2,20}\/models$/],
71
75
  ['POST', /^\/sources\/[a-z-]{2,20}\/models$/],