mingdao-harness 0.6.11 → 0.6.13

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.
@@ -11,9 +11,612 @@ import path from 'node:path';
11
11
  import { mingdaoHome, ensureHome } from './config.js';
12
12
  import { PROVIDERS, MODELS } from './models.js';
13
13
  import { getStoredKey } from './credentials.js';
14
+ import { isLocalBaseUrl, resolveModelCaps } from './model-caps.js';
15
+ import { resolveProviderConfig } from './providers/index.js';
14
16
 
15
17
  const TTL_MS = 60 * 60 * 1000;
16
18
 
19
+ // ---------------------------------------------------------------------------
20
+ // v0.6.13(A/B):自定义端点的**来源标注**(B)与**能力/延迟预检**(A 追加)
21
+ //
22
+ // B(负责人实测:模型下拉里出现下游环境的东西,看不出是不是官方模型):
23
+ // 下拉数据来源核实(本函数即唯一来源):GET /api/state → availableModels() →
24
+ // · 内置服务商:PROVIDERS 名单 + 该服务商 /models 的线上名单(缓存在 model-cache.json,TTL 1h);
25
+ // · 自定义:**只来自 config.customModels** —— fetchProviderModels 显式跳过 'custom',
26
+ // 自定义端点的 /models 从不写进 model-cache.json,因此**不存在跨环境运行时串数据**。
27
+ // 也就是说:下拉里那些条目是"配置驱动"的(谁往 config.customModels 里写了什么,就列什么),
28
+ // 但此前它们只标了 providerLabel='自定义'、**看不出来自哪个端点**,于是容易被当成官方模型。
29
+ // 现在每条自定义条目都带 source/sourceLabel/endpoint,并把 providerLabel 直接写成
30
+ // 「自定义端点 · host」——下拉按 providerLabel 分组,分组名本身就是来源。
31
+ //
32
+ // A(负责人本机 35B 卡住 4 小时、日志零行):光有超时不够,"这个端点到底行不行"必须在
33
+ // 长任务开始前几秒内就有答案。probeEndpoint() 打一次**极小**请求(1 条 user + 1 个工具声明 +
34
+ // max_tokens 16,流式取首帧),给出三态判定 + 实测 TTFT + 引擎实际加载的模型名。
35
+ // ---------------------------------------------------------------------------
36
+
37
+ /** 预检结果缓存 TTL(10 分钟):端点能力/延迟变化不快,但也不能一辈子不重测。 */
38
+ const PROBE_TTL_MS = 10 * 60 * 1000;
39
+ /** 无进展看门狗的自适应下限/上限(见 adaptiveTimeouts)。
40
+ * 上限与 agent.js 的 DEFAULT_NO_PROGRESS_TIMEOUT_MS 相同——由冒烟断言钉住两者一致(单源守卫)。 */
41
+ export const PROGRESS_MIN_MS = 600000;
42
+ export const PROGRESS_MAX_MS = 3600000;
43
+
44
+ /**
45
+ * 从 baseUrl 取一个给人看的端点标识(host + 路径前缀)。
46
+ * @param {any} baseUrl
47
+ */
48
+ export function endpointLabel(/** @type {any} */ baseUrl) {
49
+ try {
50
+ const u = new URL(String(baseUrl || ''));
51
+ if (!u.hostname) return '';
52
+ const p = u.pathname && u.pathname !== '/' ? u.pathname.replace(/\/+$/, '') : '';
53
+ return `${u.host}${p}`;
54
+ } catch {
55
+ return '';
56
+ }
57
+ }
58
+
59
+ /**
60
+ * 自定义条目的来源描述(纯函数,**B 的核心**)。
61
+ * 判据走 resolveProviderConfig(`custom:<名>` = 声明了传输字段的端点;否则只是能力覆盖)——
62
+ * 不在本文件再抄一份"哪些字段算端点声明"的键表(那正是本项目反复栽过的"同一规则多份实现")。
63
+ * @param {any} cfg @param {string} cmName
64
+ * @returns {{ source: 'custom-endpoint'|'custom-capability', sourceLabel: string, endpoint: string,
65
+ * providerLabel: string, isLocalEndpoint: boolean, note: string }}
66
+ */
67
+ export function customSourceOf(/** @type {any} */ cfg, /** @type {string} */ cmName) {
68
+ const cm = (cfg?.customModels || {})[cmName] || {};
69
+ let pc = /** @type {any} */ (null);
70
+ try {
71
+ pc = resolveProviderConfig(cfg, cmName);
72
+ } catch {}
73
+ const endpoint = endpointLabel(pc?.baseUrl || cm.baseUrl || '');
74
+ // `custom:<名>` 是 providers/index.js 对"声明式端点"的命名;`custom:`/`custom/` 前缀的名字同理。
75
+ const isEndpoint = Boolean(pc && /^custom[:/]/.test(String(pc.name || ''))) || /^custom[:/]/i.test(String(cmName));
76
+ const local = isLocalBaseUrl(pc?.baseUrl || cm.baseUrl || '');
77
+ if (isEndpoint) {
78
+ const where = endpoint || '(未写明 baseUrl)';
79
+ const sourceLabel = `自定义端点 · ${where}`;
80
+ return {
81
+ source: 'custom-endpoint',
82
+ sourceLabel,
83
+ endpoint,
84
+ providerLabel: sourceLabel,
85
+ isLocalEndpoint: local,
86
+ note: `该条目来自 config.customModels,是**你自己配置的端点**(${where})${local ? '(本机/内网地址)' : ''}——不是内置官方模型,也不随内核发版变化。`,
87
+ };
88
+ }
89
+ const via = String(cm.provider || pc?.name || '').trim();
90
+ const sourceLabel = `自定义(能力覆盖${via ? ` · 沿用 ${via} 端点` : ''})`;
91
+ return {
92
+ source: 'custom-capability',
93
+ sourceLabel,
94
+ endpoint,
95
+ providerLabel: sourceLabel,
96
+ isLocalEndpoint: local,
97
+ note:
98
+ `该条目只声明能力(contextWindow/maxOutputTokens/vision 等),**不改变请求去向**` +
99
+ `${via ? `——请求仍走 ${via}` : ''};它来自 config.customModels,不是内置官方模型。`,
100
+ };
101
+ }
102
+
103
+ /** @type {Map<string, {at: number, value: any}>} */
104
+ const probeCache = new Map();
105
+ /** @type {Map<string, Promise<any>>} */
106
+ const probeInflight = new Map();
107
+
108
+ /** 引擎加载的模型名与配置项名是否"对不上"(纯函数,便于断言)。
109
+ * 归一化:小写 + 取 basename + 去掉 .gguf 后缀;一边包含另一边即视为同一个(引擎常带路径/量化后缀)。 */
110
+ export function modelNameMismatch(/** @type {any} */ configured, /** @type {any} */ loaded) {
111
+ const norm = (/** @type {any} */ s) =>
112
+ String(s || '')
113
+ .trim()
114
+ .toLowerCase()
115
+ .split(/[\\/]/)
116
+ .pop()
117
+ ?.replace(/\.gguf$/i, '')
118
+ .replace(/[^a-z0-9._-]+/g, '');
119
+ const c = norm(configured);
120
+ const list = /** @type {string[]} */ ((Array.isArray(loaded) ? loaded : [loaded]).map(norm).filter(Boolean));
121
+ if (!c || !list.length) return false; // 无从比较时不误报
122
+ return !list.some((/** @type {string} */ l) => l.includes(c) || c.includes(l));
123
+ }
124
+
125
+ /**
126
+ * 端点能力/延迟预检(三态:ok-tools / ok-textonly / unreachable)。
127
+ * 结果按「端点 + 模型名」缓存 10 分钟,并发去重。
128
+ * @param {any} cfg @param {string} modelName
129
+ * @param {{ timeoutMs?: number, force?: boolean, fetchImpl?: any, credentialProbe?: boolean }} [opts]
130
+ */
131
+ /**
132
+ * 端点能力/延迟预检(A 追加;三态 × 三样本,**不允许一次结果当结论**)。
133
+ *
134
+ * 设计依据(负责人本机三端点实测):
135
+ * · 60091 MLocalModel3.6.2(llama.cpp 35B Q4_K_M):同一请求同一端点两次结果相反——
136
+ * 第一次 2.7s 且 tool_calls=有,第二次 23.9s 且 tool_calls=无;引擎回的 model 是**完整文件路径**
137
+ * (/Users/…/mLocalModel3.6.2.gguf),与配置项名 MLocalModel3.6.2 不一致。
138
+ * → 所以:① 工具调用必须重复 N 次取"稳定/不稳定/不支持"三态,不能一次定论;
139
+ * ② TTFT 必须落账(2.7s→23.9s 这种跳变就是"引擎被占住/排队"的信号);
140
+ * ③ 配置名与引擎名不一致要写出来(避免"配的是 A、跑的是 B")。
141
+ * · 8081 mtplx-qwen38-27b-optimized-quality:小请求 2.9s、**不返回 tool_calls**;2009 tokens 长提示 5.5s。
142
+ * · 长提示实测 1.8-5.5s(2009 tokens)——"35B prefill 要几小时"**不成立**:慢/卡不是模型体量的问题,
143
+ * 更像引擎 slot 被占/排队或请求形态让引擎进了异常态;因此预检除了 TTFT,还要测一次**参考 prefill**
144
+ * (固定 ~2000 tokens),并与**本机历史值**比对,显著变慢就提示"可能仍有旧请求占着,建议重启引擎"。
145
+ *
146
+ * 返回(关键字段):
147
+ * state 'ok-tools' | 'unstable-tools' | 'ok-textonly' | 'unreachable'(粗三态,兼容旧调用)
148
+ * toolState 'stable-tools' | 'unstable-tools' | 'no-tools' | 'unknown'(细三态,N/3)
149
+ * toolCallHits / toolCallRuns 命中次数 / 实际样本数
150
+ * ttftMs 三次小请求的首帧中位数(ms)
151
+ * prefillMs 参考 prefill(prefillTokens 个 token)的耗时(ms)
152
+ * baseline / slowdown 历史基线(持久化在 <home>/model-probe.json)与本次倍数
153
+ * nameMismatch / loadedModel 引擎实际加载的模型名与配置项名是否对不上
154
+ * note 一行给人看的结论(UI + web-server.log 直接用)
155
+ *
156
+ * @param {any} cfg @param {string} modelName
157
+ * @param {{ timeoutMs?: number, force?: boolean, fetchImpl?: any, repeats?: number,
158
+ * prefillTokens?: number, budgetMs?: number, persist?: boolean }} [opts]
159
+ */
160
+ export async function probeEndpoint(/** @type {any} */ cfg, /** @type {string} */ modelName, opts = {}) {
161
+ const {
162
+ timeoutMs = 10000,
163
+ force = false,
164
+ fetchImpl = null,
165
+ repeats = 3,
166
+ prefillTokens = 2000,
167
+ budgetMs = 35000,
168
+ persist = true,
169
+ } = opts;
170
+ const pc = /** @type {any} */ (resolveProviderConfig(cfg, modelName) || {});
171
+ const base = String(pc.baseUrl || '').replace(/\/+$/, '');
172
+ const key = `${base}|${modelName}`;
173
+ const hit = probeCache.get(key);
174
+ if (!force && hit && Date.now() - hit.at < PROBE_TTL_MS) return hit.value;
175
+ if (!force && probeInflight.has(key)) return probeInflight.get(key);
176
+ const doFetch = fetchImpl || globalThis.fetch;
177
+ const run = (async () => {
178
+ const startedAt = Date.now();
179
+ const left = () => Math.max(1500, Math.min(timeoutMs, budgetMs - (Date.now() - startedAt)));
180
+ const info = /** @type {any} */ ({
181
+ at: Date.now(),
182
+ endpoint: endpointLabel(base),
183
+ isLocal: isLocalBaseUrl(base),
184
+ provider: String(pc.name || ''),
185
+ configuredModel: modelName,
186
+ loadedModels: [],
187
+ loadedModel: null,
188
+ nameMismatch: false,
189
+ ttftSamples: [],
190
+ ttftMs: null,
191
+ prefillTokens,
192
+ prefillMs: null,
193
+ toolCallHits: 0,
194
+ toolCallRuns: 0,
195
+ toolCallInconclusive: 0,
196
+ toolCallFinishes: [],
197
+ toolState: 'unknown',
198
+ ttftBaselineMs: null,
199
+ prefillBaselineMs: null,
200
+ slowdown: null,
201
+ // v0.6.13(问题 2):引擎自述上下文(本地端点读 GET /props)——读不到就是 null,**不猜**
202
+ engineCtx: null,
203
+ engineCtxSource: null,
204
+ engineProbed: false,
205
+ configuredWindow: (() => {
206
+ try {
207
+ return resolveModelCaps(cfg, modelName).contextWindow;
208
+ } catch {
209
+ return null;
210
+ }
211
+ })(),
212
+ state: /** @type {string} */ ('unreachable'),
213
+ error: /** @type {string|null} */ (null),
214
+ note: '',
215
+ partial: false,
216
+ });
217
+ if (!base) {
218
+ return { ...info, error: '该模型没有可用的 baseUrl(未配置端点)', note: '端点未配置:请先在 ⚙ 设置里填 baseUrl/API Key。' };
219
+ }
220
+ const headers = { 'Content-Type': 'application/json', ...(pc.apiKey ? { Authorization: `Bearer ${pc.apiKey}` } : {}) };
221
+ /** 单次 HTTP(带超时);返回 { ok, status, text } 或抛错 */
222
+ const once = async (/** @type {any} */ url, /** @type {any} */ init, /** @type {any} */ ms) => {
223
+ const ac = new AbortController();
224
+ const t = setTimeout(() => ac.abort(new Error(`预检超时(${Math.round(ms / 1000)}s)`)), ms);
225
+ try {
226
+ return await fetchImplOr(doFetch, url, { ...init, signal: ac.signal });
227
+ } finally {
228
+ clearTimeout(t);
229
+ }
230
+ };
231
+ // ① 引擎"实际加载的是什么"——**从 chat 响应的 `model` 字段读**,不额外打 `GET /models`。
232
+ // 为什么不打 /models(v0.6.13 实测取舍):
233
+ // · 有的端点根本没实现它(chatflow 网关常见 404);
234
+ // · 更要紧的是"只实现了 chat 的端点"不该因为一次探测请求而报错——预检必须无可侵入;
235
+ // · 流式响应里就带着引擎真正的模型名:llama.cpp 直接回**完整文件路径**
236
+ // (/Users/…/mLocalModel3.6.2.gguf,负责人实测),语义与 /models 等价;
237
+ // · 少一次请求、少一个超时点。
238
+ // 名字对不上(配置项名 ≠ 引擎回的模型名)时把两者都写进结论——避免"配的是 A、跑的是 B"。
239
+
240
+ // ② 工具调用探针(重复 N 次取三态):提示词**明确要求调用工具**,同时看 tool_calls 与 finish_reason
241
+ const probeTool = {
242
+ type: 'function',
243
+ function: {
244
+ name: 'mingdao_probe',
245
+ description: '预检探针:确认该端点是否支持 function calling',
246
+ parameters: { type: 'object', properties: { v: { type: 'integer', description: '任意整数' } }, required: ['v'] },
247
+ },
248
+ };
249
+ const chunkHasTool = (/** @type {string} */ s) => /"tool_calls"\s*:/.test(s);
250
+ let lastError = /** @type {string|null} */ (null);
251
+ let connected = false;
252
+ for (let i = 0; i < Math.max(1, repeats); i++) {
253
+ if (Date.now() - startedAt > budgetMs) { info.partial = true; break; }
254
+ const t0 = Date.now();
255
+ try {
256
+ const res = await once(
257
+ `${base}/chat/completions`,
258
+ {
259
+ method: 'POST',
260
+ headers,
261
+ body: JSON.stringify({
262
+ model: modelName,
263
+ // 提示词必须**明确要求工具**:否则"模型恰好直接回答"会被误判成不支持工具调用
264
+ messages: [{ role: 'user', content: `请调用 mingdao_probe 工具,参数 v=${i + 1}。只调用工具,不要输出任何解释文字。` }],
265
+ tools: [probeTool],
266
+ tool_choice: 'auto',
267
+ // 256 而不是 16:思考型模型(本机 MLocalModel3.6.2 就是)会先输出思考内容,
268
+ // 16 个 token 必然被 finish_reason=length 截断 —— 那样探针会把"没测出来"误报成"不支持工具调用"。
269
+ max_tokens: 256,
270
+ stream: true,
271
+ }),
272
+ },
273
+ left()
274
+ );
275
+ connected = true;
276
+ if (!res || !res.ok) {
277
+ const status = Number(res?.status) || 0;
278
+ let detail = '';
279
+ try { detail = String(await res.text()).slice(0, 300); } catch {}
280
+ lastError = `HTTP ${status || '(无响应)'}${detail ? `:${detail}` : ''}`;
281
+ info.toolCallFinishes.push(`http-${status}`);
282
+ continue;
283
+ }
284
+ const r = await readProbeStream(res, { hasTool: chunkHasTool, t0 });
285
+ if (r.model && !info.loadedModel) {
286
+ info.loadedModel = String(r.model);
287
+ info.loadedModels = [String(r.model)];
288
+ info.nameMismatch = modelNameMismatch(modelName, [String(r.model)]);
289
+ }
290
+ if (r.ttftMs != null) info.ttftSamples.push(r.ttftMs);
291
+ const finish = String(r.finish || (r.toolCalls ? 'tool_calls' : 'stop'));
292
+ info.toolCallFinishes.push(finish);
293
+ // 样本必须**有结论**才算数:`length`(被 max_tokens 截断,思考型模型常见)既不是命中也不是未命中
294
+ const conclusive = r.toolCalls || finish === 'tool_calls' || finish === 'stop';
295
+ if (conclusive) {
296
+ info.toolCallRuns += 1;
297
+ if (r.toolCalls || finish === 'tool_calls') info.toolCallHits += 1;
298
+ } else {
299
+ info.toolCallInconclusive = (info.toolCallInconclusive || 0) + 1;
300
+ }
301
+ } catch (/** @type {any} */ e) {
302
+ lastError = String(e?.name === 'AbortError' || /aborted/i.test(String(e?.message)) ? `预检超时(${Math.round(left() / 1000)}s 内没有任何响应)` : e?.message || e);
303
+ info.toolCallFinishes.push('error');
304
+ }
305
+ }
306
+ if (!connected) {
307
+ info.state = 'unreachable';
308
+ info.error = lastError || '连接失败';
309
+ info.note = `❌ 不可达:${info.error}${info.isLocal ? '(本地引擎没起来?先确认端口/进程)' : ''}`;
310
+ return info;
311
+ }
312
+ // ⑤ 引擎自述上下文(v0.6.13 问题 2):**本地端点**额外读一次 `GET /props`。
313
+ // · 放在 chat 探测之后、结论拼装之前:chat 通不通与"引擎装得下多少"是两件事;
314
+ // · 只打一次、只读不改(GET),失败/非 JSON/没有 n_ctx 一律视为"未读到",绝不编造;
315
+ // · llama.cpp 的 /props 挂在**根**上(`http://host:port/props`),而 baseUrl 通常带 `/v1`,
316
+ // 所以先试剥掉 `/v\d+` 的根路径,再退回 baseUrl 原样(兼容把 /props 挂在前缀下的网关)。
317
+ // · **门槛**:只有引擎**自报了模型名**(`info.loadedModel`)才多打这一个请求。理由有两条:
318
+ // ① llama.cpp 家族(含 llama-cpp-python/koboldcpp/LM Studio)总会在响应里回模型名/文件路径,
319
+ // 回不了名的多半是"只实现了 /v1/chat/completions"的网关或测试桩——它们不认识 /props;
320
+ // ② 实测教训:`test/e2e-web.js` 的 mock 只按 POST+JSON body 解析(收到空 body 的 GET 会抛
321
+ // `JSON.parse('')`),多打一个 GET 会把整条 e2e 打断——不认识 /props 的端点**一个字节都不该多发**。
322
+ // 跳过时结论里会写"跳过"(`engineContextNotice` 的 skipReason),不会假装核对过。
323
+ if (info.isLocal && info.loadedModel) {
324
+ info.engineProbed = true;
325
+ const strippedBase = base.replace(/\/v\d+$/, '');
326
+ const propsUrls = strippedBase !== base ? [`${strippedBase}/props`, `${base}/props`] : [`${base}/props`];
327
+ for (const u of propsUrls) {
328
+ try {
329
+ const res = await once(u, { method: 'GET', headers }, Math.min(5000, left()));
330
+ if (!res || !res.ok) continue;
331
+ const raw = String(await res.text()).slice(0, 65536);
332
+ const ctx = engineContextFromProps(JSON.parse(raw));
333
+ if (ctx != null) {
334
+ info.engineCtx = ctx;
335
+ info.engineCtxSource = u;
336
+ break;
337
+ }
338
+ } catch {
339
+ /* 读不到就跳过(端点没实现 /props、返回非 JSON、超时)——结论里会如实写"未读到" */
340
+ }
341
+ }
342
+ }
343
+ info.ttftMs = median(info.ttftSamples);
344
+ const n = info.toolCallRuns;
345
+ info.toolState = n === 0 ? 'unknown' : info.toolCallHits === n ? 'stable-tools' : info.toolCallHits === 0 ? 'no-tools' : 'unstable-tools';
346
+ info.state = info.toolState === 'stable-tools' ? 'ok-tools' : info.toolState === 'unstable-tools' ? 'unstable-tools' : info.toolState === 'unknown' ? 'unreachable' : 'ok-textonly';
347
+ // ③ 参考 prefill:固定 ~2000 tokens 的**每次不同**前缀(防引擎前缀缓存把耗时抹平)
348
+ {
349
+ const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
350
+ const filler = Array.from({ length: Math.max(8, Math.round(prefillTokens / 4)) }, (_, i) => `第${i}段:${nonce}`).join(';');
351
+ const t0 = Date.now();
352
+ try {
353
+ const res = await once(
354
+ `${base}/chat/completions`,
355
+ {
356
+ method: 'POST',
357
+ headers,
358
+ body: JSON.stringify({
359
+ model: modelName,
360
+ messages: [{ role: 'user', content: `${filler}\n以上编号只用于占位,请只回复"ok"。` }],
361
+ max_tokens: 8,
362
+ stream: true,
363
+ }),
364
+ },
365
+ Math.min(20000, left())
366
+ );
367
+ if (res && res.ok) {
368
+ const r = await readProbeStream(res, { hasTool: chunkHasTool, t0 });
369
+ if (r.model && !info.loadedModel) {
370
+ info.loadedModel = String(r.model);
371
+ info.loadedModels = [String(r.model)];
372
+ info.nameMismatch = modelNameMismatch(modelName, [String(r.model)]);
373
+ }
374
+ info.prefillMs = r.ttftMs ?? Date.now() - t0;
375
+ } else {
376
+ info.prefillMs = null;
377
+ }
378
+ } catch {
379
+ info.prefillMs = null;
380
+ }
381
+ }
382
+ // ④ 与本机历史值比对:显著变慢 = "引擎可能还被上一个请求占着"(负责人实测 2.7s → 23.9s)
383
+ {
384
+ const hist = persist ? loadProbeBaseline() : {};
385
+ const prev = hist[key] || null;
386
+ info.ttftBaselineMs = prev?.ttftMs ?? null;
387
+ info.prefillBaselineMs = prev?.prefillMs ?? null;
388
+ const ratio = (/** @type {any} */ cur, /** @type {any} */ base0) =>
389
+ Number(cur) > 0 && Number(base0) > 0 ? Number(cur) / Number(base0) : null;
390
+ const ttftRatio = ratio(info.ttftMs, prev?.ttftMs);
391
+ const prefillRatio = ratio(info.prefillMs, prev?.prefillMs);
392
+ info.slowdown = { ttftRatio, prefillRatio };
393
+ if (persist && info.ttftMs != null) {
394
+ hist[key] = { at: Date.now(), ttftMs: info.ttftMs, prefillMs: info.prefillMs ?? prev?.prefillMs ?? null };
395
+ saveProbeBaseline(hist);
396
+ }
397
+ }
398
+ const nameNote = info.nameMismatch
399
+ ? `⚠ 引擎实际加载的是 ${info.loadedModel},与配置项名 ${modelName} **不一致**(配的是 A、跑的可能不是 A)`
400
+ : info.loadedModel
401
+ ? `引擎加载 ${info.loadedModel}`
402
+ : '(引擎未回报名)';
403
+ const toolNote =
404
+ info.toolState === 'stable-tools'
405
+ ? `✅ 稳定支持工具调用(${info.toolCallHits}/${n})`
406
+ : info.toolState === 'unstable-tools'
407
+ ? `⚠ 工具调用**不稳定**(${info.toolCallHits}/${n} 次返回 tool_calls,finish_reason=${info.toolCallFinishes.join('/')})——agent 任务可能步数为 0,建议换端点或重试预检`
408
+ : info.toolState === 'no-tools'
409
+ ? `❌ 未返回 tool_calls(0/${n},finish_reason=${info.toolCallFinishes.join('/')})——该端点很可能不做 function calling`
410
+ : `⚠ 工具调用**未能判定**(${info.toolCallInconclusive || 0} 次样本不可判定:finish_reason=${info.toolCallFinishes.join('/')})` +
411
+ `——多为"被 max_tokens 截断"(思考型模型先输出思考)或请求异常${lastError ? `:${lastError}` : ''};` +
412
+ `agent 任务可能步数为 0,**不要**据此判定"不支持工具调用"`;
413
+ const ttftNote = info.ttftMs != null ? `首帧 ${(info.ttftMs / 1000).toFixed(2)}s` : '首帧未测到';
414
+ const prefillNote = info.prefillMs != null ? `参考 prefill ${prefillTokens} tokens ${(info.prefillMs / 1000).toFixed(2)}s` : `参考 prefill 未测到`;
415
+ // v0.6.13(问题 2):引擎自述上下文 vs 配置——**必须在结论里**(界面 banner 与 web-server.log 都用这条 note)
416
+ const engineNote = engineContextNotice({
417
+ engineCtx: info.engineCtx,
418
+ configuredWindow: info.configuredWindow,
419
+ source: info.engineCtxSource,
420
+ probed: info.engineProbed,
421
+ isLocal: info.isLocal,
422
+ // 跳过的**具体**原因要写出来(否则"跳过"与"没读"分不清,等于没说)
423
+ skipReason: info.isLocal ? (info.loadedModel ? null : '该端点未自报模型名,不确认它实现 /props') : '非本地端点不预检 /props',
424
+ });
425
+ const slowNote =
426
+ (info.slowdown?.ttftRatio != null && info.slowdown.ttftRatio >= 3) || (info.slowdown?.prefillRatio != null && info.slowdown.prefillRatio >= 3)
427
+ ? `\n ⚠ 与上次预检相比明显变慢(首帧 ${info.ttftBaselineMs != null ? (info.ttftBaselineMs / 1000).toFixed(2) + 's → ' : '?'}${info.ttftMs != null ? (info.ttftMs / 1000).toFixed(2) + 's' : '?'}):该端点**可能仍在处理上一个请求**(引擎 slot 被占/排队),建议重启引擎后再跑长任务。`
428
+ : '';
429
+ info.note = `${toolNote} · ${ttftNote} · ${prefillNote}${info.partial ? ' ·(预检超过时间预算,样本未跑满)' : ''} · ${nameNote} · ${engineNote}${slowNote}`;
430
+ return info;
431
+ })();
432
+ probeInflight.set(key, run);
433
+ try {
434
+ const value = await run;
435
+ probeCache.set(key, { at: Date.now(), value });
436
+ return value;
437
+ } finally {
438
+ if (probeInflight.get(key) === run) probeInflight.delete(key);
439
+ }
440
+ }
441
+
442
+ /** 读一次探针的流式响应:取首帧时延 + 是否出现 tool_calls + finish_reason;拿到结论就主动关流(省 token)。
443
+ * t0 必须由调用方传入(= **请求发起**时刻):TTFT 的定义是"发出→第一帧",在这里取 Date.now()
444
+ * 会把"引擎排队 + prefill"整段抹掉(实测过:真值 120ms 会被量成 0ms)。 */
445
+ async function readProbeStream(/** @type {any} */ res, /** @type {{ hasTool: (s: string) => boolean, t0: number }} */ { hasTool, t0 }) {
446
+ let ttftMs = /** @type {number|null} */ (null);
447
+ let toolCalls = false;
448
+ let finish = /** @type {string|null} */ (null);
449
+ let model = /** @type {string|null} */ (null);
450
+ const reader = res?.body?.getReader?.();
451
+ if (!reader) return { ttftMs: null, toolCalls: false, finish: null };
452
+ const dec = new TextDecoder();
453
+ let buf = '';
454
+ try {
455
+ for (;;) {
456
+ const { done, value } = await reader.read();
457
+ if (done) break;
458
+ if (ttftMs == null && value && value.length) ttftMs = Date.now() - t0;
459
+ buf += dec.decode(value, { stream: true });
460
+ if (hasTool(buf)) toolCalls = true;
461
+ const fm = /"finish_reason"\s*:\s*"([a-z_]+)"/.exec(buf);
462
+ if (fm) finish = fm[1];
463
+ if (!model) {
464
+ const mm = /"model"\s*:\s*"([^"]+)"/.exec(buf);
465
+ if (mm) model = mm[1];
466
+ }
467
+ if (toolCalls || finish) break; // 已能判定:立刻收手
468
+ if (buf.length > 65536) buf = buf.slice(-4096); // 防无界增长(异常端点狂吐)
469
+ }
470
+ } catch {
471
+ /* 读失败按"没拿到结论"处理(上层按 unreachable/unknown 归类) */
472
+ }
473
+ try { reader.cancel(); } catch {}
474
+ return { ttftMs, toolCalls, finish, model };
475
+ }
476
+
477
+ /** 中位数(偶数量取中间两个的平均;空数组回 null)。 */
478
+ function median(/** @type {number[]} */ arr) {
479
+ const a = (Array.isArray(arr) ? arr : []).filter((n) => Number.isFinite(n)).sort((x, y) => x - y);
480
+ if (!a.length) return null;
481
+ const mid = Math.floor(a.length / 2);
482
+ return a.length % 2 ? a[mid] : Math.round((a[mid - 1] + a[mid]) / 2);
483
+ }
484
+
485
+ /** 本机历史基线:用于识别"同端点突然变慢 = 可能被旧请求占着"(负责人实测 2.7s → 23.9s)。
486
+ * 存放位置复用既有的 <home>/model-cache.json 里的**保留键** `__probeBaseline`
487
+ * ——不新开文件、不新增加一处"静默吞写"(写路径复用已审阅的 saveCache)。 */
488
+ export const PROBE_BASELINE_KEY = '__probeBaseline';
489
+ function loadProbeBaseline() {
490
+ const b = loadCache()?.[PROBE_BASELINE_KEY];
491
+ return b && typeof b === 'object' ? b : {};
492
+ }
493
+ function saveProbeBaseline(/** @type {any} */ data) {
494
+ const cache = loadCache();
495
+ cache[PROBE_BASELINE_KEY] = data;
496
+ saveCache(cache);
497
+ }
498
+
499
+ function fetchImplOr(/** @type {any} */ f, /** @type {any} */ url, /** @type {any} */ init) {
500
+ return f(url, init);
501
+ }
502
+
503
+ /**
504
+ * 按预检结果**自适应**超时(纯函数,取舍写在下面)。
505
+ *
506
+ * 事实依据:现状是"一刀切 600s 首帧 + 60 分钟无进展看门狗"。实测 2.7s 就能出首帧的端点,
507
+ * 卡 10 分钟已经异常;而实测首帧 30s 的端点,600s 上限是合理的。于是:
508
+ * · 首帧上限 = clamp(max(实测 TTFT × 20, 参考 prefill × 5), 既有默认(本地 600s/远程 300s), 1800s)
509
+ * —— ×20 是"给长上下文 prefill 留 20 倍余量"(实测 TTFT 是极小请求,真实任务上下文大得多);
510
+ * 地板取既有默认(**不比现状更激进**);天花板取既有单请求总量上限 1800s(首帧不可能超过它)。
511
+ * 用户显式配了 config.timeout.firstTokenMs 时**一律以用户为准**(显式 > 自适应)。
512
+ * · 无进展看门狗 = clamp(max(实测 TTFT × 100, 参考 prefill × 20), 10 分钟, 60 分钟)
513
+ * —— ×100 让"慢端点"(TTFT 30s → 50 分钟)不至于被误杀,"快端点"(2.7s → 4.5 分钟→取 10 分钟)
514
+ * 能更早被判为异常并报错。下限 10 分钟保证不误杀一次正常的工具执行/一次长回复。
515
+ * 同样:用户显式配了 config.noProgressTimeoutMs 就听用户的。
516
+ * @param {{ probeTtftMs?: number|null, probePrefillMs?: number|null, isLocal?: boolean, timeoutCfg?: any, noProgressCfg?: any }} input
517
+ */
518
+ export function adaptiveTimeouts({ probeTtftMs = null, probePrefillMs = null, isLocal = false, timeoutCfg = null, noProgressCfg = null } = {}) {
519
+ const explicitFirst = Number(timeoutCfg?.firstTokenMs) > 0 ? Number(timeoutCfg.firstTokenMs) : null;
520
+ const explicitNoProgress = Number(noProgressCfg) > 0 ? Number(noProgressCfg) : null;
521
+ const measured = Number(probeTtftMs) > 0 ? Number(probeTtftMs) : null;
522
+ const prefill = Number(probePrefillMs) > 0 ? Number(probePrefillMs) : null;
523
+ const floorFirst = isLocal ? 600000 : 300000;
524
+ // 两个实测量一起用:小请求首帧(×20)+ 参考 prefill(×5,2000 tokens 的实测值越慢、真实任务越危险)
525
+ const firstTokenMs =
526
+ explicitFirst ?? (measured || prefill ? Math.min(1800000, Math.max(floorFirst, Math.round(Math.max(measured ? measured * 20 : 0, prefill ? prefill * 5 : 0)))) : floorFirst);
527
+ const noProgressTimeoutMs =
528
+ explicitNoProgress ??
529
+ (measured || prefill
530
+ ? Math.min(PROGRESS_MAX_MS, Math.max(PROGRESS_MIN_MS, Math.round(Math.max(measured ? measured * 100 : 0, prefill ? prefill * 20 : 0))))
531
+ : PROGRESS_MAX_MS);
532
+ const bits = [];
533
+ if (measured) bits.push(`首帧 ${Math.round(measured)}ms`);
534
+ if (prefill) bits.push(`参考 prefill ${prefillTokensLabel()} ${Math.round(prefill)}ms`);
535
+ return {
536
+ firstTokenMs,
537
+ noProgressTimeoutMs,
538
+ adapted: (measured != null || prefill != null) && explicitFirst == null,
539
+ basis: bits.length ? `预检实测 ${bits.join(' + ')}` : '无预检数据:用既有默认(本地 600s / 远程 300s)',
540
+ };
541
+ }
542
+ /** 参考 prefill 的样本规模(文案用;与 probeEndpoint 的 prefillTokens 默认一致)。 */
543
+ function prefillTokensLabel() {
544
+ return '2000 tokens';
545
+ }
546
+
547
+ // ---------------------------------------------------------------------------
548
+ // v0.6.13(问题 2):**引擎自述上下文** vs **配置上下文**
549
+ //
550
+ // 真机证据(~/.mingdao/logs/web-server.log:229,2026-10-09T02:41:48):
551
+ // chat 错误 t820945ac81c9ce5f [流式响应错误] insufficient memory: the request exceeded
552
+ // available GPU memory (sustained critical memory pressure during prefill … Reduce
553
+ // --context-window, close other apps, or try q8 KV quantization.
554
+ // 而 ~/.mingdao/config.json 里 customModels.MLocalModel3.6.2.contextWindow = 131072,
555
+ // 引擎(llama.cpp,127.0.0.1:60091)自述 n_ctx = 32768 —— 配置是引擎能力的 **4 倍**。
556
+ // 后果:内核按 131072 推导预算(98,304),把 15,983 tokens 的 prompt 发给一个只装了 32k 的
557
+ // 引擎,长上下文**在 prefill 阶段就被引擎直接拒绝**——此时换预设、调 contextBudget、
558
+ // 调 timeout.firstTokenMs 全都无济于事(那些都改不了引擎的 n_ctx)。这正是负责人"换预设也没用"
559
+ // 的根因,所以它必须在预检阶段就被读出来、并且**在日志与界面上各说一次**。
560
+ //
561
+ // 读法:llama.cpp 的 `GET /props` 返回 `default_generation_settings.n_ctx`(有的版本另有顶层
562
+ // `n_ctx` / `n_ctx_train`);其他引擎(vLLM 风格 / Ollama 风格 / 自研网关)能读多少读多少,
563
+ // 读不到就**跳过并说明**(不猜、不编造、不把它当成"引擎不行")。
564
+ // ---------------------------------------------------------------------------
565
+
566
+ /** 从引擎自述(/props 的 JSON)里抽"真实上下文窗口"(纯函数,便于断言/变异)。
567
+ *
568
+ * 候选字段按"越贴近引擎真实装载值越靠前"排序:
569
+ * · llama.cpp /props:`default_generation_settings.n_ctx`(服务端实际 n_ctx)、顶层 `n_ctx`、`n_ctx_train`;
570
+ * · vLLM 风格:顶层 `max_model_len`;
571
+ * · 通用/自研:`context_length`、`model_info.*.context_length`(Ollama /api/show 的形态)。
572
+ * 一律要求**正整数**(0/负数/字符串/NaN 视为读不到)——绝不拿一个假值去和配置比。 */
573
+ export function engineContextFromProps(/** @type {any} */ json) {
574
+ if (!json || typeof json !== 'object') return null;
575
+ const dgs = json.default_generation_settings || {};
576
+ const candidates = [
577
+ dgs.n_ctx,
578
+ json.n_ctx,
579
+ dgs.params?.n_ctx,
580
+ json.n_ctx_train,
581
+ json.max_model_len,
582
+ json.context_length,
583
+ json.model_info?.context_length,
584
+ ...Object.values(json.model_info || {}).map((/** @type {any} */ v) => v?.context_length),
585
+ ];
586
+ for (const c of candidates) {
587
+ const n = Number(c);
588
+ if (Number.isFinite(n) && n > 0) return Math.round(n);
589
+ }
590
+ return null;
591
+ }
592
+
593
+ /**
594
+ * 「引擎自述上下文 vs 配置」的**用户可见结论**(纯函数,日志/界面 banner/规模预告共用同一份文案)。
595
+ *
596
+ * 三态(不合并、不省略):
597
+ * · 读到了且配置 ≤ 引擎 → 明确说"一致"(用户才知道这条核对**做过**了);
598
+ * · 读到了且配置 > 引擎 → **警告**:"配置超出引擎能力,长上下文将直接被引擎拒绝",
599
+ * 并点明"换预设/调 contextBudget 都改不了引擎上限"(负责人现场正是卡在这里);
600
+ * · 没读到(端点不提供 /props、字段缺失、非本地端点或不确认实现 /props 而跳过)→ 如实说"未读到/跳过",
601
+ * **不**据此判定端点不行,也不假装核对过。
602
+ * @param {{ engineCtx?: number|null, configuredWindow?: number|null, source?: string|null, probed?: boolean, isLocal?: boolean, skipReason?: string|null }} input
603
+ */
604
+ export function engineContextNotice({ engineCtx = null, configuredWindow = null, source = null, probed = false, isLocal = false, skipReason = null } = {}) {
605
+ const eng = Number(engineCtx) > 0 ? Math.round(Number(engineCtx)) : null;
606
+ const conf = Number(configuredWindow) > 0 ? Math.round(Number(configuredWindow)) : null;
607
+ const where = source ? `(读自 ${source})` : '';
608
+ if (eng == null) {
609
+ if (!probed) {
610
+ return `引擎自述上下文:跳过(${skipReason || (isLocal ? '该端点未自报模型名,不确认它实现 /props' : '非本地端点不预检 /props')})——无法核对配置的 ${conf ?? '?'} 是否超出引擎能力`;
611
+ }
612
+ return `引擎自述上下文:**未读到**(该端点不提供 /props 或其中没有 n_ctx/default_generation_settings.n_ctx 字段)——无法核对配置的 ${conf ?? '?'} 是否超出引擎能力`;
613
+ }
614
+ if (conf != null && conf > eng) {
615
+ return `⚠ 引擎自述上下文 ${eng} vs 配置 ${conf} —— 配置超出引擎能力,长上下文将直接被引擎拒绝${where}。换预设/调小 config.contextBudget 都**改不了引擎上限**:要么在引擎侧把上下文窗口调大(llama.cpp 的 --ctx-size / --context-window),要么把配置改成 ≤ ${eng}。`;
616
+ }
617
+ return `引擎自述上下文 ${eng} vs 配置 ${conf ?? '(未声明)'} —— 配置未超出引擎能力${where}`;
618
+ }
619
+
17
620
  export function modelCacheFile() {
18
621
  return path.join(mingdaoHome(), 'model-cache.json');
19
622
  }
@@ -154,6 +757,12 @@ export function isDiscoveredModel(/** @type {any} */ name, /** @type {string} */
154
757
  }
155
758
 
156
759
  // 合并可用模型列表:只含已设置 Key 的服务商;动态名单优先、预设回退;自定义模型恒在。
760
+ //
761
+ // v0.6.13(B):**每个条目都要能看出"来自哪个服务商/端点"**。此前自定义条目只有
762
+ // providerLabel='自定义',而下拉是按 providerLabel 分组的——于是 config.customModels 里的
763
+ // chatflow 应用名/本地端点模型名与官方模型长得一样,容易被当成官方模型(负责人实测)。
764
+ // 现在:自定义端点条目带 source/sourceLabel/endpoint(providerLabel 直接写成「自定义端点 · host」),
765
+ // 官方/线上条目带 source/sourceLabel(providerLabel 仍是服务商名)。
157
766
  export async function availableModels(/** @type {any} */ cfg, /** @type {any} */ currentModel) {
158
767
  const out = [];
159
768
  const seen = new Set();
@@ -172,18 +781,31 @@ export async function availableModels(/** @type {any} */ cfg, /** @type {any} */
172
781
  provider: pname,
173
782
  providerLabel: pp.label,
174
783
  dynamic: !(/** @type {any} */ (MODELS))[n],
784
+ // 来源标注(B):内置预设 vs 服务商线上名单——两者都是"该服务商自己的模型"
785
+ official: Boolean(preset),
786
+ source: preset ? 'preset' : 'discovered',
787
+ sourceLabel: preset ? `内置预设 · ${pp.label}` : `线上名单 · ${pp.label}`,
175
788
  });
176
789
  }
177
790
  }
178
791
  for (const [cmName, cm] of Object.entries(cfg?.customModels || {})) {
179
792
  if (seen.has(cmName)) continue;
180
793
  seen.add(cmName);
794
+ const src = customSourceOf(cfg, cmName);
181
795
  out.push({
182
796
  name: cmName,
183
- label: `${cmName} — ${cm.label || '自定义模型'}`,
797
+ // 展示名里也带上来源(不只是 title/tooltip):下拉标题行、设置面板、日志三处一致
798
+ label: `${cmName} — ${cm.label || '自定义模型'}(${src.sourceLabel})`,
184
799
  provider: 'custom',
185
- providerLabel: '自定义',
800
+ providerLabel: src.providerLabel,
186
801
  custom: true,
802
+ // v0.6.13(B):来源标注的结构化字段(前端/测试都读它,不靠解析 label 文本)
803
+ official: false,
804
+ source: src.source,
805
+ sourceLabel: src.sourceLabel,
806
+ endpoint: src.endpoint || null,
807
+ isLocalEndpoint: src.isLocalEndpoint,
808
+ note: src.note,
187
809
  });
188
810
  }
189
811
  if (currentModel && !out.some((m) => m.name === currentModel)) {
@@ -192,6 +814,10 @@ export async function availableModels(/** @type {any} */ cfg, /** @type {any} */
192
814
  label: `${currentModel}(当前配置)`,
193
815
  provider: 'current',
194
816
  providerLabel: '当前',
817
+ official: false,
818
+ source: 'current',
819
+ sourceLabel: '当前配置(不在任何已知名单里)',
820
+ note: '该名称不在任何服务商名单/自定义条目里——只是 config.json 里当前写着的模型名。',
195
821
  });
196
822
  }
197
823
  return out;