mingdao-harness 0.6.15 → 0.6.17

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/docs/CONFIG.md CHANGED
@@ -373,6 +373,158 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
373
373
 
374
374
  长期偏好仍走用户记忆(`AGENTS.md`,见上),不受此开关影响。
375
375
 
376
+ ## 模型名称以 API 为准(v0.6.19,§3.67)
377
+
378
+ **仓库不维护具体版本号,只维护「家族能力 / 定价模式」。** 官方改名、升版本(例如
379
+ `deepseek-v4-flash` → `deepseek-flash` → `DeepSeek-V4.1-Flash 1017` → 未来的 V4.2/V5)
380
+ **不需要改代码**:模型名一律以 `GET /v1/models` 实时返回的官方名为准。
381
+
382
+ > 官方**宣传名**(如 `DeepSeek-V4.1-Flash 1017`)与实际 API id(如 `deepseek-flash`)不一致时,
383
+ > **不要**把它写进请求:用下面的「显示别名 `config.modelLabels`」,请求仍用 API id。
384
+
385
+ ### 家族模式表
386
+
387
+ `src/models.js` 的 `MODEL_FAMILIES` 用正则匹配名字,能力(上下文窗口、单次输出上限、工具调用、
388
+ 视觉)与定价挂在**模式**上:
389
+
390
+ | 家族 | 匹配(大小写 / 空格 / 连字符 / 版本号无关) | 典型官方名 |
391
+ | --- | --- | --- |
392
+ | `pro` | `deepseek` + 可选版本 + `pro` | `DeepSeek-V4.1-Pro 1017` |
393
+ | `flash` | `deepseek` + 可选版本 + `flash` | `DeepSeek-V4.1-Flash 1017` |
394
+ | `vision` | `deepseek` + 可选版本 + `flash` + `vision` | `DeepSeek-V4.1-Flash-Vision 1017` |
395
+
396
+ - 精确 id 只作为**已知别名**存在(如旧名 `deepseek-v4-flash`),老配置与历史会话照常可用;
397
+ 路由比较前会归一化到家族代表名,不会因为厂家改名而静默失效。
398
+ - 比已核对规格更新的版本(例如 `DeepSeek-V4.2-Flash 1101`)**仍可选中**,列表会标
399
+ 「能力未知(新版本)」并按家族规格保守估算上下文与价格;需要精确规格时用
400
+ `customModels.<模型名>.contextWindow` / `config.pricing.overrides` 覆盖。
401
+
402
+ ### 默认值如何解析
403
+
404
+ 默认模型(新建配置、WebUI 模型下拉、自动路由的 planner / executor)在**运行时**按下列顺序解析:
405
+
406
+ 1. 用户显式配置优先:`config.model`、`config.routing.planner`、`config.routing.executor`;
407
+ 2. 否则从**当前 API 名单**里按家族挑官方名:planner → `pro` 家族,executor → `flash` 家族,
408
+ 多个版本并存时取最高版本;
409
+ 3. 名单里没有对应家族、或 API 不可达时,才退回**内置家族代表名**,并输出提示:
410
+ - 服务端日志:`[MingDao] ⚠ … 默认模型走兜底 …`
411
+ - WebUI / HTTP:`GET /api/models` 与 `GET /api/state` 的 `note` 字段,离线时
412
+ `offlineFallback: true`,文案为「离线兜底:模型名可能不是最新的」。
413
+
414
+ 离线时模型列表使用内置家族代表名(不是最新官方版本),并在每个条目上标
415
+ 「离线兜底」;连上 API 后刷新即恢复官方名。
416
+
417
+ **自定义端点不主动探测。** `provider: "custom"`(以及未内置的服务商)不会收到
418
+ `GET /models` 请求——很多自定义网关只实现 chat,空 body 的 `/models` 请求会让它们直接崩。
419
+ 这类端点的默认 planner / executor 用家族代表名兜底,需要指定时在 `config.routing.*`
420
+ 里写端点自己的模型名。
421
+
422
+ ### 旧名与自定义模型
423
+
424
+ - 旧名(`deepseek-v4-flash`、`deepseek-flash`、`deepseek-v4-pro`、`deepseek-v4-flash-vision-exp`)
425
+ 仍可作为 `--model` / `config.model` 的取值;能力与定价照旧按家族解析。
426
+ - 完全不属于任何家族的线上新模型(例如非 DeepSeek 命名)仍会出现在列表里,可直接选中;
427
+ 能力未知时按远程模型保守默认(上下文 128k)估算,可用 `customModels` 显式覆盖。
428
+
429
+ ### 显示别名 `config.modelLabels`(v0.6.20,§3.69)
430
+
431
+ **厂家的「官方宣传名 / 构建名」不等于 API id,不能拿去请求。** 实测(2026-10,真机):
432
+
433
+ ```text
434
+ GET https://api.deepseek.com/v1/models
435
+ → ["deepseek-flash", "deepseek-v4-pro"]
436
+
437
+ POST /v1/chat/completions {"model":"deepseek-flash"}
438
+ → 200(响应 model = "deepseek-flash")
439
+
440
+ POST /v1/chat/completions {"model":"DeepSeek-V4.1-Flash 1017"}
441
+ → 400 {"message":"The supported API model names are deepseek-flash, deepseek-v4-pro,
442
+ but you passed DeepSeek-V4.1-Flash 1017."}
443
+ ```
444
+
445
+ 也就是说:`DeepSeek-V4.1-Flash 1017` **既不会出现在 `/models` 里,也不能用于请求**。
446
+ 界面想显示官方宣传名,只能用「显示别名」——它**只影响渲染**,绝不参与出网请求的
447
+ `model` 字段(`config.model` / `config.routing.*` / 请求体 `model` 始终是 API id)。
448
+
449
+ ```json
450
+ {
451
+ "model": "deepseek-flash",
452
+ "modelLabels": {
453
+ "deepseek-flash": "DeepSeek-V4.1-Flash 1017",
454
+ "deepseek-v4-pro": "DeepSeek-V4.1-Pro 1017"
455
+ }
456
+ }
457
+ ```
458
+
459
+ 校验规则(`src/models.js` 的 `normalizeModelLabels`,在 `loadConfig()` 时执行):
460
+
461
+ | 规则 | 非法时的行为 |
462
+ | --- | --- |
463
+ | `modelLabels` 必须是对象(不能是数组 / null / 字符串) | 整个字段忽略 + `console.warn` |
464
+ | key(API id)去空白后必须非空 | 该项忽略 + `console.warn` |
465
+ | value 必须是字符串、去空白后必须非空 | 该项忽略 + `console.warn` |
466
+ | value 长度 ≤ 80 字符(`MODEL_LABEL_MAX_LENGTH`) | 该项忽略 + `console.warn` |
467
+ | key 为 `__proto__` / `constructor` / `prototype` | 该项忽略 + `console.warn`(原型污染防线) |
468
+
469
+ 非法项**不会阻断启动**(配置写坏一个字不该让应用起不来);同一非法项每个进程只提醒一次。
470
+ 合法映射的值会去掉首尾空白后生效。
471
+
472
+ 接口形状(`GET /api/models` 与 `GET /api/state` 的 `models[]`,**服务商线上模型**条目):
473
+
474
+ | 字段 | 含义 |
475
+ | --- | --- |
476
+ | `id` | API 实际 id(出网请求用它) |
477
+ | `name` | 同 `id`(保留给现有前端的选择 value,选择逻辑不变) |
478
+ | `label` | 显示名:`modelLabels[id]`;没有映射时**回退 `id`** |
479
+ | `labelSource` | 仅当 `label !== id` 时为 `"config"`(显示名来自 `config.modelLabels`) |
480
+ | `detail` | 能力 / 来源说明(原来的组合标签:离线兜底、能力未知、来源标注) |
481
+
482
+ `config.customModels` 的条目保持既有的「名字 — 用户标签(来源)」展示——它们不是官方
483
+ API id,不参与 `modelLabels`(其条目仍带 `id`,等于自定义模型名)。
484
+
485
+ 回归:`test/model-names.js` 第 ⑧ 组断言 + `test/mutate/batch37-model-labels.mjs`(10/10 全中)。
486
+
487
+ ### TODO(UI:显示别名落地,待另一道 / 下一轮实现;本次不改 UI 文件)
488
+
489
+ > 本次(v0.6.20 §3.69)只做内核 / 接口 / 文档;`src/web/app.js`、`src/web/index.html` 由并行
490
+ > UI 线持有(P1 已合入 `878d4cb`),故以下三处**尚未落地**。行号为 `878d4cb` 时;
491
+ > 后续改动会漂移,请以 `o.value=m.name; o.textContent=m.name; o.title=m.label` 等代码锚点为准。
492
+
493
+ 1. **模型选择器显示 `label`、`title` 显示 API id**(只改显示,不改选择逻辑)
494
+ - 位置:`src/web/app.js` 两处重复的选项渲染(`:1859` 与 `:2189`),锚点
495
+ `o.value=m.name; o.textContent=m.name; o.title=m.label;`。
496
+ - 建议改法:
497
+ ```js
498
+ const id = m.id || m.name;
499
+ const label = m.label || id;
500
+ const o = document.createElement('option');
501
+ o.value = id; // 选择 / 请求仍用 API id
502
+ o.textContent = label; // 界面显示官方宣传名
503
+ o.title = 'API id: ' + id + (m.detail ? ' · ' + m.detail : '');
504
+ if (id === j.model) o.selected = true;
505
+ ```
506
+ - 期望效果:下拉显示 `DeepSeek-V4.1-Flash 1017`;悬浮提示能对上
507
+ `API id: deepseek-flash · (能力/来源说明)`;选择后提交的仍是 id。
508
+ 2. **会话设置一行小字(仅当 `label !== id` 时出现)**
509
+ - `src/web/index.html`:会话设置的模型行(`:653` 已有 `ssModelLabel` / `ssModelId`),
510
+ 在 `ssModelSrc` 后加:
511
+ ```html
512
+ <div class="ss-src" id="ssModelAliasHint" hidden>显示名来自 config.modelLabels,请求使用 API id。</div>
513
+ ```
514
+ - `src/web/app.js`:`renderSessionSettings()` 中 `set('#ssModelId', ...)` 附近加:
515
+ ```js
516
+ const hint = $('#ssModelAliasHint');
517
+ if (hint) hint.hidden = !(md.label && md.label !== md.name);
518
+ ```
519
+ (`md` 即已有的 `modelDisplay(当前模型条目)`,`app.js:966`。)
520
+ - 期望效果:配了映射的模型显示这行说明,没配时不出现。
521
+ 3. **设置页说明文案(不是新表单)**
522
+ - `src/web/index.html`:`模型与 API Key` 标题下(`:757`)加一条
523
+ `cfg-hint`(带 `data-kw` 便于设置搜索命中,不新增任何输入框):
524
+ ```html
525
+ <div class="cfg-hint" data-kw="模型 id 官方名 宣传名 modelLabels 显示名">模型 id 以 API 返回为准;官方宣传名可在 <code>config.modelLabels</code> 里映射为显示名(请求仍用 API id)。</div>
526
+ ```
527
+
376
528
  ## 自定义模型(WebUI 添加后落盘的结构)
377
529
 
378
530
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mingdao-harness",
3
- "version": "0.6.15",
3
+ "version": "0.6.17",
4
4
  "description": "MingDao Harness —— 开源智能体框架(Agent Harness)。零依赖、开箱即用,针对 DeepSeek-V4 系列优化,开放主流模型接入。",
5
5
  "type": "module",
6
6
  "bin": {
package/src/config.js CHANGED
@@ -4,7 +4,7 @@
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import os from 'node:os';
7
- import { PROVIDERS, modelPreset, providerPreset, DEFAULT_MODEL, DEFAULT_PLANNER_MODEL, DEFAULT_EXECUTOR_MODEL } from './models.js';
7
+ import { PROVIDERS, modelPreset, providerPreset, pickFamilyModel, normalizeModelLabels, DEFAULT_MODEL, DEFAULT_PLANNER_MODEL, DEFAULT_EXECUTOR_MODEL } from './models.js';
8
8
  import { setStoredKey, resolveApiKey, maskKey } from './credentials.js';
9
9
  import { atomicWriteFileSync } from './atomic-write.js';
10
10
 
@@ -87,6 +87,71 @@ export function quarantineCorruptConfig(reason) {
87
87
  return dest;
88
88
  }
89
89
 
90
+ /**
91
+ * 同步读取 model-cache.json 里某服务商的线上名单(最近一次 `GET /models` 的结果)。
92
+ * `ensureMinimalConfig` / `loadConfig` 都是同步接口,无法 await 网络请求,用这份缓存
93
+ * 把「默认模型」对齐到 API 返回的官方名;缓存为空时才用家族代表名兜底并告警。
94
+ * @param {string} provider
95
+ * @returns {string[]}
96
+ */
97
+ function cachedModelNames(provider) {
98
+ try {
99
+ const j = JSON.parse(fs.readFileSync(path.join(mingdaoHome(), 'model-cache.json'), 'utf8'));
100
+ const list = j?.[provider]?.models;
101
+ return Array.isArray(list) ? list.map(String) : [];
102
+ } catch {
103
+ return [];
104
+ }
105
+ }
106
+
107
+ let warnedCachedFallback = false;
108
+ function warnCachedFallback(/** @type {string} */ what, /** @type {string} */ rep) {
109
+ if (warnedCachedFallback) return;
110
+ warnedCachedFallback = true;
111
+ console.warn(`[MingDao] ⚠ ${what} 没有可用的 API 官方名缓存:暂用内置家族代表名 ${rep}(兜底,模型名可能不是最新的;连上 API 后会自动刷新)。`);
112
+ }
113
+
114
+ /**
115
+ * v0.6.19(§3.67):把配置里的**缺省**模型补成 API 名单里的官方名。
116
+ * 只补缺省值,绝不覆盖用户显式写下的模型名/路由(用户意图优先)。
117
+ * @param {any} cfg
118
+ */
119
+ function applyCachedModelDefaults(/** @type {any} */ cfg) {
120
+ if (!cfg || typeof cfg !== 'object') return cfg;
121
+ const provider = String(cfg.provider || 'deepseek');
122
+ const cached = cachedModelNames(provider);
123
+ if (!cfg.model) {
124
+ const m = pickFamilyModel(cached, 'flash');
125
+ if (m) cfg.model = m;
126
+ }
127
+ if (cfg.routing && cfg.routing.enabled) {
128
+ if (!cfg.routing.planner) {
129
+ const p = pickFamilyModel(cached, 'pro');
130
+ cfg.routing.planner = p || DEFAULT_PLANNER_MODEL;
131
+ if (!p) warnCachedFallback('routing.planner', DEFAULT_PLANNER_MODEL);
132
+ }
133
+ if (!cfg.routing.executor) {
134
+ const e = pickFamilyModel(cached, 'flash');
135
+ cfg.routing.executor = e || DEFAULT_EXECUTOR_MODEL;
136
+ if (!e) warnCachedFallback('routing.executor', DEFAULT_EXECUTOR_MODEL);
137
+ }
138
+ }
139
+ return cfg;
140
+ }
141
+
142
+ /**
143
+ * 校验显示别名(v0.6.20 §3.69):`config.modelLabels` 只影响界面显示,**不参与任何出网请求**。
144
+ * 非法项忽略并 warn(不阻断启动);非对象/空值也在 normalizeModelLabels 里统一处理。
145
+ * 返回的是同一个对象(就地归一化),不新建配置副本。
146
+ * @param {any} cfg
147
+ */
148
+ function applyModelLabels(/** @type {any} */ cfg) {
149
+ if (cfg && typeof cfg === 'object' && Object.prototype.hasOwnProperty.call(cfg, 'modelLabels')) {
150
+ cfg.modelLabels = normalizeModelLabels(cfg.modelLabels);
151
+ }
152
+ return cfg;
153
+ }
154
+
90
155
  /** 读取配置对象(不存在/损坏返回 null);返回值为用户可编辑的任意 JSON 配置,类型不定
91
156
  *
92
157
  * 注意(v0.6.3 / H-7):null **同时**表示「不存在」与「读不出来」。凡是要**写**配置的调用点,
@@ -94,7 +159,7 @@ export function quarantineCorruptConfig(reason) {
94
159
  * @returns {any} */
95
160
  export function loadConfig() {
96
161
  const r = readConfigStrict();
97
- return r.ok ? r.data : null;
162
+ return r.ok ? applyModelLabels(applyCachedModelDefaults(r.data)) : null;
98
163
  }
99
164
 
100
165
  /**
@@ -121,12 +186,16 @@ export function loadConfigForWrite(why = '某个写配置的命令') {
121
186
  * 不再要求先去终端跑 mingdao init)。CLI 的 mingdao init 向导不受影响。 */
122
187
  export function ensureMinimalConfig() {
123
188
  const strict = readConfigStrict();
124
- if (strict.ok && strict.data) return strict.data;
189
+ if (strict.ok && strict.data) return applyModelLabels(strict.data);
125
190
  // v0.6.3(H-7):存在但读不出来 → 先备份再写,绝不让"首启自动建配置"吃掉用户配置
126
191
  if (strict.exists && !strict.ok) quarantineCorruptConfig(strict.error || '未知原因');
127
192
  ensureHome();
128
193
  const pp = PROVIDERS['deepseek'] || Object.values(PROVIDERS)[0];
129
- const model = modelPreset(DEFAULT_MODEL) ? DEFAULT_MODEL : pp.models[0];
194
+ // v0.6.19(§3.67):首选最近一次 API 名单里的 flash 官方名;没有缓存才用家族代表名。
195
+ // (首次运行还没填 Key,API 拉不到,此时代表名就是合理的兜底,后续拉到官方名会自动替换。)
196
+ const officialFlash = pickFamilyModel(cachedModelNames('deepseek'), 'flash');
197
+ const model = officialFlash || (modelPreset(DEFAULT_MODEL) ? DEFAULT_MODEL : pp.models[0]);
198
+ if (!officialFlash) warnCachedFallback('默认模型', model);
130
199
  const cfg = {
131
200
  provider: 'deepseek',
132
201
  model,
@@ -212,25 +281,28 @@ export async function runWizard(io) {
212
281
  if (models) io.print(`✓ API Key 验证通过:该服务商线上可用模型 ${models.length} 个。`);
213
282
  else if (verifyError) io.print('(已跳过验证,稍后可在 WebUI 设置面板「刷新模型」处再次校验)');
214
283
 
215
- // ③ 选择模型(允许暂时跳过——不写 config.model,进入后默认用该服务商首个模型,/model 可随时换)
284
+ // ③ 选择模型(允许暂时跳过——跳过时默认用 **API 名单里的 flash 官方名**,/model 可随时换)
216
285
  let model = null;
217
286
  if (provider === 'custom') {
218
287
  const m = await io.ask('③ 模型名(可留空跳过,稍后 /model 再选):');
219
288
  model = m.trim() || null;
220
289
  } else {
221
- const options = [{ value: '__skip__', label: '暂时跳过(稍后 /model 再选)' }];
290
+ const options = [{ value: '__skip__', label: '暂时跳过(用 API 返回的官方默认名,/model 可换)' }];
222
291
  for (const m of models || pp.models) {
223
292
  const preset = modelPreset(m);
224
- options.push({ value: m, label: preset ? `${m} — ${preset.label}` : m });
293
+ options.push({ value: m, label: preset ? `${m} — ${preset.label}` : `${m}(能力未知(新版本),仍可选用)` });
225
294
  }
226
295
  options.push({ value: '__custom__', label: '自定义模型名(手动输入)' });
227
296
  const choice = await io.choose('③ 选择模型:', options);
228
- if (choice === '__skip__') model = null;
297
+ if (choice === '__skip__') model = pickFamilyModel(models || [], 'flash');
229
298
  else if (choice === '__custom__') model = (await io.ask('模型名:')).trim() || null;
230
299
  else model = choice;
231
300
  }
301
+ const chosenPreset = model ? modelPreset(model) : null;
232
302
  if (model) io.print(`✓ 已选择模型:${model}`);
233
303
  else io.print('✓ 已跳过模型选择:进入后自动使用该服务商默认模型,输入 /model 可随时切换。');
304
+ if (chosenPreset?.capabilityUnknown) io.print('⚠ 该模型是 API 返回的新版本:能力未知(按家族规格保守估算,仍可选用)。');
305
+ if (!models && provider !== 'custom') io.print('(未取得 API 模型名单:模型名用内置家族代表名——离线兜底,模型名可能不是最新的)');
234
306
 
235
307
  const perm = await io.choose('权限模式(写文件 / 执行命令时):', [
236
308
  { value: 'ask', label: 'ask — 每次询问(推荐,最安全)' },
@@ -245,12 +317,19 @@ export async function runWizard(io) {
245
317
  ]);
246
318
 
247
319
  let routing = null;
248
- if (modelPreset(DEFAULT_PLANNER_MODEL) && modelPreset(DEFAULT_EXECUTOR_MODEL)) {
320
+ // v0.6.19(§3.67):路由默认名同样以 API 名单里的官方名为准(planner→pro 家族、executor→flash 家族);
321
+ // 名单不可用才退回家族代表名,并在下面明确提示「兜底」。
322
+ const plannerDefault = pickFamilyModel(models || [], 'pro') || DEFAULT_PLANNER_MODEL;
323
+ const executorDefault = pickFamilyModel(models || [], 'flash') || DEFAULT_EXECUTOR_MODEL;
324
+ if (modelPreset(plannerDefault) && modelPreset(executorDefault)) {
249
325
  const routeChoice = await io.choose('自动模型路由(规划类任务→pro,执行类→flash):', [
250
326
  { value: 'on', label: 'on — 开启(省钱又高效,推荐)' },
251
327
  { value: 'off', label: 'off — 关闭(始终用当前模型)' },
252
328
  ]);
253
- if (routeChoice === 'on') routing = { enabled: true, planner: DEFAULT_PLANNER_MODEL, executor: DEFAULT_EXECUTOR_MODEL };
329
+ if (routeChoice === 'on') {
330
+ routing = { enabled: true, planner: plannerDefault, executor: executorDefault };
331
+ if (!models) io.print(`⚠ 路由默认名走兜底(未取得 API 名单):planner=${plannerDefault} / executor=${executorDefault}——模型名可能不是最新的。`);
332
+ }
254
333
  }
255
334
 
256
335
  const preset = model ? modelPreset(model) : null;
@@ -274,7 +353,7 @@ export async function runWizard(io) {
274
353
  `服务商 ${provider}(${pp.label})`,
275
354
  model ? `模型 ${model}` : '模型 (暂未选择,进入后 /model 随时切换)',
276
355
  `权限 ${perm} · 沙箱 ${sandbox}`,
277
- `路由 ${routing ? '自动(pro⇄flash)' : '关闭'}`,
356
+ `路由 ${routing ? `自动(${routing.planner} ⇄ ${routing.executor})` : '关闭'}`,
278
357
  `密钥 ${apiKey ? '凭证库 ' + maskKey(apiKey) : '环境变量 ' + (pp.envKey || 'MINGDAO_API_KEY')}`,
279
358
  `保存于 ${configPath()}`,
280
359
  ]);
package/src/cost-guard.js CHANGED
@@ -125,7 +125,7 @@ export function checkCostGuard(modelName) {
125
125
  // P0-4(v0.4.5):无价格数据时护栏无法累计——显式告警而非静默放行(绝不当「没花钱」)
126
126
  return {
127
127
  blocked: false,
128
- message: '⚠ 费用护栏:当前模型无价格数据,今日费用无法累计、dailyLimitYuan 不生效——请在 config.pricing.overrides 为模型补充定价,或改用有价的 DeepSeek 模型(deepseek-v4-pro/flash)。',
128
+ message: '⚠ 费用护栏:当前模型无价格数据,今日费用无法累计、dailyLimitYuan 不生效——请在 config.pricing.overrides 为模型补充定价,或改用有价的 DeepSeek pro / flash 家族模型(具体名称以 API 返回的官方名为准)。',
129
129
  };
130
130
  }
131
131
  if (st.overLimit && st.action === 'block') {
package/src/help.js CHANGED
@@ -56,7 +56,7 @@ export function helpLines({ variant = 'cli', home = '' } = {}) {
56
56
  [' mingdao --continue 继续最近一次会话', null],
57
57
  [' mingdao --journal 新会话带上最近会话日志(默认不注入,新会话全新开始)', null],
58
58
  [' mingdao --resume 从会话列表选择恢复', null],
59
- [' mingdao --model <模型名> 指定模型,例如 deepseek-v4-pro', null],
59
+ [' mingdao --model <模型名> 指定模型(pro / flash 家族;具体名称以 API 返回的官方名为准)', null],
60
60
  ...cliOnly,
61
61
  [' mingdao init 初始化配置向导', null],
62
62
  [' mingdao update [--check] 一键自更新(git 安装形态;--check 只对比版本)', null],
@@ -9,7 +9,7 @@ import fs from 'node:fs';
9
9
  import { safeFetchText } from './safe-fetch.js';
10
10
  import path from 'node:path';
11
11
  import { mingdaoHome, ensureHome } from './config.js';
12
- import { PROVIDERS, MODELS } from './models.js';
12
+ import { PROVIDERS, modelPreset, familyRepresentative, pickFamilyModel, modelCapabilityUnknown, normalizeModelLabels, modelDisplayLabel } from './models.js';
13
13
  import { getStoredKey } from './credentials.js';
14
14
  import { isLocalBaseUrl, resolveModelCaps } from './model-caps.js';
15
15
  import { resolveProviderConfig, acquireLocalSlot, localQueueTimeoutMs } from './providers/index.js';
@@ -1005,6 +1005,128 @@ export function isDiscoveredModel(/** @type {any} */ name, /** @type {string} */
1005
1005
  return false;
1006
1006
  }
1007
1007
 
1008
+ // ---------------------------------------------------------------------------
1009
+ // v0.6.19(§3.67):**默认模型 = API 实时返回的官方名**
1010
+ //
1011
+ // 以前 DEFAULT_MODEL / DEFAULT_PLANNER_MODEL 是写死的具体版本,厂家升版本就要改代码;
1012
+ // 现在默认值在运行时解析:
1013
+ // ① 用户显式配置(config.routing.planner/executor、config.model)优先——不覆盖用户意图;
1014
+ // ② 否则从该服务商当前 `/models` 名单里按**家族模式**挑(planner→pro、executor→flash);
1015
+ // ③ 名单里没有该家族 / API 不可达时,才退回家族代表名,并明确提示「兜底」。
1016
+ // 提示会出现在 console.warn(服务端日志)与 /api/models 的 note 字段(界面 banner 可读)。
1017
+ // ---------------------------------------------------------------------------
1018
+
1019
+ /** 离线兜底提示(测试与界面共用同一句,避免两处漂移)。 */
1020
+ export const OFFLINE_MODEL_NOTE = '离线兜底:模型名可能不是最新的(API 不可达,已使用内置家族代表名)';
1021
+
1022
+ /** 角色 → 家族(planner 用 pro,executor/model 用 flash)。 */
1023
+ export const MODEL_ROLE_FAMILY = /** @type {Record<string, string>} */ ({ planner: 'pro', executor: 'flash', model: 'flash' });
1024
+
1025
+ /** 兜底告警只打一次/角色,避免每轮刷屏(但**不能不打**,否则用户不知道用的是旧名)。 */
1026
+ const warnedFallback = new Set();
1027
+
1028
+ function warnFallback(/** @type {string} */ role, /** @type {string} */ reason, /** @type {string} */ model) {
1029
+ const key = `${role}:${reason}`;
1030
+ if (warnedFallback.has(key)) return;
1031
+ warnedFallback.add(key);
1032
+ console.warn(`[MingDao] ⚠ ${role} 默认模型走兜底(${reason}):使用内置家族代表名 ${model}——模型名可能不是最新的,请检查 API 连通性/密钥。`);
1033
+ }
1034
+
1035
+ /**
1036
+ * 把模型列表里「离线兜底 / 能力未知」的条目标成一句提示,供 /api/models 与界面 banner 使用。
1037
+ * @param {any} models
1038
+ * @returns {string|null}
1039
+ */
1040
+ export function modelsCatalogNote(/** @type {any} */ models) {
1041
+ const list = Array.isArray(models) ? models : [];
1042
+ if (list.some((m) => m && m.offlineFallback === true)) return OFFLINE_MODEL_NOTE;
1043
+ const unknown = list.filter((m) => m && m.capabilityUnknown === true).length;
1044
+ if (unknown) return `${unknown} 个模型是 API 返回的新版本:能力未知(按家族规格保守估算,仍可选用)`;
1045
+ return null;
1046
+ }
1047
+
1048
+ /**
1049
+ * 解析某个角色的默认模型(**以 API 实时返回的官方名为准**)。
1050
+ * @param {any} cfg
1051
+ * @param {'planner'|'executor'|'model'} [role]
1052
+ * @param {{force?: boolean}} [opts]
1053
+ * @returns {Promise<{model: string, role: string, family: string, source: 'config'|'api'|'fallback', official: boolean, fallback: boolean, offline: boolean, note: string|null}>}
1054
+ */
1055
+ export async function resolveDefaultModel(/** @type {any} */ cfg, /** @type {any} */ role = 'executor', { force = false } = {}) {
1056
+ const r = String(role || 'executor');
1057
+ const family = MODEL_ROLE_FAMILY[r] || 'flash';
1058
+ const explicit = r === 'planner' ? cfg?.routing?.planner : r === 'executor' ? cfg?.routing?.executor : cfg?.model;
1059
+ if (explicit) {
1060
+ return {
1061
+ model: String(explicit),
1062
+ role: r,
1063
+ family,
1064
+ source: 'config',
1065
+ official: false,
1066
+ fallback: false,
1067
+ offline: false,
1068
+ note: null,
1069
+ };
1070
+ }
1071
+ const provider = String(cfg?.provider || 'deepseek');
1072
+ // 既有策略(v0.6.13):**自定义端点不主动探测 `GET /models`**——真实世界里很多自定义网关
1073
+ // 只实现 chat,`/models` 可能返回空 body/非 JSON(e2e-web 的 mock 就会因此 JSON.parse 崩)。
1074
+ // 自定义端点/未内置的服务商还没有可枚举的名单元数据,直接走家族代表名兜底(不发任何请求)。
1075
+ if (provider === 'custom' || !/** @type {any} */ (PROVIDERS)[provider]) {
1076
+ const rep = familyRepresentative(family);
1077
+ return {
1078
+ model: rep,
1079
+ role: r,
1080
+ family,
1081
+ source: 'fallback',
1082
+ official: false,
1083
+ fallback: true,
1084
+ offline: false,
1085
+ note: `自定义端点/未内置服务商不主动探测 /models,使用内置家族代表名 ${rep}(可用 config.routing.* 显式指定端点自己的模型名)`,
1086
+ };
1087
+ }
1088
+ let fetched = /** @type {any} */ ({ error: '该服务商未设置 API Key' });
1089
+ try {
1090
+ fetched = await fetchProviderModels(cfg, provider, { force });
1091
+ } catch (/** @type {any} */ e) {
1092
+ fetched = { error: String(e?.message || e) };
1093
+ }
1094
+ if (Array.isArray(fetched?.models) && fetched.models.length) {
1095
+ const picked = pickFamilyModel(fetched.models, family);
1096
+ if (picked) {
1097
+ return {
1098
+ model: picked,
1099
+ role: r,
1100
+ family,
1101
+ source: 'api',
1102
+ official: true,
1103
+ fallback: false,
1104
+ offline: false,
1105
+ note: null,
1106
+ };
1107
+ }
1108
+ const rep = familyRepresentative(family);
1109
+ const note = `兜底:API 列表里没有 ${family} 家族模型,改用内置家族代表名 ${rep}`;
1110
+ warnFallback(r, 'API 列表无匹配家族', rep);
1111
+ return { model: rep, role: r, family, source: 'fallback', official: false, fallback: true, offline: false, note };
1112
+ }
1113
+ const rep = familyRepresentative(family);
1114
+ warnFallback(r, String(fetched?.error || '未知原因'), rep);
1115
+ return { model: rep, role: r, family, source: 'fallback', official: false, fallback: true, offline: true, note: OFFLINE_MODEL_NOTE };
1116
+ }
1117
+
1118
+ /**
1119
+ * 一次解析 planner + executor 的默认值(WebUI 开启自动路由时调用)。
1120
+ * @param {any} cfg
1121
+ * @param {{force?: boolean}} [opts]
1122
+ */
1123
+ export async function resolveRoutingDefaults(/** @type {any} */ cfg, { force = false } = {}) {
1124
+ const [planner, executor] = await Promise.all([resolveDefaultModel(cfg, 'planner', { force }), resolveDefaultModel(cfg, 'executor', { force })]);
1125
+ const fellBack = [planner, executor].filter((x) => x.fallback);
1126
+ const note = planner.offline || executor.offline ? OFFLINE_MODEL_NOTE : fellBack.length ? fellBack.map((x) => x.note).filter(Boolean).join(';') : null;
1127
+ return { planner, executor, offline: planner.offline || executor.offline, note };
1128
+ }
1129
+
1008
1130
  // 合并可用模型列表:只含已设置 Key 的服务商;动态名单优先、预设回退;自定义模型恒在。
1009
1131
  //
1010
1132
  // v0.6.13(B):**每个条目都要能看出"来自哪个服务商/端点"**。此前自定义条目只有
@@ -1015,25 +1137,60 @@ export function isDiscoveredModel(/** @type {any} */ name, /** @type {string} */
1015
1137
  export async function availableModels(/** @type {any} */ cfg, /** @type {any} */ currentModel) {
1016
1138
  const out = [];
1017
1139
  const seen = new Set();
1140
+ // v0.6.20(§3.69):显示别名只用于界面。官方 /models 条目的 `label` 是**显示名**
1141
+ // (`config.modelLabels[id]`,没有映射就回退 id),`id`/`name` 才是 API 实际 id。
1142
+ // 原来的能力/来源说明保留到 `detail` 字段(下拉 title / 提示可读)。
1143
+ // 出网请求的 model 字段只从 config.model / state.modelName 取,**永不**从 label 取。
1144
+ const modelLabels = normalizeModelLabels(cfg?.modelLabels, { warn: false });
1018
1145
  for (const [pname, pp] of Object.entries(PROVIDERS)) {
1019
1146
  if (pname === 'custom') continue;
1020
1147
  if (!providerHasKey(pname)) continue;
1021
1148
  const dynamic = await fetchProviderModels(cfg, pname);
1149
+ // §3.67:拉取失败时用内置名单,但必须**明确标成离线兜底**——
1150
+ // 否则界面会把这些可能过期的名字当成「官方实时名单」展示。
1151
+ const offline = Boolean(dynamic?.error) && !dynamic?.models?.length;
1022
1152
  const names = dynamic.models?.length ? dynamic.models : pp.models || [];
1023
1153
  for (const n of names) {
1024
1154
  if (seen.has(n)) continue;
1025
1155
  seen.add(n);
1026
- const preset = /** @type {any} */ (MODELS)[n];
1156
+ // 家族模式优先:`DeepSeek-V4.1-Flash 1017` 这类 API 官方名不在精确表里,但能力照样可解析。
1157
+ const preset = modelPreset(n);
1158
+ const capabilityUnknown = modelCapabilityUnknown(n);
1159
+ const label = offline
1160
+ ? `${n} — ${preset ? preset.label : '能力未知(新版本/非家族)'}(离线兜底,模型名可能不是最新的)`
1161
+ : preset
1162
+ ? `${n} — ${preset.label}`
1163
+ : `${n}(线上最新;能力未知(新版本),仍可选用)`;
1164
+ // 显示名与 API id 分离:label 可以来自 config.modelLabels,请求仍用 name/id。
1165
+ const displayLabel = modelDisplayLabel(modelLabels, n);
1027
1166
  out.push({
1028
1167
  name: n,
1029
- label: preset ? `${n} — ${preset.label}` : `${n}(线上最新)`,
1168
+ // v0.6.20(§3.69):显式保留 API 实际 id(前端 title 用「API id: <id>」随时对上 API)
1169
+ id: n,
1170
+ // label 是**给人看的显示名**;没有配 modelLabels 时就是 id 本身。
1171
+ label: displayLabel,
1172
+ // 原 label 的能力/来源说明(离线兜底、能力未知、来源标注)挪到这里,信息不丢。
1173
+ detail: label,
1174
+ // 只有"映射生效、显示名 != id"时才标来源,避免把自定义条目的组合展示名也误标成 config。
1175
+ ...(displayLabel !== n ? { labelSource: 'config' } : {}),
1030
1176
  provider: pname,
1031
1177
  providerLabel: pp.label,
1032
- dynamic: !(/** @type {any} */ (MODELS))[n],
1178
+ // dynamic 的既有语义(前端/旧测试读它):不在任何内置模式表里。家族命中不等于 dynamic。
1179
+ dynamic: !preset,
1033
1180
  // 来源标注(B):内置预设 vs 服务商线上名单——两者都是"该服务商自己的模型"
1034
1181
  official: Boolean(preset),
1182
+ family: preset?.family || null,
1183
+ capabilityUnknown,
1184
+ offlineFallback: offline,
1185
+ // 这一行保持原样:既有变异批 batch28 以它作锚点;离线来源在下一行覆盖。
1035
1186
  source: preset ? 'preset' : 'discovered',
1036
- sourceLabel: preset ? `内置预设 · ${pp.label}` : `线上名单 · ${pp.label}`,
1187
+ ...(offline ? { source: 'offline' } : {}),
1188
+ sourceLabel: offline
1189
+ ? `离线兜底 · ${pp.label}`
1190
+ : preset
1191
+ ? `线上名单 · ${pp.label}(家族模式匹配)`
1192
+ : `线上名单 · ${pp.label}`,
1193
+ note: offline ? OFFLINE_MODEL_NOTE : capabilityUnknown ? '能力未知(新版本):按家族规格保守估算,仍可选用' : undefined,
1037
1194
  });
1038
1195
  }
1039
1196
  }
@@ -1041,10 +1198,15 @@ export async function availableModels(/** @type {any} */ cfg, /** @type {any} */
1041
1198
  if (seen.has(cmName)) continue;
1042
1199
  seen.add(cmName);
1043
1200
  const src = customSourceOf(cfg, cmName);
1201
+ // 自定义条目保留既有组合展示名(来源必须在展示名里,且 test/smoke §139 以此为准);
1202
+ // modelLabels 只映射服务商 /models 的官方 id,不改变自定义条目。
1203
+ const customLabel = `${cmName} — ${cm.label || '自定义模型'}(${src.sourceLabel})`;
1044
1204
  out.push({
1045
1205
  name: cmName,
1206
+ id: cmName,
1046
1207
  // 展示名里也带上来源(不只是 title/tooltip):下拉标题行、设置面板、日志三处一致
1047
- label: `${cmName} — ${cm.label || '自定义模型'}(${src.sourceLabel})`,
1208
+ label: customLabel,
1209
+ detail: customLabel,
1048
1210
  provider: 'custom',
1049
1211
  providerLabel: src.providerLabel,
1050
1212
  custom: true,
@@ -1058,14 +1220,19 @@ export async function availableModels(/** @type {any} */ cfg, /** @type {any} */
1058
1220
  });
1059
1221
  }
1060
1222
  if (currentModel && !out.some((m) => m.name === currentModel)) {
1223
+ const currentLabel = `${currentModel}(当前配置)`;
1061
1224
  out.unshift({
1062
1225
  name: currentModel,
1063
- label: `${currentModel}(当前配置)`,
1226
+ id: currentModel,
1227
+ label: currentLabel,
1228
+ detail: currentLabel,
1064
1229
  provider: 'current',
1065
1230
  providerLabel: '当前',
1066
1231
  official: false,
1067
1232
  source: 'current',
1068
1233
  sourceLabel: '当前配置(不在任何已知名单里)',
1234
+ family: modelPreset(currentModel)?.family || null,
1235
+ capabilityUnknown: modelCapabilityUnknown(currentModel),
1069
1236
  note: '该名称不在任何服务商名单/自定义条目里——只是 config.json 里当前写着的模型名。',
1070
1237
  });
1071
1238
  }