@modusensus/dsh-mneme 0.7.26 → 0.7.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +1 -0
- package/README.md +1 -0
- package/lib/api.js +99 -0
- package/lib/dream.js +1 -1
- package/package.json +1 -1
- package/src/api.js +99 -0
- package/src/dream.js +1 -1
- package/test/api.test.js +163 -0
package/README.en.md
CHANGED
|
@@ -174,6 +174,7 @@ Every **background LLM call** (autoDream consolidation + summary, autoSummarize
|
|
|
174
174
|
|
|
175
175
|
| Version | Highlights |
|
|
176
176
|
|------|------|
|
|
177
|
+
| **v0.7.27** | v0.7.26 endpoint backfill: the v0.7.26 release tree did not include the `fix/dream-effort-trap` branch, so the `GET /api/dsh-mneme/llm-providers` + `POST /api/dsh-mneme/test-model` endpoints announced in its CHANGELOG/Release were absent from the package (the panel's cascading dropdown + connectivity test would 404); this release rebases and merges the branch (PR #100), landing the endpoints — panel connectivity test now works; 733 tests green |
|
|
177
178
|
| **v0.7.26** | Consolidation `UNSUPPORTED_REASONING_EFFORT` root-cause fix + dream/sleep model connectivity test: root-caused the **defaultEffort trap** — the harness injects `reasoning.defaultEffort` when effort is omitted, so if that default tier itself is unsupported, retrying without effort never helps (whatever effort is sent gets rejected); fix: new `resolveDreamEffort` proactively queries `ctx.llm.resolveModelInfo()` for the model's supported effort tiers and sends a supported one — an unsupported configured tier auto-falls back to the model's default/first supported tier, and the field is omitted when the model declares no reasoning capability. Settings panel adds selection hints on 6 dream/sleep model fields (guiding toward non-thinking models so they don't burn the token budget on reasoning). New `GET /api/dsh-mneme/llm-providers` (host-side provider/model discovery; keys never touch the plugin side) + `POST /api/dsh-mneme/test-model` (connectivity test; empty body resolves via the consolidation route and returns `modelId`); 732 tests green |
|
|
178
179
|
| **v0.7.25** | Tool-compat hardening + `memory_get`/render content preview + consolidation-model guide: new `memory_get` tool (8th model tool, read a single memory's full body by id) + `memory_search`/`memory_list` render now embeds title/metadata/body preview (the model reads actual memory content even when the host only forwards render text) + consolidation-model selection guide (config comments/README classification, non-thinking vs thinking). Fixed `memory_get` execute nested inside output crashing with `userExecute is not a function` (previous tests only counted tool names, never executed it) + tool de-dup on live patch reload + Standalone API port-collision retry (EADDRINUSE) + client inject declaration alignment + better-sidebar hardening; 718 tests green |
|
|
179
180
|
| **v0.7.24** | Fixed DSH Desktop plugin-tree load crash (v0.7.23 regression): cordis 4's ctx is a Proxy — accessing a property not declared in `inject` throws `cannot get property "webServer" without inject` (not `undefined`), and removing webServer from inject meant cordis no longer waited for the host service, so Desktop crashed on restart; fix: restored webServer to inject (cordis applies the plugin only after the host service is ready) + apply/register guard switched to `ctx.reflect.get` (inject-free read, returns `undefined` when absent, never throws); verified with a real cordis + dsh-host-webserver plugin (API routes 200, unknown path 404, headless silently inactive); 714 tests green |
|
package/README.md
CHANGED
|
@@ -215,6 +215,7 @@ v0.3.0 起新增**记忆基因**层:从记忆里抽取**命名实体**、**带
|
|
|
215
215
|
|
|
216
216
|
| 版本 | 亮点 |
|
|
217
217
|
|------|------|
|
|
218
|
+
| **v0.7.27** | v0.7.26 端点遗漏补齐:v0.7.26 发版树未含 `fix/dream-effort-trap` 分支,CHANGELOG/Release 宣告的 `GET /api/dsh-mneme/llm-providers` + `POST /api/dsh-mneme/test-model` 两个端点实际不在包内(面板「级联下拉 + 测试连通性」会 404);本版将分支 rebase 后合入(PR #100),端点落地,面板连通性测试可用;733 测试全绿 |
|
|
218
219
|
| **v0.7.26** | 记忆巩固 `UNSUPPORTED_REASONING_EFFORT` 根治 + 巩固/睡眠模型连通性测试:根治 **defaultEffort 陷阱**——harness 在 effort 省略时注入 `reasoning.defaultEffort`,若该默认档位本身不被模型支持,则去 effort 重试也无济于事(换什么 effort 都会被拒绝);修复:新增 `resolveDreamEffort` 经 `ctx.llm.resolveModelInfo()` 主动探测模型支持的档位再发送——配置档位不被支持时自动换用模型支持的默认/首个档位、模型声明无 reasoning 能力则省略字段;设置面板巩固/睡眠 6 字段补选型提示(引导非思考模型,避免再踩思考模型烧光 token 预算);新增 `GET /api/dsh-mneme/llm-providers`(宿主侧 provider/model 发现,密钥不经插件侧)+ `POST /api/dsh-mneme/test-model`(模型连通性测试,空 body 按巩固路由解析返回 modelId);732 测试全绿 |
|
|
219
220
|
| **v0.7.25** | 工具兼容性加固 + `memory_get`/render 内容预览 + 巩固模型引导:新增 `memory_get` 工具(第 8 个模型工具,按 id 读单条记忆全文)+ `memory_search`/`memory_list` render 嵌入标题/元数据/正文预览(宿主只透传 render 文本时模型也能直接读到记忆内容)+ 巩固模型选型引导(config 注释/README 分类声明,非思考 vs 思考模型差异);修复 `memory_get` execute 嵌套进 output 的崩溃(`userExecute is not a function`,此前测试只数工具名从未执行该工具而掩盖)+ 工具重复注册去重(live patch reload)+ Standalone API 端口冲突重试(EADDRINUSE)+ client inject 声明对齐 + better-sidebar 集成加固;718 测试全绿 |
|
|
220
221
|
| **v0.7.24** | 修复 DSH Desktop 插件树加载崩溃(v0.7.23 回归):cordis 4 的 ctx 是 Proxy,访问未在 inject 声明的 `webServer` 会抛 `cannot get property without inject`(而非返回 undefined),且去掉 inject 后 cordis 不再等待宿主服务 → 桌面端重启即崩;修复:恢复 webServer 到 inject(cordis 等宿主就绪再 apply)+ apply/register 守卫改 `ctx.reflect.get`(免 inject 读取、未提供返回 undefined 不抛错);真实 cordis + dsh-host-webserver 插件实测;714 测试全绿 |
|
package/lib/api.js
CHANGED
|
@@ -4,6 +4,7 @@ import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
|
4
4
|
import { FEATURE_FLAG_SPEC } from "./settings.js";
|
|
5
5
|
import { TYPE_FILE, renderMirrorText, parseHumanEdits } from "./mirror.js";
|
|
6
6
|
import { computeHeat } from "./heat.js";
|
|
7
|
+
import { describeStreamFailure, resolveRoute } from "./dream.js";
|
|
7
8
|
|
|
8
9
|
// headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
|
|
9
10
|
function sendJson(res, status, payload, headers = {}) {
|
|
@@ -277,6 +278,104 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
277
278
|
}
|
|
278
279
|
});
|
|
279
280
|
|
|
281
|
+
// --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
|
|
282
|
+
// Connectivity probe for a dream/sleep model route: runs one minimal LLM call
|
|
283
|
+
// through the same harness path the consolidation uses and reports success /
|
|
284
|
+
// failure + latency + error. Reuses describeStreamFailure so the panel shows
|
|
285
|
+
// the same error wording the audit rows carry. Optional reasoningEffort
|
|
286
|
+
// mirrors what the consolidation would send — the panel can verify a model
|
|
287
|
+
// actually accepts the configured effort without tripping a full run.
|
|
288
|
+
async function testLlmConnectivity(llm, provider, model, reasoningEffort) {
|
|
289
|
+
const startedAt = Date.now();
|
|
290
|
+
const elapsed = () => Date.now() - startedAt;
|
|
291
|
+
const modelId = `${provider}:${model}`;
|
|
292
|
+
try {
|
|
293
|
+
let reply = "";
|
|
294
|
+
let error = "";
|
|
295
|
+
for await (const chunk of llm.stream({
|
|
296
|
+
provider,
|
|
297
|
+
model,
|
|
298
|
+
purpose: "dsh-mneme-connectivity-test",
|
|
299
|
+
maxTokens: 16,
|
|
300
|
+
...(reasoningEffort && reasoningEffort !== "none" ? { reasoningEffort } : {}),
|
|
301
|
+
messages: [{ role: "system", content: [{ type: "text", text: "Reply with exactly one word: ok" }] }]
|
|
302
|
+
})) {
|
|
303
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text;
|
|
304
|
+
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
|
|
305
|
+
error = describeStreamFailure(chunk.reason);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (error) return { ok: false, durationMs: elapsed(), error, modelId };
|
|
310
|
+
return { ok: true, durationMs: elapsed(), modelId, reply: reply.trim().slice(0, 100) };
|
|
311
|
+
} catch (error) {
|
|
312
|
+
return { ok: false, durationMs: elapsed(), error: String(error?.message ?? error), modelId };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// List every provider + model the host has registered, so the panel can offer
|
|
317
|
+
// a dropdown over REAL models instead of free-text guesses. Read-only, no auth
|
|
318
|
+
// (same as list/search). Best-effort per provider: one whose model list can't
|
|
319
|
+
// be resolved yields an empty models array instead of failing the whole call.
|
|
320
|
+
register({
|
|
321
|
+
kind: "exact",
|
|
322
|
+
path: "/api/dsh-mneme/llm-providers",
|
|
323
|
+
handler(req, res) {
|
|
324
|
+
try {
|
|
325
|
+
const llm = ctx.llm;
|
|
326
|
+
if (typeof llm?.listProviders !== "function" || typeof llm?.listModels !== "function") {
|
|
327
|
+
return sendJson(res, 501, { error: "llm-unavailable" });
|
|
328
|
+
}
|
|
329
|
+
let providers;
|
|
330
|
+
try { providers = llm.listProviders() ?? []; } catch { providers = []; }
|
|
331
|
+
return Promise.all(providers.map(async (p) => {
|
|
332
|
+
let models = [];
|
|
333
|
+
try { models = (await llm.listModels(p.id)) ?? []; } catch { models = []; }
|
|
334
|
+
return {
|
|
335
|
+
provider: p.id,
|
|
336
|
+
models: models.map((m) => ({ id: m.id, name: m.name ?? m.id }))
|
|
337
|
+
};
|
|
338
|
+
})).then((rows) => sendJson(res, 200, { providers: rows }));
|
|
339
|
+
} catch {
|
|
340
|
+
return sendJson(res, 500, { error: "internal" });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Connectivity test for a dream/sleep model route. Runs one tiny LLM call and
|
|
346
|
+
// reports ok / failure + latency + error. Auth-gated: it spends the user's
|
|
347
|
+
// API quota, so a stray local page must not be able to drain it via loopback.
|
|
348
|
+
register({
|
|
349
|
+
kind: "exact",
|
|
350
|
+
path: "/api/dsh-mneme/test-model",
|
|
351
|
+
handler(req, res) {
|
|
352
|
+
try {
|
|
353
|
+
if (req.method !== "POST") return sendJson(res, 404, { error: "not-found" });
|
|
354
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
355
|
+
return readBody(req).then(async (text) => {
|
|
356
|
+
const body = parseBody(text);
|
|
357
|
+
let provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
358
|
+
let model = typeof body.model === "string" ? body.model.trim() : "";
|
|
359
|
+
if ((!provider && model) || (provider && !model)) return sendJson(res, 400, { error: "missing-provider-or-model" });
|
|
360
|
+
if (!provider && !model) {
|
|
361
|
+
// 空 = 按巩固路由解析(dreamProvider/dreamModel > agent 默认模型),
|
|
362
|
+
// 让面板一键测"当前巩固模型"而无需手填。
|
|
363
|
+
const route = resolveRoute(ctx, config ?? {}, ctx.logger);
|
|
364
|
+
if (!route) return sendJson(res, 400, { error: "no-route" });
|
|
365
|
+
provider = route.provider;
|
|
366
|
+
model = route.model;
|
|
367
|
+
}
|
|
368
|
+
const llm = ctx.llm;
|
|
369
|
+
if (typeof llm?.stream !== "function") return sendJson(res, 501, { error: "llm-unavailable" });
|
|
370
|
+
const result = await testLlmConnectivity(llm, provider, model, body.reasoningEffort);
|
|
371
|
+
return sendJson(res, result.ok ? 200 : 502, result);
|
|
372
|
+
});
|
|
373
|
+
} catch {
|
|
374
|
+
return sendJson(res, 500, { error: "internal" });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
280
379
|
// --- delete one memory by id ---
|
|
281
380
|
// Mutation route: apiToken-gated like the profile/rules writes above. POST
|
|
282
381
|
// body is JSON { id }. store.remove deletes silently, so existence is checked
|
package/lib/dream.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateDecisions, applyDecisions } from "./dream/decisions.js";
|
|
2
2
|
import { clusterMemories, findPotentialConflicts } from "./dream/clustering.js";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort };
|
|
4
|
+
export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute };
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
// Extract the first JSON array from LLM output, tolerating markdown fences,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modusensus/dsh-mneme",
|
|
3
3
|
"description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 7 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.27",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
package/src/api.js
CHANGED
|
@@ -4,6 +4,7 @@ import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
|
4
4
|
import { FEATURE_FLAG_SPEC } from "./settings.js";
|
|
5
5
|
import { TYPE_FILE, renderMirrorText, parseHumanEdits } from "./mirror.js";
|
|
6
6
|
import { computeHeat } from "./heat.js";
|
|
7
|
+
import { describeStreamFailure, resolveRoute } from "./dream.js";
|
|
7
8
|
|
|
8
9
|
// headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
|
|
9
10
|
function sendJson(res, status, payload, headers = {}) {
|
|
@@ -277,6 +278,104 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
277
278
|
}
|
|
278
279
|
});
|
|
279
280
|
|
|
281
|
+
// --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
|
|
282
|
+
// Connectivity probe for a dream/sleep model route: runs one minimal LLM call
|
|
283
|
+
// through the same harness path the consolidation uses and reports success /
|
|
284
|
+
// failure + latency + error. Reuses describeStreamFailure so the panel shows
|
|
285
|
+
// the same error wording the audit rows carry. Optional reasoningEffort
|
|
286
|
+
// mirrors what the consolidation would send — the panel can verify a model
|
|
287
|
+
// actually accepts the configured effort without tripping a full run.
|
|
288
|
+
async function testLlmConnectivity(llm, provider, model, reasoningEffort) {
|
|
289
|
+
const startedAt = Date.now();
|
|
290
|
+
const elapsed = () => Date.now() - startedAt;
|
|
291
|
+
const modelId = `${provider}:${model}`;
|
|
292
|
+
try {
|
|
293
|
+
let reply = "";
|
|
294
|
+
let error = "";
|
|
295
|
+
for await (const chunk of llm.stream({
|
|
296
|
+
provider,
|
|
297
|
+
model,
|
|
298
|
+
purpose: "dsh-mneme-connectivity-test",
|
|
299
|
+
maxTokens: 16,
|
|
300
|
+
...(reasoningEffort && reasoningEffort !== "none" ? { reasoningEffort } : {}),
|
|
301
|
+
messages: [{ role: "system", content: [{ type: "text", text: "Reply with exactly one word: ok" }] }]
|
|
302
|
+
})) {
|
|
303
|
+
if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text;
|
|
304
|
+
if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) {
|
|
305
|
+
error = describeStreamFailure(chunk.reason);
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (error) return { ok: false, durationMs: elapsed(), error, modelId };
|
|
310
|
+
return { ok: true, durationMs: elapsed(), modelId, reply: reply.trim().slice(0, 100) };
|
|
311
|
+
} catch (error) {
|
|
312
|
+
return { ok: false, durationMs: elapsed(), error: String(error?.message ?? error), modelId };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// List every provider + model the host has registered, so the panel can offer
|
|
317
|
+
// a dropdown over REAL models instead of free-text guesses. Read-only, no auth
|
|
318
|
+
// (same as list/search). Best-effort per provider: one whose model list can't
|
|
319
|
+
// be resolved yields an empty models array instead of failing the whole call.
|
|
320
|
+
register({
|
|
321
|
+
kind: "exact",
|
|
322
|
+
path: "/api/dsh-mneme/llm-providers",
|
|
323
|
+
handler(req, res) {
|
|
324
|
+
try {
|
|
325
|
+
const llm = ctx.llm;
|
|
326
|
+
if (typeof llm?.listProviders !== "function" || typeof llm?.listModels !== "function") {
|
|
327
|
+
return sendJson(res, 501, { error: "llm-unavailable" });
|
|
328
|
+
}
|
|
329
|
+
let providers;
|
|
330
|
+
try { providers = llm.listProviders() ?? []; } catch { providers = []; }
|
|
331
|
+
return Promise.all(providers.map(async (p) => {
|
|
332
|
+
let models = [];
|
|
333
|
+
try { models = (await llm.listModels(p.id)) ?? []; } catch { models = []; }
|
|
334
|
+
return {
|
|
335
|
+
provider: p.id,
|
|
336
|
+
models: models.map((m) => ({ id: m.id, name: m.name ?? m.id }))
|
|
337
|
+
};
|
|
338
|
+
})).then((rows) => sendJson(res, 200, { providers: rows }));
|
|
339
|
+
} catch {
|
|
340
|
+
return sendJson(res, 500, { error: "internal" });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Connectivity test for a dream/sleep model route. Runs one tiny LLM call and
|
|
346
|
+
// reports ok / failure + latency + error. Auth-gated: it spends the user's
|
|
347
|
+
// API quota, so a stray local page must not be able to drain it via loopback.
|
|
348
|
+
register({
|
|
349
|
+
kind: "exact",
|
|
350
|
+
path: "/api/dsh-mneme/test-model",
|
|
351
|
+
handler(req, res) {
|
|
352
|
+
try {
|
|
353
|
+
if (req.method !== "POST") return sendJson(res, 404, { error: "not-found" });
|
|
354
|
+
if (!requireAuth(req, res, apiToken)) return;
|
|
355
|
+
return readBody(req).then(async (text) => {
|
|
356
|
+
const body = parseBody(text);
|
|
357
|
+
let provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
358
|
+
let model = typeof body.model === "string" ? body.model.trim() : "";
|
|
359
|
+
if ((!provider && model) || (provider && !model)) return sendJson(res, 400, { error: "missing-provider-or-model" });
|
|
360
|
+
if (!provider && !model) {
|
|
361
|
+
// 空 = 按巩固路由解析(dreamProvider/dreamModel > agent 默认模型),
|
|
362
|
+
// 让面板一键测"当前巩固模型"而无需手填。
|
|
363
|
+
const route = resolveRoute(ctx, config ?? {}, ctx.logger);
|
|
364
|
+
if (!route) return sendJson(res, 400, { error: "no-route" });
|
|
365
|
+
provider = route.provider;
|
|
366
|
+
model = route.model;
|
|
367
|
+
}
|
|
368
|
+
const llm = ctx.llm;
|
|
369
|
+
if (typeof llm?.stream !== "function") return sendJson(res, 501, { error: "llm-unavailable" });
|
|
370
|
+
const result = await testLlmConnectivity(llm, provider, model, body.reasoningEffort);
|
|
371
|
+
return sendJson(res, result.ok ? 200 : 502, result);
|
|
372
|
+
});
|
|
373
|
+
} catch {
|
|
374
|
+
return sendJson(res, 500, { error: "internal" });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
280
379
|
// --- delete one memory by id ---
|
|
281
380
|
// Mutation route: apiToken-gated like the profile/rules writes above. POST
|
|
282
381
|
// body is JSON { id }. store.remove deletes silently, so existence is checked
|
package/src/dream.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateDecisions, applyDecisions } from "./dream/decisions.js";
|
|
2
2
|
import { clusterMemories, findPotentialConflicts } from "./dream/clustering.js";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort };
|
|
4
|
+
export { validateDecisions, applyDecisions, withEffortFallback, describeStreamFailure, resolveDreamEffort, resolveRoute };
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
// Extract the first JSON array from LLM output, tolerating markdown fences,
|
package/test/api.test.js
CHANGED
|
@@ -1085,3 +1085,166 @@ test("GET /api/dsh-mneme/list projects per-memory heat only when heatEnabled=tru
|
|
|
1085
1085
|
assert.ok(typeof v === "number" && v >= 0 && v <= 1, "heat values stay within [0,1]");
|
|
1086
1086
|
}
|
|
1087
1087
|
});
|
|
1088
|
+
|
|
1089
|
+
// ---------------------------------------------------------------- llm-providers / test-model
|
|
1090
|
+
// Settings-panel support: enumerate host-registered providers/models so the
|
|
1091
|
+
// panel can offer a dropdown over real models, and probe connectivity with a
|
|
1092
|
+
// minimal LLM call (same harness path the consolidation uses).
|
|
1093
|
+
|
|
1094
|
+
function setupLlm(llm, apiToken = "", extraCtx = {}, config = null) {
|
|
1095
|
+
const store = createStore(":memory:");
|
|
1096
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
1097
|
+
const settings = createSettings(store.db);
|
|
1098
|
+
const commands = { add: () => {}, remove: () => {}, list: () => [] };
|
|
1099
|
+
const routes = [];
|
|
1100
|
+
const ctx = {
|
|
1101
|
+
webServer: { register(route) { routes.push(route); return () => {}; } },
|
|
1102
|
+
llm,
|
|
1103
|
+
...extraCtx
|
|
1104
|
+
};
|
|
1105
|
+
const api = createApi(ctx, service, settings, commands, undefined, undefined, apiToken, config);
|
|
1106
|
+
return { routes };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const MOCK_LLM = {
|
|
1110
|
+
listProviders: () => [
|
|
1111
|
+
{ id: "deepseek", name: "DeepSeek" },
|
|
1112
|
+
{ id: "broken", name: "Broken Adapter" }
|
|
1113
|
+
],
|
|
1114
|
+
listModels: async (provider) => {
|
|
1115
|
+
if (provider === "broken") throw new Error("provider not reachable");
|
|
1116
|
+
return [{ id: "deepseek-chat", name: "DeepSeek Chat" }, { id: "deepseek-reasoner", name: "DeepSeek Reasoner" }];
|
|
1117
|
+
},
|
|
1118
|
+
async *stream(options) {
|
|
1119
|
+
MOCK_LLM.lastOptions = options;
|
|
1120
|
+
if (options.model === "bad-model") {
|
|
1121
|
+
yield {
|
|
1122
|
+
type: "finish",
|
|
1123
|
+
reason: { kind: "error", failure: { code: "AUTH_FAILED", message: "invalid api key" } }
|
|
1124
|
+
};
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
if (options.reasoningEffort === "reject") {
|
|
1128
|
+
throw new Error('UNSUPPORTED_REASONING_EFFORT: provider "mock" model "mock-model" does not support reasoning effort "reject"');
|
|
1129
|
+
}
|
|
1130
|
+
yield { type: "text-delta", index: 0, text: " ok " };
|
|
1131
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
test("GET /api/dsh-mneme/llm-providers lists host providers with models, per-provider best effort", async () => {
|
|
1136
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1137
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/llm-providers");
|
|
1138
|
+
const res = new FakeRes();
|
|
1139
|
+
await route.handler(req("/api/dsh-mneme/llm-providers"), res);
|
|
1140
|
+
assert.equal(res.statusCode, 200);
|
|
1141
|
+
const data = JSON.parse(res.body);
|
|
1142
|
+
assert.equal(data.providers.length, 2);
|
|
1143
|
+
const deepseek = data.providers.find((p) => p.provider === "deepseek");
|
|
1144
|
+
assert.deepEqual(deepseek.models.map((m) => m.id), ["deepseek-chat", "deepseek-reasoner"]);
|
|
1145
|
+
const broken = data.providers.find((p) => p.provider === "broken");
|
|
1146
|
+
assert.deepEqual(broken.models, [], "a provider whose model list throws degrades to empty, not a hard fail");
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
test("GET /api/dsh-mneme/llm-providers without ctx.llm returns 501", async () => {
|
|
1150
|
+
const { routes } = setup();
|
|
1151
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/llm-providers");
|
|
1152
|
+
const res = new FakeRes();
|
|
1153
|
+
await route.handler(req("/api/dsh-mneme/llm-providers"), res);
|
|
1154
|
+
assert.equal(res.statusCode, 501);
|
|
1155
|
+
assert.equal(JSON.parse(res.body).error, "llm-unavailable");
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
test("POST /api/dsh-mneme/test-model succeeds and reports reply + latency", async () => {
|
|
1159
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1160
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1161
|
+
const res = new FakeRes();
|
|
1162
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
|
|
1163
|
+
assert.equal(res.statusCode, 200);
|
|
1164
|
+
const data = JSON.parse(res.body);
|
|
1165
|
+
assert.equal(data.ok, true);
|
|
1166
|
+
assert.equal(data.reply, "ok", "reply trimmed");
|
|
1167
|
+
assert.ok(data.durationMs >= 0);
|
|
1168
|
+
assert.equal(data.modelId, "deepseek:deepseek-chat", "modelId reports the probed route");
|
|
1169
|
+
assert.equal(MOCK_LLM.lastOptions.provider, "deepseek");
|
|
1170
|
+
assert.equal(MOCK_LLM.lastOptions.purpose, "dsh-mneme-connectivity-test");
|
|
1171
|
+
assert.equal("reasoningEffort" in MOCK_LLM.lastOptions, false, "no effort configured -> field omitted");
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
test("POST /api/dsh-mneme/test-model forwards reasoningEffort verbatim", async () => {
|
|
1175
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1176
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1177
|
+
const res = new FakeRes();
|
|
1178
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat", reasoningEffort: "low" }), res);
|
|
1179
|
+
assert.equal(res.statusCode, 200);
|
|
1180
|
+
assert.equal(MOCK_LLM.lastOptions.reasoningEffort, "low", "effort forwarded to the probe call");
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
test("POST /api/dsh-mneme/test-model reports stream-level failure with 502", async () => {
|
|
1184
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1185
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1186
|
+
const res = new FakeRes();
|
|
1187
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "bad-model" }), res);
|
|
1188
|
+
assert.equal(res.statusCode, 502);
|
|
1189
|
+
const data = JSON.parse(res.body);
|
|
1190
|
+
assert.equal(data.ok, false);
|
|
1191
|
+
assert.match(data.error, /AUTH_FAILED/, "stream failure reason surfaced like audit rows");
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
test("POST /api/dsh-mneme/test-model reports thrown failure with 502", async () => {
|
|
1195
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1196
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1197
|
+
const res = new FakeRes();
|
|
1198
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "mock", model: "mock-model", reasoningEffort: "reject" }), res);
|
|
1199
|
+
assert.equal(res.statusCode, 502);
|
|
1200
|
+
const data = JSON.parse(res.body);
|
|
1201
|
+
assert.equal(data.ok, false);
|
|
1202
|
+
assert.match(data.error, /UNSUPPORTED_REASONING_EFFORT/, "throw path surfaces the harness rejection");
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
test("POST /api/dsh-mneme/test-model resolves empty body against the consolidation route", async () => {
|
|
1206
|
+
// Empty provider/model = "test whatever consolidation uses now": the route
|
|
1207
|
+
// falls back to agentDefaultModel (config dreamProvider/dreamModel absent),
|
|
1208
|
+
// the probe runs against that model, and modelId reports what was tested.
|
|
1209
|
+
const { routes } = setupLlm(MOCK_LLM, "", {
|
|
1210
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "sel-provider", model: "sel-model" }) }
|
|
1211
|
+
});
|
|
1212
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1213
|
+
const res = new FakeRes();
|
|
1214
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", {}), res);
|
|
1215
|
+
assert.equal(res.statusCode, 200);
|
|
1216
|
+
const data = JSON.parse(res.body);
|
|
1217
|
+
assert.equal(data.ok, true);
|
|
1218
|
+
assert.equal(data.modelId, "sel-provider:sel-model", "probe ran against the resolved consolidation route");
|
|
1219
|
+
assert.equal(MOCK_LLM.lastOptions.provider, "sel-provider");
|
|
1220
|
+
assert.equal(MOCK_LLM.lastOptions.model, "sel-model");
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
test("POST /api/dsh-mneme/test-model validates input and llm availability", async () => {
|
|
1224
|
+
const { routes } = setupLlm(MOCK_LLM);
|
|
1225
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1226
|
+
let res = new FakeRes();
|
|
1227
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek" }), res);
|
|
1228
|
+
assert.equal(res.statusCode, 400, "partial input (provider without model) rejected");
|
|
1229
|
+
assert.equal(JSON.parse(res.body).error, "missing-provider-or-model");
|
|
1230
|
+
|
|
1231
|
+
// empty body with no route (no agentDefaultModel, no config route) -> no-route
|
|
1232
|
+
res = new FakeRes();
|
|
1233
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", {}), res);
|
|
1234
|
+
assert.equal(res.statusCode, 400, "empty body with no resolvable route rejected");
|
|
1235
|
+
assert.equal(JSON.parse(res.body).error, "no-route");
|
|
1236
|
+
|
|
1237
|
+
const { routes: routesNoLlm } = setup();
|
|
1238
|
+
const routeNoLlm = routesNoLlm.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1239
|
+
res = new FakeRes();
|
|
1240
|
+
await routeNoLlm.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
|
|
1241
|
+
assert.equal(res.statusCode, 501, "no ctx.llm -> llm-unavailable");
|
|
1242
|
+
});
|
|
1243
|
+
|
|
1244
|
+
test("POST /api/dsh-mneme/test-model is auth-gated like other expensive endpoints", async () => {
|
|
1245
|
+
const { routes } = setupLlm(MOCK_LLM, "secret-token");
|
|
1246
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/test-model");
|
|
1247
|
+
const res = new FakeRes();
|
|
1248
|
+
await route.handler(req("/api/dsh-mneme/test-model", "POST", { provider: "deepseek", model: "deepseek-chat" }), res);
|
|
1249
|
+
assert.equal(res.statusCode, 401, "probe spends the user's API quota, so it must require auth");
|
|
1250
|
+
});
|