mingdao-harness 0.3.1 → 0.3.2
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 +24 -2
- package/docs/PLAN-v0.3.2.md +65 -0
- package/package.json +1 -1
- package/src/agent.js +29 -3
- package/src/compact.js +3 -1
- package/src/model-caps.js +69 -0
- package/src/providers/index.js +45 -6
- package/src/providers/openai-compatible.js +8 -3
- package/src/web/app.js +7 -3
- package/src/web/index.html +11 -0
- package/src/web/routes/domains/config.js +26 -2
- package/src/web/server.js +3 -1
package/docs/CONFIG.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
| `baseUrl` | OpenAI 兼容 API 地址(可覆盖内置服务商默认值) |
|
|
24
24
|
| `permission` | `ask`(默认)/ `auto` / `readonly`,或规则对象(见下) |
|
|
25
25
|
| `sandbox` | `off` / `readonly` / `safe`(Linux + bubblewrap;其余平台自动降级) |
|
|
26
|
-
| `contextBudget` |
|
|
26
|
+
| `contextBudget` | 期望的上下文预算 tokens;实际预算按模型窗口自动收紧(见「本地模型自适应」) |
|
|
27
27
|
|
|
28
28
|
可选字段:`temperature`、`maxOutputTokens`、`includeUsage`(流式请求 usage 统计,个别网关不支持
|
|
29
29
|
`stream_options` 时设 `false`)、`autoTitle`(自动生成会话标题,默认开)、`notify`(任务桌面通知,默认开)、
|
|
@@ -221,7 +221,8 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
|
|
|
221
221
|
{
|
|
222
222
|
"customModels": {
|
|
223
223
|
"my-gpt4": { "label": "我的 GPT-4 网关", "baseUrl": "https://gateway.example.com/v1" },
|
|
224
|
-
"my-ds": { "label": "自建 DeepSeek 网关", "baseUrl": "https://gw.example.com/v1", "tokenizer": "deepseek" }
|
|
224
|
+
"my-ds": { "label": "自建 DeepSeek 网关", "baseUrl": "https://gw.example.com/v1", "tokenizer": "deepseek" },
|
|
225
|
+
"local-qwen": { "label": "本机 Qwen", "baseUrl": "http://127.0.0.1:8081/v1", "contextWindow": 131072, "maxOutputTokens": 8192 }
|
|
225
226
|
}
|
|
226
227
|
}
|
|
227
228
|
```
|
|
@@ -231,6 +232,27 @@ completion 计费,防止推理吃满上限时空轮白烧)、`compactTrigger
|
|
|
231
232
|
自定义端点若跑的是 DeepSeek 系模型(模型名不以 `deepseek` 开头时默认走启发式估算、预算误差
|
|
232
233
|
可达 ±2 倍),加 `"tokenizer": "deepseek"` 即按官方词表精确计数:
|
|
233
234
|
|
|
235
|
+
### 本地模型自适应(v0.3.2)
|
|
236
|
+
|
|
237
|
+
本机/内网部署的推理框架(baseUrl 为 `127.0.0.1`/`localhost`/私网 IP)自动按「资源有限」对待,
|
|
238
|
+
避免长任务把上下文撑到窗口边缘后 prefill 指数恶化、被客户端超时掐断(典型:127k 上下文首 token
|
|
239
|
+
需 200s+,客户端 3 分钟无响应断开 → network error)。机制:
|
|
240
|
+
|
|
241
|
+
- **上下文窗口感知**:`customModels.<name>.contextWindow` 显式声明模型真实窗口;未声明时本地
|
|
242
|
+
模型兜底 **32k**、远程兜底 **128k**。
|
|
243
|
+
- **安全预算**:`contextBudget` 会被自动收紧到 `min(contextBudget, 窗口×75%, 窗口−maxOutput−余量)`,
|
|
244
|
+
prompt 永不逼近窗口边缘(75% 舒适区以上 prefill 时间陡增)。
|
|
245
|
+
- **边缘检测**:模型每轮上报真实 `prompt_tokens`,≥ 窗口 85% 时下一轮强制压缩历史(即使启发式
|
|
246
|
+
计数低估也强制触发)。
|
|
247
|
+
- **工具输出截断**:单条工具结果按 `窗口/16` 封顶(最少 2000 字),小窗口不再整条回灌大段代码。
|
|
248
|
+
- **分层超时**:`timeout.firstTokenMs`(首 token 等待,本地默认 600s / 远程 300s)、
|
|
249
|
+
`timeout.streamIdleMs`(流式空闲,默认 120s)、`timeout.totalMs`(总量,本地 30min / 远程 10min),
|
|
250
|
+
留空则自适应;本地慢 prefill 不再被一刀切超时误杀。
|
|
251
|
+
|
|
252
|
+
```json
|
|
253
|
+
{ "timeout": { "firstTokenMs": 600000, "streamIdleMs": 120000, "totalMs": 1800000 } }
|
|
254
|
+
```
|
|
255
|
+
|
|
234
256
|
## 自定义 Provider 模块(非 OpenAI 兼容协议)
|
|
235
257
|
|
|
236
258
|
在 `~/.mingdao/providers/<name>.mjs` 导出:
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# v0.3.2 规划:本地模型自适应(资源受限部署不中断)
|
|
2
|
+
|
|
3
|
+
## 背景(用户 MacBook M5 Pro 实测定位结论)
|
|
4
|
+
|
|
5
|
+
本地部署 `mtplx-qwen38-27b`(131072 窗口 + q8 KV 量化)跑 71 步长任务时 network error。
|
|
6
|
+
其他 agent 定位结论:**不是内存拒绝,是「长上下文 prefill 过慢 → 客户端等待超时主动断连」**。
|
|
7
|
+
|
|
8
|
+
- 服务端无任何 507/内存拒绝,q8 生效,峰值 47.38GB < 48G 预算。
|
|
9
|
+
- 失败请求:prompt=127,912 / 131,072(窗口边缘),`request_cancelled=client_disconnected`,
|
|
10
|
+
188.9s 内 0 token 输出。
|
|
11
|
+
- prefill 指数恶化:ttft 46.4s → 67.8s → 96.0s → 196.5s → 中断(189s 无输出)。
|
|
12
|
+
根因:每轮全量历史 + 上一轮大段代码输出回灌,新增 prefill 从 7.9k 涨到 32k,
|
|
13
|
+
prefill 速度仅 ~165–185 tok/s,ttft 超过客户端等待阈值。
|
|
14
|
+
|
|
15
|
+
调用 DeepSeek 官方 API 无此问题(窗口 1M + prefill 极快),说明瓶颈在「资源受限的本地模型」,
|
|
16
|
+
必须做成**共性能力**:其他客户本地部署更小模型(窗口小/内存少)也会踩,不能只修 MacBook。
|
|
17
|
+
|
|
18
|
+
## 目标
|
|
19
|
+
|
|
20
|
+
让 MingDao 针对不同「参数 / 上下文窗口 / KV cache / 机器资源」的模型**灵活自适应**,
|
|
21
|
+
小模型、低内存自动收紧参数适应,不撑爆、不误杀;本地慢 prefill 不被一刀切超时掐断。
|
|
22
|
+
|
|
23
|
+
## 方案(本次实现)
|
|
24
|
+
|
|
25
|
+
### 1. 模型能力解析(`src/model-caps.js` 新增)
|
|
26
|
+
单一来源解析 `contextWindow / maxOutputTokens / isLocal`:
|
|
27
|
+
- 优先级:`customModels.<name>.contextWindow/maxOutputTokens` > 内置 preset > 兜底。
|
|
28
|
+
- 兜底:本地模型 32k、远程 128k(本地小模型宁可保守不撑爆)。
|
|
29
|
+
- `isLocalBaseUrl`:127.0.0.1 / localhost / 私网 IP 判定本地推理框架。
|
|
30
|
+
|
|
31
|
+
### 2. 安全预算推导(`safeBudget`)
|
|
32
|
+
`budget = min(期望 contextBudget, 窗口×75% 舒适区, 窗口 − maxOutput − 2048 余量)`。
|
|
33
|
+
prompt 永不逼近窗口边缘(75% 以上 prefill 时间陡增),从根上避免 prefill 爆炸。
|
|
34
|
+
对内置模型零影响(pro 200k / flash 128k 均远小于各自窗口 75%)。
|
|
35
|
+
|
|
36
|
+
### 3. 分层超时(providers)
|
|
37
|
+
- 首 token 等待:本地 600s / 远程 300s(覆盖慢 prefill)。
|
|
38
|
+
- 流式空闲:有帧后 120s 无新帧即断(真挂死才断)。
|
|
39
|
+
- 总量:本地 30min / 远程 10min。
|
|
40
|
+
- `config.timeout.{firstTokenMs,streamIdleMs,totalMs}` 可覆盖;`parseStream` 按「帧到达」刷新
|
|
41
|
+
空闲计时(prefill 阶段服务端可能先发 usage-only 帧,不误杀)。
|
|
42
|
+
|
|
43
|
+
### 4. 边缘检测 + 强制压缩
|
|
44
|
+
模型每轮上报真实 `prompt_tokens`,≥ 窗口 85% 时下一轮 `force` 压缩——即便非 DeepSeek 模型
|
|
45
|
+
启发式计数低估(误差 ±2 倍)也强制触发(`compact.js` force 绕过触发线门槛)。
|
|
46
|
+
|
|
47
|
+
### 5. 工具输出截断自适应
|
|
48
|
+
单条工具结果按 `窗口/16` 封顶(最少 2000 字),小窗口不再整条回灌大段代码。
|
|
49
|
+
|
|
50
|
+
### 6. 配置/UI 打通
|
|
51
|
+
- `customModels.<name>.contextWindow/maxOutputTokens`(WebUI 添加自定义模型表单新增两项)。
|
|
52
|
+
- `config.timeout.*`(设置 → 通用面板新增三项,秒为单位,留空自适应)。
|
|
53
|
+
- `/api/config`、`/api/models-config` 契约扩展。
|
|
54
|
+
|
|
55
|
+
## 验收
|
|
56
|
+
|
|
57
|
+
- smoke 新增 model-caps(本地/远程判定、窗口兜底/显式声明、舒适区+输出余量预算)与
|
|
58
|
+
provider 分层超时(首 token vs 流式空闲)两组断言。
|
|
59
|
+
- 全绿:smoke 69 组 / e2e-local / e2e-web / e2e-schedule / api-contracts / bench;strict 0/0。
|
|
60
|
+
- 用户在 3820 用本地模型跑长任务验收,确认无 network error 中断后发布。
|
|
61
|
+
|
|
62
|
+
## 非目标(顺延)
|
|
63
|
+
|
|
64
|
+
- 增量上下文(基线+变化)——自动续跑 + 语义检索已覆盖省钱与长程主场景。
|
|
65
|
+
- 本地窗口自动探测(从服务端 /models 读 max_model_len)——依赖各家推理框架能力,暂用显式声明。
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -6,6 +6,7 @@ import { trimMessages, clampText, messageTokens, approxTokens } from './context.
|
|
|
6
6
|
import { compactConversation } from './compact.js';
|
|
7
7
|
import { buildToolSchemas, dispatch } from './tools/index.js';
|
|
8
8
|
import { modelPreset } from './models.js';
|
|
9
|
+
import { resolveModelCaps, safeBudget, EDGE_RATIO } from './model-caps.js';
|
|
9
10
|
import { makeTokenCounter } from './tokenizer.js';
|
|
10
11
|
import { createHooks } from './hooks.js';
|
|
11
12
|
import { createIO, style, C } from './ui.js';
|
|
@@ -29,8 +30,19 @@ const SUBAGENT_MAX_STEPS = 24;
|
|
|
29
30
|
*/
|
|
30
31
|
export function createAgent({ provider, permission, io, modelName, workingDir, cfg = {}, undoStore, maxSteps, mcp, onCompact, sessionRef }) {
|
|
31
32
|
const preset = modelPreset(modelName) || {};
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
// v0.3.2 模型自适应:预算按模型上下文窗口推导(留输出余量 + 75% 舒适区),
|
|
34
|
+
// 自定义/本地小模型不再套 128000 默认撑爆窗口;prompt 永不逼近窗口边缘(prefill 不爆炸)。
|
|
35
|
+
const caps = resolveModelCaps(cfg, modelName);
|
|
36
|
+
// safeBudget 恒用:即使用户显式 contextBudget 也套「窗口−输出−余量」上限与 75% 舒适区,
|
|
37
|
+
// 防显式值撑爆窗口(本地模型窗口可能只有 32k/131k,用户却留了默认 128000)。
|
|
38
|
+
const budget = safeBudget(cfg, caps);
|
|
39
|
+
// maxOutput 也按窗口封顶:显式配超大 maxOutputTokens 时,prompt(预算)+output 仍不得越过窗口
|
|
40
|
+
// (预算已按 caps.maxOutputTokens 留余量,但显式值可能更大——此处兜底,防服务端截断/拒绝)
|
|
41
|
+
const maxOutput = Math.min(cfg.maxOutputTokens || caps.maxOutputTokens, Math.max(1024, caps.contextWindow - budget));
|
|
42
|
+
// v0.3.2 工具输出截断自适应:窗口越小截得越狠(单条工具结果按窗口 1/16 封顶,最少 2000 字),
|
|
43
|
+
// 但绝不超过旧默认 20000(大窗口模型如 1M 不因公式放大回灌、不推高成本)。
|
|
44
|
+
// 本地小模型(32k 窗口 → 2k 字)不再把大段代码/日志整条回灌,省 prompt 且不撑爆窗口。
|
|
45
|
+
const toolResultCap = Math.min(20000, Math.max(2000, Math.floor(caps.contextWindow / 16)));
|
|
34
46
|
const temperature = cfg.temperature ?? preset.temperature ?? 0.6;
|
|
35
47
|
const reasoningEffort = cfg.reasoningByModel?.[modelName] ?? cfg.reasoningEffort ?? preset.reasoningEffort?.default ?? undefined;
|
|
36
48
|
const hooks = createHooks(cfg.hooks, workingDir);
|
|
@@ -152,6 +164,9 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
152
164
|
let aborted = false;
|
|
153
165
|
let emptyRounds = 0; // 连续空/截断输出计数(防止无限续写)
|
|
154
166
|
let currentAc = /** @type {any} */ (null);
|
|
167
|
+
// v0.3.2 边缘检测状态:模型上报 prompt_tokens 逼近窗口 → 下一轮强制压缩(见下方 compactConversation force)
|
|
168
|
+
let windowPressure = false;
|
|
169
|
+
let pressureWarned = false;
|
|
155
170
|
// 省钱 B4(护栏降级):action='downgrade' 超限后本回合切换到便宜模型继续执行;
|
|
156
171
|
// activeModel 是本回合实际使用的模型(分账/记录归属它),downgraded 保证只提示一次。
|
|
157
172
|
let activeModel = modelName;
|
|
@@ -201,6 +216,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
201
216
|
provider,
|
|
202
217
|
executorModel: subagentModel(cfg, modelName),
|
|
203
218
|
triggerRatio: cfg.compactTrigger, // 可配置触发线(默认 80%)
|
|
219
|
+
force: windowPressure, // v0.3.2:逼近窗口时强制压缩(忽略最小阈值门槛)
|
|
204
220
|
});
|
|
205
221
|
if (compacted) {
|
|
206
222
|
messages.splice(0, messages.length, ...compacted.messages);
|
|
@@ -368,6 +384,16 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
368
384
|
if (Number.isFinite(res.usage.prompt_cache_miss_tokens)) {
|
|
369
385
|
usage.prompt_cache_miss_tokens = (usage.prompt_cache_miss_tokens || 0) + res.usage.prompt_cache_miss_tokens;
|
|
370
386
|
}
|
|
387
|
+
// v0.3.2 边缘检测:模型上报的真实 prompt_tokens(含缓存命中)逼近窗口 85% 即标记——
|
|
388
|
+
// 下一轮强制激进压缩(force),不让 prompt 逼近窗口边缘导致 prefill 指数恶化。
|
|
389
|
+
const realPrompt = Number(res.usage.prompt_tokens);
|
|
390
|
+
if (Number.isFinite(realPrompt) && realPrompt > 0 && realPrompt >= caps.contextWindow * EDGE_RATIO) {
|
|
391
|
+
windowPressure = true;
|
|
392
|
+
if (!pressureWarned) {
|
|
393
|
+
pressureWarned = true;
|
|
394
|
+
io.print(style(`⚠ 上下文已逼近模型窗口(${realPrompt}/${caps.contextWindow},≥${Math.round(EDGE_RATIO * 100)}%),下轮将强制压缩历史防 prefill 恶化`, C.yellow));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
371
397
|
}
|
|
372
398
|
|
|
373
399
|
if (res.toolCalls?.length) {
|
|
@@ -529,7 +555,7 @@ export function createAgent({ provider, permission, io, modelName, workingDir, c
|
|
|
529
555
|
}
|
|
530
556
|
const text = typeof result === 'string' ? result : JSON.stringify(result); // 紧凑 JSON(评估 B3):嵌套结果省 10-20% 回填 token,且下轮按 prompt 重复计费
|
|
531
557
|
const prefix = prep.cached ? '(与同回合相同调用结果一致,已复用)\n' : '';
|
|
532
|
-
messages.push({ role: 'tool', tool_call_id: prep.tc.id, content: prefix + clampText(text) });
|
|
558
|
+
messages.push({ role: 'tool', tool_call_id: prep.tc.id, content: prefix + clampText(text, toolResultCap) });
|
|
533
559
|
}
|
|
534
560
|
|
|
535
561
|
let i = 0;
|
package/src/compact.js
CHANGED
|
@@ -65,7 +65,9 @@ export async function compactConversation(/** @type {any} */ { messages, budget,
|
|
|
65
65
|
total += t;
|
|
66
66
|
}
|
|
67
67
|
const trigger = Number.isFinite(Number(triggerRatio)) ? Number(triggerRatio) : DEFAULT_TRIGGER_RATIO;
|
|
68
|
-
|
|
68
|
+
// force(v0.3.2 边缘检测):逼近窗口时即便启发式计数低估(total 未达触发线)也强制压缩——
|
|
69
|
+
// 非 DeepSeek 模型启发式计数误差可达 ±2 倍,等它越过触发线时真实 prompt 可能已到窗口边缘。
|
|
70
|
+
if (total <= budget * trigger && !force) return null;
|
|
69
71
|
// 保留边界:保留段(boundary..end)≤ budget×TARGET_RATIO(system 恒保留)
|
|
70
72
|
let keepTokens = sizes[0] ?? 0;
|
|
71
73
|
let boundary = messages.length;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// 模型能力解析(v0.3.2 本地模型自适应):
|
|
2
|
+
// 把「模型能装多少上下文、单次最多输出多少、是否本地部署」收敛成单一来源,
|
|
3
|
+
// 供预算推导、超时、工具截断统一引用——避免各层各自猜一份 128000 默认,
|
|
4
|
+
// 本地小模型(窗口小/内存少)自动收紧预算与超时,不撑爆、不误杀。
|
|
5
|
+
import { modelPreset } from './models.js';
|
|
6
|
+
|
|
7
|
+
// 兜底:未知模型默认上下文窗口。本地小模型宁可保守(不撑爆)也不乐观。
|
|
8
|
+
export const UNKNOWN_LOCAL_WINDOW = 32768;
|
|
9
|
+
export const UNKNOWN_REMOTE_WINDOW = 128000;
|
|
10
|
+
export const DEFAULT_MAX_OUTPUT = 8192;
|
|
11
|
+
// 输出余量:prompt 预算必须给模型输出留足空间,否则 prompt+output 越过窗口 → 服务端截断/拒绝。
|
|
12
|
+
export const OUTPUT_HEADROOM = 2048;
|
|
13
|
+
// 舒适区:prompt 预算最多占窗口 75%——逼近 75% 以上时 prefill 时间陡增(长上下文 dequant 开销),
|
|
14
|
+
// 留 25% 给输出 + 抗抖缓冲,从根上避免「prompt 到窗口边缘 → 首 token 等 200s+ 被客户端掐断」。
|
|
15
|
+
export const COMFORT_RATIO = 0.75;
|
|
16
|
+
// 边缘比:模型上报的真实 prompt_tokens 逼近窗口 85% 即视为「边缘」,本回合结束强制激进压缩。
|
|
17
|
+
export const EDGE_RATIO = 0.85;
|
|
18
|
+
|
|
19
|
+
/** 判断 baseUrl 是否指向本机/内网(本地推理框架部署)。 */
|
|
20
|
+
export function isLocalBaseUrl(/** @type {any} */ baseUrl) {
|
|
21
|
+
try {
|
|
22
|
+
const u = new URL(String(baseUrl || ''));
|
|
23
|
+
const h = u.hostname.toLowerCase();
|
|
24
|
+
if (!h) return false;
|
|
25
|
+
if (h === 'localhost' || h === '::1') return true;
|
|
26
|
+
const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
27
|
+
if (!m) return false;
|
|
28
|
+
const a = Number(m[1]);
|
|
29
|
+
const b = Number(m[2]);
|
|
30
|
+
return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 解析模型能力。优先级:customModels.<name>.contextWindow/maxOutputTokens > 内置 preset > 兜底。
|
|
38
|
+
* @param {any} cfg
|
|
39
|
+
* @param {string} modelName
|
|
40
|
+
* @returns {{ contextWindow: number, maxOutputTokens: number, isLocal: boolean, budgetTokens: number|null, preset: any }}
|
|
41
|
+
*/
|
|
42
|
+
export function resolveModelCaps(/** @type {any} */ cfg, /** @type {any} */ modelName) {
|
|
43
|
+
const preset = modelPreset(modelName);
|
|
44
|
+
const cm = (cfg?.customModels || {})[modelName] || {};
|
|
45
|
+
const baseUrl = cm.baseUrl || cfg?.baseUrl || '';
|
|
46
|
+
const isLocal = isLocalBaseUrl(baseUrl);
|
|
47
|
+
const contextWindow =
|
|
48
|
+
Number(cm.contextWindow) > 0
|
|
49
|
+
? Number(cm.contextWindow)
|
|
50
|
+
: preset?.contextWindow || (isLocal ? UNKNOWN_LOCAL_WINDOW : UNKNOWN_REMOTE_WINDOW);
|
|
51
|
+
const maxOutputTokens =
|
|
52
|
+
Number(cm.maxOutputTokens) > 0
|
|
53
|
+
? Number(cm.maxOutputTokens)
|
|
54
|
+
: preset?.maxOutputTokens || Math.min(DEFAULT_MAX_OUTPUT, Math.max(1024, Math.floor(contextWindow / 8)));
|
|
55
|
+
const budgetTokens = preset?.budgetTokens || null;
|
|
56
|
+
return { contextWindow, maxOutputTokens, isLocal, budgetTokens, preset };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 安全 prompt 预算:min(用户配置/预设, 窗口×75% 舒适区, 窗口−输出−余量)。
|
|
61
|
+
* 保证 prompt + maxOutput + 余量 ≤ contextWindow,且 prompt 不越舒适区(prefill 不爆炸)。
|
|
62
|
+
*/
|
|
63
|
+
export function safeBudget(/** @type {any} */ cfg, /** @type {any} */ caps) {
|
|
64
|
+
const ceiling = Math.max(1024, caps.contextWindow - caps.maxOutputTokens - OUTPUT_HEADROOM);
|
|
65
|
+
const comfort = Math.max(1024, Math.floor(caps.contextWindow * COMFORT_RATIO));
|
|
66
|
+
const configured = Number(cfg?.contextBudget) > 0 ? Number(cfg.contextBudget) : null;
|
|
67
|
+
const base = configured ?? caps.budgetTokens ?? comfort;
|
|
68
|
+
return Math.max(1024, Math.min(base, ceiling, comfort));
|
|
69
|
+
}
|
package/src/providers/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { chat as openaiChat } from './openai-compatible.js';
|
|
|
15
15
|
import { modelPreset, providerPreset } from '../models.js';
|
|
16
16
|
import { mingdaoHome } from '../config.js';
|
|
17
17
|
import { resolveApiKey } from '../credentials.js';
|
|
18
|
+
import { isLocalBaseUrl } from '../model-caps.js';
|
|
18
19
|
|
|
19
20
|
export function resolveProviderConfig(/** @type {any} */ cfg, /** @type {any} */ modelName) {
|
|
20
21
|
// 自定义模型(config.customModels,WebUI 可增删改):优先于内置预设
|
|
@@ -56,7 +57,7 @@ function isTransient(/** @type {any} */ err) {
|
|
|
56
57
|
return /timeout|超时|ECONNRESET|fetch failed/i.test(String(err?.message || ''));
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
export async function createProvider(/** @type {any} */ cfg, /** @type {any} */ modelName, { timeoutMs
|
|
60
|
+
export async function createProvider(/** @type {any} */ cfg, /** @type {any} */ modelName, /** @type {{ timeoutMs?: number, retries?: number }} */ { timeoutMs, retries = 2 } = {}) {
|
|
60
61
|
const pc = resolveProviderConfig(cfg, modelName);
|
|
61
62
|
|
|
62
63
|
// 自定义 Provider 模块优先(仅普通自定义端点;custom:<模型名> 走 OpenAI 兼容直连)
|
|
@@ -74,6 +75,18 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
74
75
|
throw new Error(`服务商 "${pc.name}" 缺少 baseUrl,请运行 mingdao init 重新配置。`);
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
// v0.3.2 本地模型自适应:本地推理框架(CPU/GPU 有限)长上下文 prefill 极慢(诊断实测 127k 上下文
|
|
79
|
+
// 首 token 需 196s+,q8 dequant 下 prefill 仅 ~165 tok/s)。默认超时按是否本地分层:
|
|
80
|
+
// - 首 token 等待:本地 600s / 远程 300s(覆盖慢 prefill,而非 189s 被掐断)
|
|
81
|
+
// - 流式空闲:有帧后 120s 无新帧即断(真正挂死才断,慢速吐字不误杀)
|
|
82
|
+
// - 总量:本地 30min / 远程 10min(长生成不误杀)
|
|
83
|
+
// 均可用 cfg.timeout.firstTokenMs / streamIdleMs / totalMs 覆盖。
|
|
84
|
+
const isLocal = isLocalBaseUrl(pc.baseUrl);
|
|
85
|
+
const tCfg = cfg?.timeout || {};
|
|
86
|
+
const firstTokenMs = Number(tCfg.firstTokenMs) > 0 ? Number(tCfg.firstTokenMs) : (isLocal ? 600000 : 300000);
|
|
87
|
+
const streamIdleMs = Number(tCfg.streamIdleMs) > 0 ? Number(tCfg.streamIdleMs) : 120000;
|
|
88
|
+
const totalMs = Number(tCfg.totalMs) > 0 ? Number(tCfg.totalMs) : (Number(timeoutMs) > 0 ? Number(timeoutMs) : (isLocal ? 1800000 : 600000));
|
|
89
|
+
|
|
77
90
|
return {
|
|
78
91
|
name: pc.name,
|
|
79
92
|
config: pc,
|
|
@@ -82,10 +95,32 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
82
95
|
for (;;) {
|
|
83
96
|
const ac = new AbortController();
|
|
84
97
|
let timedOut = false; // 审计 P2-6:用标志而非 name/字符串匹配识别内部超时
|
|
85
|
-
|
|
98
|
+
// 总量护栏:整次请求(prefill+生成)的绝对上限
|
|
99
|
+
const totalTimer = setTimeout(() => {
|
|
100
|
+
timedOut = true;
|
|
101
|
+
ac.abort(new Error(`请求总时长超限(${Math.round(totalMs / 1000)}s),已中断`));
|
|
102
|
+
}, totalMs);
|
|
103
|
+
// 首 token 等待:prefill 阶段无任何帧到达即断(覆盖长上下文慢 prefill)
|
|
104
|
+
let firstTokenTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (setTimeout(() => {
|
|
86
105
|
timedOut = true;
|
|
87
|
-
ac.abort(new Error(
|
|
88
|
-
},
|
|
106
|
+
ac.abort(new Error(`首 token 等待超限(${Math.round(firstTokenMs / 1000)}s,本地模型长上下文 prefill 可能很慢)——可调大 config.timeout.firstTokenMs,或拆分任务/压缩上下文`));
|
|
107
|
+
}, firstTokenMs));
|
|
108
|
+
// 流式空闲:有帧后 120s 无新帧即断;每收到一帧重置
|
|
109
|
+
let idleTimer = /** @type {ReturnType<typeof setTimeout> | null} */ (null);
|
|
110
|
+
const armIdle = () => {
|
|
111
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
112
|
+
idleTimer = setTimeout(() => {
|
|
113
|
+
timedOut = true;
|
|
114
|
+
ac.abort(new Error(`流式响应空闲超限(${Math.round(streamIdleMs / 1000)}s 无新数据)`));
|
|
115
|
+
}, streamIdleMs);
|
|
116
|
+
};
|
|
117
|
+
const onActivity = () => {
|
|
118
|
+
if (firstTokenTimer) {
|
|
119
|
+
clearTimeout(firstTokenTimer);
|
|
120
|
+
firstTokenTimer = null;
|
|
121
|
+
}
|
|
122
|
+
armIdle();
|
|
123
|
+
};
|
|
89
124
|
// 转发外部信号(用户 Ctrl+C 中断),避免被内部超时信号覆盖
|
|
90
125
|
const onUserAbort = () => ac.abort(opts.signal?.reason);
|
|
91
126
|
if (opts.signal?.aborted) onUserAbort();
|
|
@@ -97,20 +132,24 @@ export async function createProvider(/** @type {any} */ cfg, /** @type {any} */
|
|
|
97
132
|
apiKey: pc.apiKey,
|
|
98
133
|
signal: ac.signal,
|
|
99
134
|
includeUsage: cfg?.includeUsage !== false,
|
|
135
|
+
onActivity,
|
|
100
136
|
});
|
|
101
137
|
} catch (err) {
|
|
102
138
|
// 内部超时经 abort 抛出,用标志识别(审计 P2-6);用户 Ctrl+C 的中断不算超时、不重试
|
|
103
139
|
const transient = (timedOut && !opts.signal?.aborted) || isTransient(err);
|
|
104
140
|
if (!transient || attempt >= retries) throw err;
|
|
105
141
|
attempt += 1;
|
|
106
|
-
//
|
|
142
|
+
// 首 token 等待超时通常不是偶发网络抖动(是模型/上下文慢),重试价值低但保留一次机会;
|
|
143
|
+
// 其余瞬态错误指数退避 + 尊重 Retry-After(评估 P3-1):基础 1s/2s,封顶 30s
|
|
107
144
|
let backoff = 1000 * attempt;
|
|
108
145
|
const ra = Number((/** @type {any} */ (err))?.headers?.get?.('retry-after'));
|
|
109
146
|
if (Number.isFinite(ra) && ra > 0) backoff = Math.max(backoff, ra * 1000);
|
|
110
147
|
backoff = Math.min(backoff, 30000);
|
|
111
148
|
await sleep(backoff);
|
|
112
149
|
} finally {
|
|
113
|
-
clearTimeout(
|
|
150
|
+
clearTimeout(totalTimer);
|
|
151
|
+
if (firstTokenTimer) clearTimeout(firstTokenTimer);
|
|
152
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
114
153
|
opts.signal?.removeEventListener('abort', onUserAbort);
|
|
115
154
|
}
|
|
116
155
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* @typedef {Error & { status?: number, headers?: Headers }} ApiError
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, includeUsage = true, responseFormat, reasoningEffort }) {
|
|
9
|
+
export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages, tools, temperature, maxTokens, signal, onDelta, onActivity, includeUsage = true, responseFormat, reasoningEffort }) {
|
|
10
10
|
const url = String(baseUrl).replace(/\/+$/, '') + '/chat/completions';
|
|
11
11
|
const payload = /** @type {Record<string, any>} */ ({ model, messages });
|
|
12
12
|
if (temperature != null) payload.temperature = temperature;
|
|
@@ -62,7 +62,7 @@ export async function chat(/** @type {any} */ { baseUrl, apiKey, model, messages
|
|
|
62
62
|
if (!json) throw new Error(`[${model}] 响应解析失败。`);
|
|
63
63
|
return parseNonStream(json, onDelta);
|
|
64
64
|
}
|
|
65
|
-
return parseStream(res.body, onDelta);
|
|
65
|
+
return parseStream(res.body, onDelta, onActivity);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export function parseNonStream(/** @type {any} */ json, /** @type {any} */ onDelta) {
|
|
@@ -81,7 +81,9 @@ export function parseNonStream(/** @type {any} */ json, /** @type {any} */ onDel
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
// 解析 SSE 流:处理跨 chunk 断行、增量 content / reasoning_content / tool_calls。
|
|
84
|
-
|
|
84
|
+
// onActivity:每收到一个有效 SSE 数据帧即回调(含 usage-only 帧),供上层做「首 token 等待/流式空闲」超时仲裁——
|
|
85
|
+
// 长上下文 prefill 时服务端可能 200s+ 无正文,靠「有帧到达」而非「有正文」判定存活,避免误杀慢 prefill。
|
|
86
|
+
export async function parseStream(/** @type {any} */ body, /** @type {any} */ onDelta, /** @type {any} */ onActivity) {
|
|
85
87
|
const reader = body.getReader();
|
|
86
88
|
const decoder = new TextDecoder();
|
|
87
89
|
let buf = '';
|
|
@@ -151,6 +153,9 @@ export async function parseStream(/** @type {any} */ body, /** @type {any} */ on
|
|
|
151
153
|
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
152
154
|
const line = buf.slice(0, nl);
|
|
153
155
|
buf = buf.slice(nl + 1);
|
|
156
|
+
// 有帧到达即视为「活着」:prefill 阶段服务端可能先发 usage-only/空帧,正文迟迟不来,
|
|
157
|
+
// 靠帧到达刷新上层流式空闲计时器(首 token 等待则仍由「无任何帧」触发)
|
|
158
|
+
if (line.trim()) onActivity?.();
|
|
154
159
|
if (handleLine(line)) break;
|
|
155
160
|
}
|
|
156
161
|
}
|
package/src/web/app.js
CHANGED
|
@@ -629,6 +629,10 @@ async function init(){
|
|
|
629
629
|
$('#sbxHint').textContent=j.sandboxSupported?'':'当前环境未检测到 bubblewrap,readonly/safe 将自动降级为 off';
|
|
630
630
|
$('#routeChk').checked=Boolean(j.routing);
|
|
631
631
|
$('#budgetInput').value=j.contextBudget||128000;
|
|
632
|
+
const to=j.timeout||{};
|
|
633
|
+
$('#toFirstToken').value=to.firstTokenMs?Math.round(to.firstTokenMs/1000):'';
|
|
634
|
+
$('#toStreamIdle').value=to.streamIdleMs?Math.round(to.streamIdleMs/1000):'';
|
|
635
|
+
$('#toTotal').value=to.totalMs?Math.round(to.totalMs/1000):'';
|
|
632
636
|
$('#autoStartChk').checked=Boolean(j.autostart);
|
|
633
637
|
$('#notifyChk').checked=j.notify!==false;
|
|
634
638
|
applyReasoningUI(j.reasoning);
|
|
@@ -928,9 +932,9 @@ $('#cmAdd').onclick=async ()=>{
|
|
|
928
932
|
const name=$('#cmName').value.trim();
|
|
929
933
|
const url=$('#cmUrl').value.trim();
|
|
930
934
|
if(!name||!url){ uiAlert('模型名与 API 地址必填'); return; }
|
|
931
|
-
const r=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'addCustom',name,label:$('#cmLabel').value.trim(),baseUrl:url,key:$('#cmKey').value})});
|
|
935
|
+
const r=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'addCustom',name,label:$('#cmLabel').value.trim(),baseUrl:url,key:$('#cmKey').value,contextWindow:Number($('#cmCtx').value)||undefined,maxOutputTokens:Number($('#cmMaxOut').value)||undefined})});
|
|
932
936
|
const j=await r.json().catch(()=>({error:'请求失败'}));
|
|
933
|
-
if(j.ok){ $('#cmName').value=''; $('#cmLabel').value=''; $('#cmUrl').value=''; $('#cmKey').value=''; refreshModelsCfg(); reloadModels(); } else uiAlert(j.error||'添加失败');
|
|
937
|
+
if(j.ok){ $('#cmName').value=''; $('#cmLabel').value=''; $('#cmUrl').value=''; $('#cmKey').value=''; $('#cmCtx').value=''; $('#cmMaxOut').value=''; refreshModelsCfg(); reloadModels(); } else uiAlert(j.error||'添加失败');
|
|
934
938
|
};
|
|
935
939
|
$('#baseUrlSave').onclick=async ()=>{
|
|
936
940
|
const r=await fetch('/api/models-config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'setBaseUrl',baseUrl:$('#baseUrlOverride').value.trim()})});
|
|
@@ -1021,7 +1025,7 @@ async function refreshSyncConflicts(){
|
|
|
1021
1025
|
list.appendChild(div);
|
|
1022
1026
|
}
|
|
1023
1027
|
}
|
|
1024
|
-
$('#cfgSave').onclick=()=>{
|
|
1028
|
+
$('#cfgSave').onclick=()=>{ const to={}; const ft=Number($('#toFirstToken').value), si=Number($('#toStreamIdle').value), tt=Number($('#toTotal').value); if(ft>0) to.firstTokenMs=Math.round(ft*1000); if(si>0) to.streamIdleMs=Math.round(si*1000); if(tt>0) to.totalMs=Math.round(tt*1000); const payload={sandbox:$('#sbxSel').value, routing:$('#routeChk').checked, contextBudget:Number($('#budgetInput').value), autostart:$('#autoStartChk').checked, notify:$('#notifyChk').checked}; if(Object.keys(to).length) payload.timeout=to; applyConfig(payload); $('#cfgModal').style.display='none'; };
|
|
1025
1029
|
async function applyConfig(payload, revertTarget){
|
|
1026
1030
|
const r=await fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
1027
1031
|
const j=await r.json().catch(()=>({error:'请求失败'}));
|
package/src/web/index.html
CHANGED
|
@@ -378,6 +378,11 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
378
378
|
<input id="cmUrl" placeholder="API 地址 https://…/v1" style="flex:1.4;min-width:170px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
|
|
379
379
|
<input id="cmKey" type="password" placeholder="API Key(可选)" style="flex:1;min-width:120px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
|
|
380
380
|
</div>
|
|
381
|
+
<div class="cfg-row" style="display:flex;gap:6px;flex-wrap:wrap">
|
|
382
|
+
<input id="cmCtx" type="number" placeholder="上下文窗口 tokens(本地模型建议填,如 131072)" style="flex:1.6;min-width:180px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
|
|
383
|
+
<input id="cmMaxOut" type="number" placeholder="最大输出 tokens(可选,如 8192)" style="flex:1;min-width:120px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px">
|
|
384
|
+
</div>
|
|
385
|
+
<div class="cfg-hint">本地部署模型(127.0.0.1/localhost):填「上下文窗口」后预算自动收紧到窗口 75% 以内并留输出余量,长任务不再撑爆窗口。</div>
|
|
381
386
|
<div class="row" style="justify-content:flex-start"><button id="cmAdd">+ 添加自定义模型</button></div>
|
|
382
387
|
<div class="cfg-row" style="display:flex;gap:6px"><input id="baseUrlOverride" placeholder="API 地址覆盖(可选,覆盖当前服务商地址)" style="flex:1;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px"><button id="baseUrlSave">保存</button></div>
|
|
383
388
|
</section>
|
|
@@ -418,6 +423,12 @@ footer{flex:none;border-top:1px solid var(--border);background:var(--bg2);paddin
|
|
|
418
423
|
</div>
|
|
419
424
|
<div class="cfg-row"><label><input type="checkbox" id="routeChk"> 自动模型路由(规划→pro / 执行→flash)</label></div>
|
|
420
425
|
<div class="cfg-row"><label>上下文预算 tokens <input type="number" id="budgetInput" min="1000" step="1000"></label></div>
|
|
426
|
+
<div class="cfg-row" style="display:flex;gap:6px;flex-wrap:wrap;align-items:flex-end">
|
|
427
|
+
<label style="font-size:12px">首 token 等待(秒)<input type="number" id="toFirstToken" min="0" step="10" placeholder="自动"></label>
|
|
428
|
+
<label style="font-size:12px">流式空闲(秒)<input type="number" id="toStreamIdle" min="0" step="10" placeholder="自动"></label>
|
|
429
|
+
<label style="font-size:12px">总量(秒)<input type="number" id="toTotal" min="0" step="10" placeholder="自动"></label>
|
|
430
|
+
</div>
|
|
431
|
+
<div class="cfg-hint">留空 = 自适应:本地模型自动给更长首 token 等待(慢 prefill 不被误杀)。仅在默认值仍不够/过松时手填。</div>
|
|
421
432
|
<div class="cfg-row"><label><input type="checkbox" id="autoStartChk"> 开机自启(登录后自动启动服务器)</label></div>
|
|
422
433
|
<div class="cfg-row"><label><input type="checkbox" id="notifyChk"> 后台任务完成/失败时桌面通知</label></div>
|
|
423
434
|
</section>
|
|
@@ -50,6 +50,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
50
50
|
routing: cfg.routing?.enabled ? cfg.routing : null,
|
|
51
51
|
reasoning,
|
|
52
52
|
contextBudget: cfg.contextBudget || 128000,
|
|
53
|
+
timeout: cfg.timeout || null, // v0.3.2:分层超时(firstTokenMs/streamIdleMs/totalMs)
|
|
53
54
|
pricingAsOf: PRICE_DATA_AS_OF,
|
|
54
55
|
autostart: autostartStatus(),
|
|
55
56
|
notify: cfg.notify !== false,
|
|
@@ -112,6 +113,17 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
112
113
|
next.contextBudget = n;
|
|
113
114
|
cfg.contextBudget = n;
|
|
114
115
|
}
|
|
116
|
+
// v0.3.2 超时分层:firstTokenMs(首 token 等待)/ streamIdleMs(流式空闲)/ totalMs(总量)
|
|
117
|
+
if (body.timeout !== undefined && body.timeout && typeof body.timeout === 'object') {
|
|
118
|
+
cfg.timeout = cfg.timeout || {};
|
|
119
|
+
for (const k of ['firstTokenMs', 'streamIdleMs', 'totalMs']) {
|
|
120
|
+
const v = Number(body.timeout[k]);
|
|
121
|
+
if (Number.isFinite(v) && v > 0) cfg.timeout[k] = Math.round(v);
|
|
122
|
+
else if (body.timeout[k] === null || body.timeout[k] === '') delete cfg.timeout[k];
|
|
123
|
+
}
|
|
124
|
+
// 超时在 createProvider 时按 cfg 固化进实例,改后清缓存让新值即时生效(否则要重启)
|
|
125
|
+
providerCache.clear();
|
|
126
|
+
}
|
|
115
127
|
if (body.reasoningEffort !== undefined) {
|
|
116
128
|
const re = String(body.reasoningEffort);
|
|
117
129
|
if (!['off', 'low', 'high', 'max'].includes(re)) {
|
|
@@ -165,6 +177,8 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
165
177
|
baseUrl: cm.baseUrl || '',
|
|
166
178
|
envKey: cm.envKey || null,
|
|
167
179
|
vision: Boolean(cm.vision),
|
|
180
|
+
contextWindow: Number(cm.contextWindow) > 0 ? Number(cm.contextWindow) : null,
|
|
181
|
+
maxOutputTokens: Number(cm.maxOutputTokens) > 0 ? Number(cm.maxOutputTokens) : null,
|
|
168
182
|
keyState: stored ? 'stored' : 'none',
|
|
169
183
|
keyMasked: stored ? maskKey(stored) : null,
|
|
170
184
|
};
|
|
@@ -232,14 +246,23 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
232
246
|
return json(res, 400, { error: `自定义模型 ${name} 已存在(可修改)` });
|
|
233
247
|
}
|
|
234
248
|
cfg.customModels = cfg.customModels || {};
|
|
249
|
+
// 修改时保留未提交字段(tokenizer/contextWindow/maxOutputTokens/vision)——
|
|
250
|
+
// 前端「修改」只发 baseUrl+label,整体替换会丢这些声明,导致本地模型窗口回退兜底
|
|
251
|
+
const prev = action === 'updateCustom' ? (cfg.customModels[name] || {}) : {};
|
|
235
252
|
cfg.customModels[name] = {
|
|
236
253
|
label,
|
|
237
254
|
baseUrl,
|
|
238
|
-
envKey: String(body.envKey || '').trim() || undefined,
|
|
239
|
-
vision: body.vision === true || body.vision === 'on' ? true : undefined,
|
|
255
|
+
envKey: String(body.envKey || '').trim() || prev.envKey || undefined,
|
|
256
|
+
vision: body.vision === true || body.vision === 'on' ? true : prev.vision === true ? true : undefined,
|
|
257
|
+
...(prev.tokenizer ? { tokenizer: prev.tokenizer } : {}),
|
|
258
|
+
// v0.3.2 本地模型自适应:显式声明上下文窗口/最大输出,预算据此推导(不撑爆窗口)
|
|
259
|
+
...(Number(body.contextWindow) > 0 ? { contextWindow: Math.round(Number(body.contextWindow)) } : (prev.contextWindow ? { contextWindow: prev.contextWindow } : {})),
|
|
260
|
+
...(Number(body.maxOutputTokens) > 0 ? { maxOutputTokens: Math.round(Number(body.maxOutputTokens)) } : (prev.maxOutputTokens ? { maxOutputTokens: prev.maxOutputTokens } : {})),
|
|
240
261
|
};
|
|
241
262
|
if (String(body.key || '').trim()) setStoredKey(`custom:${name}`, String(body.key).trim());
|
|
242
263
|
saveConfig(cfg);
|
|
264
|
+
// baseUrl 改动影响 isLocal 判定 → 超时档位变化;清缓存让新超时/新端点即时生效
|
|
265
|
+
providerCache.clear();
|
|
243
266
|
return json(res, 200, { ok: true, name });
|
|
244
267
|
}
|
|
245
268
|
if (action === 'removeCustom') {
|
|
@@ -252,6 +275,7 @@ export async function handle({ req, res, method, p, url }, deps, shared) {
|
|
|
252
275
|
cfg.model = state.modelName;
|
|
253
276
|
}
|
|
254
277
|
saveConfig(cfg);
|
|
278
|
+
providerCache.clear();
|
|
255
279
|
return json(res, 200, { ok: true, name, model: state.modelName });
|
|
256
280
|
}
|
|
257
281
|
// 质检(自定义模型连通性):发起一次最小对话验证 baseUrl+Key 可用
|
package/src/web/server.js
CHANGED
|
@@ -551,7 +551,9 @@ export async function runWebServer({ host = '127.0.0.1', port = 3820, authToken
|
|
|
551
551
|
const { messageTokens } = await import('../context.js');
|
|
552
552
|
const counter = makeTokenCounter(runModel);
|
|
553
553
|
const used = messages.reduce((/** @type {any} */ sum, /** @type {any} */ m) => sum + messageTokens(m, counter), 0);
|
|
554
|
-
|
|
554
|
+
// v0.3.2:展示用 total 与 Agent 实际预算同源(safeBudget 按窗口推导),避免「显示 128k 实际 98k」的误导
|
|
555
|
+
const { resolveModelCaps, safeBudget } = await import('../model-caps.js');
|
|
556
|
+
budgetInfo = { used, total: safeBudget(cfg, resolveModelCaps(cfg, runModel)) };
|
|
555
557
|
} catch {}
|
|
556
558
|
send({
|
|
557
559
|
type: 'done',
|