dsh-cindy-host 0.1.21 → 0.1.22
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/CHANGELOG.md +14 -0
- package/package.json +1 -1
- package/src/dsh-plugin.js +14 -1
- package/src/host-model-catalog.js +78 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
本文件记录**用户可见**的变化与**每次发布验证过的 DSH 版本**。格式遵循
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),版本号遵循语义化版本。
|
|
5
5
|
|
|
6
|
+
## [0.1.22] - 2026-10-03
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- 补齐桌面模型选择入口要求的真实上下文容量:Host 在请求级按精确 provider/model 从 DSH llm.resolveModelInfo 获取 context.contextWindow,不再仅转发省略容量的 catalog。三 alias 共用同一结果,查询去重、最多4路并发、总期限3秒,不修改模型路由或默认选择。
|
|
11
|
+
- 保留手机依赖的 routing 存在标记与未知容量0;单模型元数据失败不删除模型、不清空其它模型,不用 defaultMaxTokens 或硬编码数值冒充容量。真正未知容量在旧桌面的 >0 校验下仍会被过滤,需要客户端明确支持未知合同;本补丁不声称修复旧客户端这一固有限制。
|
|
12
|
+
- 双端回归现在经过真实桌面 parseDeviceProvidersPayload 和手机 deviceProvidersCache,再进入共享选择器。保留0.1.21响应“手机可见、桌面全被过滤”的红例;之前仅测共享筛选不构成完整桌面入口验收。
|
|
13
|
+
|
|
14
|
+
### 验证
|
|
15
|
+
|
|
16
|
+
- 完整测试:768 通过 / 1 跳过 / 0 失败;通道审计 54 served / 142 declined / 0 unclassified。
|
|
17
|
+
- 重启后的真实 HTTP 响应经过桌面解析器与手机缓存入口:新建及已有会话均 Host 7 / Desktop 7 / Mobile 7,2 个供应商、未知容量 0 项。此为本次安装环境观测,不是固定模型数量。
|
|
18
|
+
- 用户已确认当前双端界面测试通过。安装目录补丁与源码一致;本修复由 0.1.22 发布,0.1.21 不包含真实容量补齐。
|
|
19
|
+
|
|
6
20
|
## [0.1.21] - 2026-10-02
|
|
7
21
|
|
|
8
22
|
### Fixed
|
package/package.json
CHANGED
package/src/dsh-plugin.js
CHANGED
|
@@ -12,6 +12,7 @@ import { createAttachmentMaterializer } from './host-attachments.js';
|
|
|
12
12
|
import { createGoalWriter } from './host-goals.js';
|
|
13
13
|
import { startHost } from './host.js';
|
|
14
14
|
import { refusalError } from './cindy-channels.js';
|
|
15
|
+
import { enrichModelCatalog } from './host-model-catalog.js';
|
|
15
16
|
import { hydrateImageAttachments } from './cindy-message-row.js';
|
|
16
17
|
import { API_PREFIX, createHostRoutes } from './host-routes.js';
|
|
17
18
|
|
|
@@ -957,7 +958,19 @@ export function buildDshSource(ctx, serviceName, options = {}) {
|
|
|
957
958
|
* asks once per device: an empty catalog does not look like a missing
|
|
958
959
|
* feature, it looks like a Host with nothing to offer.
|
|
959
960
|
*/
|
|
960
|
-
modelCatalog: () =>
|
|
961
|
+
modelCatalog: async () => {
|
|
962
|
+
const catalog = await sessionController.modelCatalog();
|
|
963
|
+
const llm = controls.llm;
|
|
964
|
+
const enriched = await enrichModelCatalog(catalog,
|
|
965
|
+
typeof llm?.resolveModelInfo === 'function' ? llm.resolveModelInfo.bind(llm) : undefined);
|
|
966
|
+
if (enriched?.contextFailures?.length) {
|
|
967
|
+
ctx.logger?.warn?.('Cindy model context metadata unavailable', {
|
|
968
|
+
count: enriched.contextFailures.length,
|
|
969
|
+
codes: [...new Set(enriched.contextFailures.map((item) => item.code))],
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
return enriched;
|
|
973
|
+
},
|
|
961
974
|
/**
|
|
962
975
|
* Make sure a session has a live agent, and say whether it does.
|
|
963
976
|
*
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request-scoped display enrichment, not a second model registry.
|
|
3
|
+
* DSH's catalog owns membership/defaults; llm.resolveModelInfo owns exact-route
|
|
4
|
+
* context capacity. Keep metadata failures separate from catalog failures.
|
|
5
|
+
*/
|
|
6
|
+
export const MODEL_CONTEXT_CONCURRENCY = 4;
|
|
7
|
+
// Mobile device-link uses a 15s invoke deadline. Spend at most 3s of that on
|
|
8
|
+
// optional enrichment, leaving the existing catalog/transport their own budget.
|
|
9
|
+
export const MODEL_CONTEXT_TIMEOUT_MS = 3_000;
|
|
10
|
+
const positiveWindow = (value) => Number.isSafeInteger(value) && value > 0;
|
|
11
|
+
|
|
12
|
+
export async function enrichModelCatalog(catalog, resolveModelInfo, {
|
|
13
|
+
timeoutMs = MODEL_CONTEXT_TIMEOUT_MS, signal,
|
|
14
|
+
} = {}) {
|
|
15
|
+
if (!Array.isArray(catalog?.groups)) return catalog;
|
|
16
|
+
const groups = catalog.groups.map((group) => ({ ...group,
|
|
17
|
+
models: (Array.isArray(group?.models) ? group.models : []).map((model) => ({ ...model })),
|
|
18
|
+
}));
|
|
19
|
+
const routes = new Map();
|
|
20
|
+
for (const group of groups) {
|
|
21
|
+
if (typeof group.id !== 'string' || !group.id) continue;
|
|
22
|
+
for (const model of group.models) {
|
|
23
|
+
if (typeof model.id !== 'string' || !model.id || positiveWindow(model.contextWindow)) continue;
|
|
24
|
+
const key = JSON.stringify([group.id, model.id]);
|
|
25
|
+
if (!routes.has(key)) routes.set(key, { provider: group.id, id: model.id, models: [] });
|
|
26
|
+
routes.get(key).models.push(model);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const pending = [...routes.values()];
|
|
30
|
+
const contextFailures = [];
|
|
31
|
+
const failed = (route, code) => contextFailures.push({ provider: route.provider, model: route.id, code });
|
|
32
|
+
if (typeof resolveModelInfo !== 'function') {
|
|
33
|
+
for (const route of pending) failed(route, 'metadata-service-unavailable');
|
|
34
|
+
return { ...catalog, groups, contextFailures };
|
|
35
|
+
}
|
|
36
|
+
if (!pending.length) return { ...catalog, groups, contextFailures };
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const cancel = () => controller.abort();
|
|
39
|
+
if (signal?.aborted) cancel();
|
|
40
|
+
else signal?.addEventListener('abort', cancel, { once: true });
|
|
41
|
+
let expire;
|
|
42
|
+
const expired = new Promise((resolve) => { expire = resolve; });
|
|
43
|
+
const aborted = () => expire({ code: signal?.aborted ? 'metadata-cancelled' : 'metadata-deadline' });
|
|
44
|
+
controller.signal.addEventListener('abort', aborted, { once: true });
|
|
45
|
+
if (controller.signal.aborted) aborted();
|
|
46
|
+
const timer = setTimeout(cancel, timeoutMs);
|
|
47
|
+
let cursor = 0;
|
|
48
|
+
async function worker() {
|
|
49
|
+
while (cursor < pending.length) {
|
|
50
|
+
const route = pending[cursor++];
|
|
51
|
+
if (controller.signal.aborted) {
|
|
52
|
+
failed(route, signal?.aborted ? 'metadata-cancelled' : 'metadata-deadline');
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
// Observe rejection even if a non-cooperative resolver outlives the deadline.
|
|
56
|
+
const lookup = Promise.resolve()
|
|
57
|
+
.then(() => resolveModelInfo(route.provider, route.id, controller.signal))
|
|
58
|
+
.then((value) => ({ value }), () => ({ code: 'metadata-read-failed' }));
|
|
59
|
+
const result = await Promise.race([lookup, expired]);
|
|
60
|
+
if (result.code) { failed(route, result.code); continue; }
|
|
61
|
+
const info = result.value;
|
|
62
|
+
if (info?.provider !== route.provider || info?.id !== route.id) {
|
|
63
|
+
failed(route, 'metadata-identity-mismatch'); continue;
|
|
64
|
+
}
|
|
65
|
+
const window = info?.context?.contextWindow;
|
|
66
|
+
if (!positiveWindow(window)) { failed(route, 'metadata-unknown'); continue; }
|
|
67
|
+
for (const model of route.models) model.contextWindow = window;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await Promise.all(Array.from({ length: Math.min(MODEL_CONTEXT_CONCURRENCY, pending.length) }, worker));
|
|
72
|
+
return { ...catalog, groups, contextFailures };
|
|
73
|
+
} finally {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
signal?.removeEventListener('abort', cancel);
|
|
76
|
+
controller.signal.removeEventListener('abort', aborted);
|
|
77
|
+
}
|
|
78
|
+
}
|