@megen-lebar/dsh-reasoning-effort 0.2.0
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.md +33 -0
- package/cordis.patch.yml +8 -0
- package/lib/client.js +1228 -0
- package/lib/index.js +565 -0
- package/lib/official-levels.json +8430 -0
- package/package.json +30 -0
- package/test/index.test.mjs +79 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-reasoning-effort — host half.
|
|
3
|
+
*
|
|
4
|
+
* Sidebar "思考强度" configurator for the DSH Web GUI. DSH stores every
|
|
5
|
+
* third-party provider's reasoning capability in the `llm-pi-ai` settings
|
|
6
|
+
* namespace (same section as settings.yaml's `llm-pi-ai:` block), validated by
|
|
7
|
+
* dsh-llm-pi-ai's `assertServiceable` and hot-reloaded into the adapter on the
|
|
8
|
+
* next request. This plugin exposes a tiny loopback-only HTTP surface that
|
|
9
|
+
* (a) reads the current configuration plus each model's *official* thinking
|
|
10
|
+
* levels from the pi-ai built-in catalog, and (b) writes user choices back
|
|
11
|
+
* through the settings service — no hand-edited YAML.
|
|
12
|
+
*
|
|
13
|
+
* Capabilities (loopback-only HTTP, same-origin with the web shell):
|
|
14
|
+
* - list providers/models with current reasoningEfforts + official levels
|
|
15
|
+
* - save a model's reasoningEfforts (+ optional route default / compat)
|
|
16
|
+
* - save/remove a provider-level default reasoning level
|
|
17
|
+
*
|
|
18
|
+
* Robustness: never throws out of `apply` (a throwing plugin kills the whole
|
|
19
|
+
* web boot). Missing settings service or missing pi-ai dependency downgrade to
|
|
20
|
+
* a read-only list / manual fallback, never a crash.
|
|
21
|
+
* @module @megen-lebar/dsh-reasoning-effort
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from "node:fs";
|
|
25
|
+
import { createRequire } from "node:module";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
28
|
+
|
|
29
|
+
const NAME = "reasoning-effort";
|
|
30
|
+
const API_PREFIX = "/api/dsh-reasoning";
|
|
31
|
+
const NS = "llm-pi-ai";
|
|
32
|
+
|
|
33
|
+
const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
34
|
+
const SUPPORTED_THINKING_FORMATS = ["openai", "deepseek", "openrouter", "together", "zai", "qwen", "string-thinking", "ant-ling"];
|
|
35
|
+
|
|
36
|
+
// ---- official thinking-level catalog ----
|
|
37
|
+
// Prefer the pi-ai catalog bundled with the running DSH installation. The
|
|
38
|
+
// plugin snapshot remains an offline fallback for layouts where an out-of-tree
|
|
39
|
+
// linked package cannot resolve DSH's private dependency tree.
|
|
40
|
+
let officialData;
|
|
41
|
+
function officialDataMap() {
|
|
42
|
+
if (officialData === undefined) {
|
|
43
|
+
try {
|
|
44
|
+
const file = join(dirname(fileURLToPath(import.meta.url)), "official-levels.json");
|
|
45
|
+
const raw = JSON.parse(readFileSync(file, "utf8"));
|
|
46
|
+
officialData = raw && typeof raw === "object" ? raw : {};
|
|
47
|
+
} catch {
|
|
48
|
+
officialData = {};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return officialData;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let runtimeCatalogPromise;
|
|
55
|
+
async function runtimeCatalog() {
|
|
56
|
+
if (runtimeCatalogPromise === undefined) {
|
|
57
|
+
runtimeCatalogPromise = (async () => {
|
|
58
|
+
try {
|
|
59
|
+
const req = createRequire(import.meta.url);
|
|
60
|
+
let entry;
|
|
61
|
+
try {
|
|
62
|
+
entry = req.resolve("@earendil-works/pi-ai/providers/all");
|
|
63
|
+
} catch {
|
|
64
|
+
const roots = [
|
|
65
|
+
typeof process.resourcesPath === "string" ? process.resourcesPath : undefined,
|
|
66
|
+
process.argv[1] ? dirname(process.argv[1]) : undefined,
|
|
67
|
+
dirname(dirname(process.execPath)),
|
|
68
|
+
].filter(Boolean);
|
|
69
|
+
for (const root of roots) {
|
|
70
|
+
const candidate = join(root, "runtime", "node_modules", "@earendil-works", "pi-ai", "dist", "providers", "all.js");
|
|
71
|
+
try {
|
|
72
|
+
readFileSync(candidate, "utf8");
|
|
73
|
+
entry = candidate;
|
|
74
|
+
break;
|
|
75
|
+
} catch {}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!entry) return undefined;
|
|
79
|
+
const piAi = await import(pathToFileURL(entry).href);
|
|
80
|
+
if (typeof piAi.getBuiltinProviders !== "function" || typeof piAi.getBuiltinModels !== "function") return undefined;
|
|
81
|
+
return {
|
|
82
|
+
generatedAt: typeof piAi.getBuiltinModelDataGeneratedAt === "function" ? piAi.getBuiltinModelDataGeneratedAt() : undefined,
|
|
83
|
+
providers: piAi.getBuiltinProviders(),
|
|
84
|
+
getModels: piAi.getBuiltinModels,
|
|
85
|
+
};
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
})();
|
|
90
|
+
}
|
|
91
|
+
return runtimeCatalogPromise;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- tiny helpers ----
|
|
95
|
+
function isLoopbackRequest(request) {
|
|
96
|
+
const address = request.socket?.remoteAddress;
|
|
97
|
+
if (address !== "127.0.0.1" && address !== "::1" && address !== "::ffff:127.0.0.1") return false;
|
|
98
|
+
const host = request.headers?.host;
|
|
99
|
+
if (typeof host !== "string") return false;
|
|
100
|
+
let hostUrl;
|
|
101
|
+
try {
|
|
102
|
+
hostUrl = new URL("http://" + host);
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
if (hostUrl.hostname !== "127.0.0.1" && hostUrl.hostname !== "localhost" && hostUrl.hostname !== "[::1]") return false;
|
|
107
|
+
if (request.headers["sec-fetch-site"] === "cross-site") return false;
|
|
108
|
+
const origin = request.headers.origin;
|
|
109
|
+
if (origin === undefined) return true;
|
|
110
|
+
try {
|
|
111
|
+
return new URL(origin).host === hostUrl.host;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeJson(res, status, body) {
|
|
118
|
+
const payload = JSON.stringify(body);
|
|
119
|
+
res.writeHead(status, {
|
|
120
|
+
"content-type": "application/json; charset=utf-8",
|
|
121
|
+
"referrer-policy": "no-referrer",
|
|
122
|
+
});
|
|
123
|
+
res.end(payload);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function readJsonBody(req) {
|
|
127
|
+
const chunks = [];
|
|
128
|
+
let size = 0;
|
|
129
|
+
for await (const chunk of req) {
|
|
130
|
+
size += chunk.length;
|
|
131
|
+
if (size > 256 * 1024) return undefined;
|
|
132
|
+
chunks.push(chunk);
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
136
|
+
return parsed !== null && typeof parsed === "object" ? parsed : undefined;
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---- official catalog lookup (live pi-ai catalog first, bundled fallback) ----
|
|
143
|
+
function levelsFromRuntimeModel(model) {
|
|
144
|
+
if (!model || model.reasoning !== true) return [];
|
|
145
|
+
const map = model.thinkingLevelMap && typeof model.thinkingLevelMap === "object" ? model.thinkingLevelMap : {};
|
|
146
|
+
const levels = [];
|
|
147
|
+
for (const level of LEVELS) {
|
|
148
|
+
const mapped = map[level];
|
|
149
|
+
if (mapped === null) continue;
|
|
150
|
+
if ((level === "xhigh" || level === "max") && mapped === undefined) continue;
|
|
151
|
+
levels.push({ level, wire: mapped === undefined ? (level === "off" ? null : level) : mapped });
|
|
152
|
+
}
|
|
153
|
+
return levels;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function modelNameKey(value) {
|
|
157
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function preferredProviders(modelId, modelName) {
|
|
161
|
+
const key = modelNameKey(modelId + " " + (modelName || ""));
|
|
162
|
+
if (/(^|[\s/_-])(kimi|k3)([\s/_.-]|$)/.test(key)) return ["moonshotai-cn", "moonshotai", "kimi-coding"];
|
|
163
|
+
if (/(^|[\s/_-])deepseek([\s/_.-]|$)/.test(key)) return ["deepseek"];
|
|
164
|
+
if (/(^|[\s/_-])glm([\s/_.-]|$)/.test(key)) return ["zai", "zai-coding-cn"];
|
|
165
|
+
if (/(^|[\s/_-])qwen([\s/_.-]|$)/.test(key)) return ["qwen-token-plan-cn", "qwen-token-plan"];
|
|
166
|
+
if (/(^|[\s/_-])minimax([\s/_.-]|$)/.test(key)) return ["minimax-cn", "minimax"];
|
|
167
|
+
if (/(^|[\s/_-])(gpt|o[134])([\s/_.-]|$)/.test(key)) return ["openai", "openai-codex", "azure-openai-responses"];
|
|
168
|
+
if (/(^|[\s/_-])claude([\s/_.-]|$)/.test(key)) return ["anthropic"];
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function selectRuntimeModel(catalog, providerId, modelId, modelName) {
|
|
173
|
+
if (!catalog) return undefined;
|
|
174
|
+
const exactProvider = catalog.getModels(providerId).find((model) => model.id === modelId);
|
|
175
|
+
if (exactProvider) return { model: exactProvider, provider: providerId, match: "provider" };
|
|
176
|
+
|
|
177
|
+
const idMatches = [];
|
|
178
|
+
const nameMatches = [];
|
|
179
|
+
const wantedName = modelNameKey(modelName);
|
|
180
|
+
for (const provider of catalog.providers) {
|
|
181
|
+
for (const model of catalog.getModels(provider)) {
|
|
182
|
+
if (model.id === modelId) idMatches.push({ model, provider, match: "id" });
|
|
183
|
+
else if (wantedName && modelNameKey(model.name) === wantedName) nameMatches.push({ model, provider, match: "name" });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (idMatches.length === 1) return idMatches[0];
|
|
187
|
+
if (idMatches.length > 1) {
|
|
188
|
+
for (const preferred of preferredProviders(modelId, modelName)) {
|
|
189
|
+
const match = idMatches.find((candidate) => candidate.provider === preferred);
|
|
190
|
+
if (match) return match;
|
|
191
|
+
}
|
|
192
|
+
const sameName = idMatches.find((candidate) => modelNameKey(candidate.model.name) === wantedName);
|
|
193
|
+
return sameName || idMatches[0];
|
|
194
|
+
}
|
|
195
|
+
return nameMatches.length === 1 ? nameMatches[0] : undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function bundledOfficialLevels(providerId, modelId, modelName) {
|
|
199
|
+
const data = officialDataMap();
|
|
200
|
+
let entry = data[modelId];
|
|
201
|
+
if (!entry && modelName) entry = data[modelName];
|
|
202
|
+
if (!entry) return { found: false, reasoning: false, levels: [] };
|
|
203
|
+
|
|
204
|
+
const map = entry.levels && typeof entry.levels === "object" ? entry.levels : {};
|
|
205
|
+
const levels = [];
|
|
206
|
+
for (const level of LEVELS) {
|
|
207
|
+
if (Object.prototype.hasOwnProperty.call(map, level) && (entry.reasoning === true || level !== "off")) {
|
|
208
|
+
levels.push({ level, wire: map[level] });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
found: true,
|
|
213
|
+
reasoning: entry.reasoning === true,
|
|
214
|
+
thinkingFormat: entry.thinkingFormat,
|
|
215
|
+
provider: entry.provider,
|
|
216
|
+
match: entry.provider === providerId ? "provider" : "fallback",
|
|
217
|
+
source: "bundled",
|
|
218
|
+
levels,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function officialLevels(catalog, providerId, modelId, modelName) {
|
|
223
|
+
const selected = selectRuntimeModel(catalog, providerId, modelId, modelName);
|
|
224
|
+
if (!selected) return bundledOfficialLevels(providerId, modelId, modelName);
|
|
225
|
+
const model = selected.model;
|
|
226
|
+
return {
|
|
227
|
+
found: true,
|
|
228
|
+
reasoning: model.reasoning === true,
|
|
229
|
+
thinkingFormat: model.compat && model.compat.thinkingFormat,
|
|
230
|
+
provider: selected.provider,
|
|
231
|
+
match: selected.match,
|
|
232
|
+
source: "runtime",
|
|
233
|
+
levels: levelsFromRuntimeModel(model),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- build directory from the settings namespace ----
|
|
238
|
+
function providersOf(value) {
|
|
239
|
+
const providers = value && typeof value === "object" ? value.providers : undefined;
|
|
240
|
+
return providers && typeof providers === "object" ? providers : {};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function buildList(settings) {
|
|
244
|
+
let descriptor;
|
|
245
|
+
let value;
|
|
246
|
+
let registered = false;
|
|
247
|
+
if (settings) {
|
|
248
|
+
try {
|
|
249
|
+
const list = settings.describe ? settings.describe() : undefined;
|
|
250
|
+
const hit = list && list.find((d) => d.ns === NS);
|
|
251
|
+
if (hit) {
|
|
252
|
+
registered = true;
|
|
253
|
+
descriptor = { revision: hit.revision };
|
|
254
|
+
value = hit.value;
|
|
255
|
+
}
|
|
256
|
+
} catch {
|
|
257
|
+
registered = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const catalog = await runtimeCatalog();
|
|
262
|
+
const catalogProviders = new Set(catalog ? catalog.providers : []);
|
|
263
|
+
const providers = [];
|
|
264
|
+
const rawProviders = providersOf(value);
|
|
265
|
+
for (const providerId of Object.keys(rawProviders)) {
|
|
266
|
+
const profile = rawProviders[providerId];
|
|
267
|
+
if (!profile || typeof profile !== "object") continue;
|
|
268
|
+
|
|
269
|
+
const configuredModels = Array.isArray(profile.models) ? profile.models : [];
|
|
270
|
+
const overrides = profile.modelOverrides && typeof profile.modelOverrides === "object" ? profile.modelOverrides : {};
|
|
271
|
+
const useCatalog = configuredModels.length === 0 && catalogProviders.has(providerId);
|
|
272
|
+
const models = useCatalog ? catalog.getModels(providerId) : configuredModels;
|
|
273
|
+
const modelRows = [];
|
|
274
|
+
for (let i = 0; i < models.length; i++) {
|
|
275
|
+
const base = models[i];
|
|
276
|
+
if (!base || typeof base !== "object" || typeof base.id !== "string") continue;
|
|
277
|
+
const override = useCatalog && overrides[base.id] && typeof overrides[base.id] === "object" ? overrides[base.id] : undefined;
|
|
278
|
+
const model = override ? { ...base, ...override } : base;
|
|
279
|
+
const official = officialLevels(catalog, providerId, model.id, model.name);
|
|
280
|
+
modelRows.push({
|
|
281
|
+
index: i,
|
|
282
|
+
id: model.id,
|
|
283
|
+
name: model.name !== undefined ? model.name : model.id,
|
|
284
|
+
storage: useCatalog ? "override" : "models",
|
|
285
|
+
current: { reasoningEfforts: model.reasoningEfforts },
|
|
286
|
+
official,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
providers.push({
|
|
290
|
+
id: providerId,
|
|
291
|
+
displayName: profile.displayName !== undefined ? profile.displayName : providerId,
|
|
292
|
+
api: profile.api,
|
|
293
|
+
reasoning: profile.reasoning,
|
|
294
|
+
compat: profile.compat,
|
|
295
|
+
catalogProvider: catalogProviders.has(providerId),
|
|
296
|
+
models: modelRows,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
namespace: NS,
|
|
301
|
+
registered,
|
|
302
|
+
revision: descriptor ? descriptor.revision : undefined,
|
|
303
|
+
catalogAvailable: catalog !== undefined || Object.keys(officialDataMap()).length > 0,
|
|
304
|
+
catalogSource: catalog ? "runtime" : "bundled",
|
|
305
|
+
catalogGeneratedAt: catalog && catalog.generatedAt,
|
|
306
|
+
providers,
|
|
307
|
+
levels: LEVELS,
|
|
308
|
+
thinkingFormats: SUPPORTED_THINKING_FORMATS,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---- mutate op builders ----
|
|
313
|
+
function assertValidLevelInput(reasoningEfforts) {
|
|
314
|
+
// `false` is the llm-pi-ai schema's explicit non-reasoning/off-only mode.
|
|
315
|
+
if (reasoningEfforts === false) return false;
|
|
316
|
+
if (!reasoningEfforts || typeof reasoningEfforts !== "object") {
|
|
317
|
+
throw new Error("reasoningEfforts must be an object or false");
|
|
318
|
+
}
|
|
319
|
+
const declared = [];
|
|
320
|
+
for (const level of Object.keys(reasoningEfforts)) {
|
|
321
|
+
if (!LEVELS.includes(level)) throw new Error("unknown thinking level: " + level);
|
|
322
|
+
const wire = reasoningEfforts[level];
|
|
323
|
+
if (level === "off") {
|
|
324
|
+
if (wire !== null && wire !== undefined) throw new Error('"off" only accepts null (no thinking)');
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (wire === null || wire === undefined) {
|
|
328
|
+
throw new Error('only "off" may have an empty wire value; level "' + level + '" needs a wire spelling');
|
|
329
|
+
}
|
|
330
|
+
if (typeof wire !== "string" || wire.length === 0) {
|
|
331
|
+
throw new Error('level "' + level + '" wire must be a non-empty string');
|
|
332
|
+
}
|
|
333
|
+
declared.push(level);
|
|
334
|
+
}
|
|
335
|
+
if (declared.length === 0) {
|
|
336
|
+
throw new Error("reasoningEfforts offers no level beyond off; declare at least one thinking level");
|
|
337
|
+
}
|
|
338
|
+
const normalized = { off: null };
|
|
339
|
+
for (const level of LEVELS) {
|
|
340
|
+
if (level === "off") continue;
|
|
341
|
+
const wire = reasoningEfforts[level];
|
|
342
|
+
if (wire !== undefined && wire !== null) normalized[level] = wire;
|
|
343
|
+
}
|
|
344
|
+
return normalized;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ---- handlers ----
|
|
348
|
+
function makeHandlers(getSettings) {
|
|
349
|
+
const settings = () => getSettings();
|
|
350
|
+
|
|
351
|
+
async function list() {
|
|
352
|
+
return buildList(settings());
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function saveModel(body) {
|
|
356
|
+
const svc = settings();
|
|
357
|
+
if (!svc || typeof svc.mutate !== "function") {
|
|
358
|
+
throw new Error("settings service is unavailable; cannot write");
|
|
359
|
+
}
|
|
360
|
+
const provider = typeof body.provider === "string" ? body.provider : undefined;
|
|
361
|
+
const modelIndex = Number.isInteger(body.modelIndex) ? body.modelIndex : undefined;
|
|
362
|
+
const modelId = typeof body.modelId === "string" ? body.modelId : undefined;
|
|
363
|
+
if (!provider || (modelIndex === undefined && !modelId)) {
|
|
364
|
+
throw new Error("provider and model identity are required");
|
|
365
|
+
}
|
|
366
|
+
const reasoningEfforts = assertValidLevelInput(body.reasoningEfforts);
|
|
367
|
+
|
|
368
|
+
let desc;
|
|
369
|
+
try {
|
|
370
|
+
desc = svc.describe().find((d) => d.ns === NS);
|
|
371
|
+
} catch {
|
|
372
|
+
desc = undefined;
|
|
373
|
+
}
|
|
374
|
+
const revision = desc ? desc.revision : undefined;
|
|
375
|
+
const value = desc ? desc.value : undefined;
|
|
376
|
+
const userProfile = desc && desc.user && desc.user.providers && desc.user.providers[provider];
|
|
377
|
+
const resolvedProfile = value && value.providers && value.providers[provider];
|
|
378
|
+
if (!userProfile && !resolvedProfile) throw new Error(`unknown provider "${provider}"`);
|
|
379
|
+
|
|
380
|
+
const ops = [];
|
|
381
|
+
const storage = body.storage === "override" ? "override" : "models";
|
|
382
|
+
if (storage === "override") {
|
|
383
|
+
if (!modelId) throw new Error("catalog model id is required");
|
|
384
|
+
const catalog = await runtimeCatalog();
|
|
385
|
+
if (!catalog || !catalog.getModels(provider).some((model) => model.id === modelId)) {
|
|
386
|
+
throw new Error(`provider "${provider}" has no catalog model "${modelId}"`);
|
|
387
|
+
}
|
|
388
|
+
ops.push({
|
|
389
|
+
op: "set",
|
|
390
|
+
path: ["providers", provider, "modelOverrides", modelId, "reasoningEfforts"],
|
|
391
|
+
value: reasoningEfforts,
|
|
392
|
+
});
|
|
393
|
+
} else {
|
|
394
|
+
const source =
|
|
395
|
+
userProfile && Array.isArray(userProfile.models)
|
|
396
|
+
? userProfile.models
|
|
397
|
+
: (resolvedProfile && Array.isArray(resolvedProfile.models) ? resolvedProfile.models : undefined);
|
|
398
|
+
if (!source) throw new Error(`provider "${provider}" has no configured model list`);
|
|
399
|
+
const models = source.map((model) => ({ ...model }));
|
|
400
|
+
let targetIndex = modelId ? models.findIndex((model) => model && model.id === modelId) : modelIndex;
|
|
401
|
+
if (targetIndex < 0 || targetIndex >= models.length) throw new Error(`model "${modelId || modelIndex}" no longer exists; refresh and retry`);
|
|
402
|
+
models[targetIndex] = { ...models[targetIndex], reasoningEfforts };
|
|
403
|
+
ops.push({ op: "set", path: ["providers", provider, "models"], value: models });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Model-only off must not erase shared provider defaults or transport settings.
|
|
407
|
+
if (reasoningEfforts !== false) {
|
|
408
|
+
const defaultReasoning = body.defaultReasoning;
|
|
409
|
+
if (defaultReasoning !== undefined) {
|
|
410
|
+
if (defaultReasoning === null || defaultReasoning === "") {
|
|
411
|
+
ops.push({ op: "unset", path: ["providers", provider, "reasoning"] });
|
|
412
|
+
} else {
|
|
413
|
+
if (!LEVELS.includes(defaultReasoning)) throw new Error("invalid default reasoning level");
|
|
414
|
+
if (defaultReasoning !== "off" && !Object.prototype.hasOwnProperty.call(reasoningEfforts, defaultReasoning)) {
|
|
415
|
+
throw new Error(`default reasoning level "${defaultReasoning}" is not enabled for this model`);
|
|
416
|
+
}
|
|
417
|
+
ops.push({ op: "set", path: ["providers", provider, "reasoning"], value: defaultReasoning });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const compat = body.compat;
|
|
422
|
+
if (compat && typeof compat === "object") {
|
|
423
|
+
const tf = compat.thinkingFormat;
|
|
424
|
+
if (tf !== undefined) {
|
|
425
|
+
if (tf === null || tf === "") ops.push({ op: "unset", path: ["providers", provider, "compat", "thinkingFormat"] });
|
|
426
|
+
else {
|
|
427
|
+
if (!SUPPORTED_THINKING_FORMATS.includes(tf)) throw new Error("invalid thinkingFormat");
|
|
428
|
+
ops.push({ op: "set", path: ["providers", provider, "compat", "thinkingFormat"], value: tf });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (compat.supportsReasoningEffort === null || compat.supportsReasoningEffort === "") {
|
|
432
|
+
ops.push({ op: "unset", path: ["providers", provider, "compat", "supportsReasoningEffort"] });
|
|
433
|
+
} else if (typeof compat.supportsReasoningEffort === "boolean") {
|
|
434
|
+
ops.push({ op: "set", path: ["providers", provider, "compat", "supportsReasoningEffort"], value: compat.supportsReasoningEffort });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
await svc.mutate(NS, ops, revision);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
if (error && error.code === "SETTINGS_CONFLICT") {
|
|
443
|
+
const err = new Error("configuration changed elsewhere; refresh and retry");
|
|
444
|
+
err.conflict = true;
|
|
445
|
+
throw err;
|
|
446
|
+
}
|
|
447
|
+
throw error;
|
|
448
|
+
}
|
|
449
|
+
return { saved: true, offOnly: reasoningEfforts === false };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function saveProviderDefault(body) {
|
|
453
|
+
const svc = settings();
|
|
454
|
+
if (!svc || typeof svc.mutate !== "function") throw new Error("settings service is unavailable");
|
|
455
|
+
const provider = typeof body.provider === "string" ? body.provider : undefined;
|
|
456
|
+
if (!provider) throw new Error("provider is required");
|
|
457
|
+
let revision;
|
|
458
|
+
try {
|
|
459
|
+
const desc = svc.describe().find((d) => d.ns === NS);
|
|
460
|
+
revision = desc ? desc.revision : undefined;
|
|
461
|
+
} catch {
|
|
462
|
+
revision = undefined;
|
|
463
|
+
}
|
|
464
|
+
const level = body.reasoning;
|
|
465
|
+
const ops =
|
|
466
|
+
level === null || level === ""
|
|
467
|
+
? [{ op: "unset", path: ["providers", provider, "reasoning"] }]
|
|
468
|
+
: (LEVELS.includes(level)
|
|
469
|
+
? [{ op: "set", path: ["providers", provider, "reasoning"], value: level }]
|
|
470
|
+
: (() => { throw new Error("invalid reasoning level"); })());
|
|
471
|
+
await svc.mutate(NS, ops, revision);
|
|
472
|
+
return { saved: true };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return { list, saveModel, saveProviderDefault };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const inject = ["webServer"];
|
|
479
|
+
export { inject };
|
|
480
|
+
export { NAME as name };
|
|
481
|
+
export { makeHandlers };
|
|
482
|
+
|
|
483
|
+
function apply(ctx) {
|
|
484
|
+
const getSettings = () => {
|
|
485
|
+
try {
|
|
486
|
+
return ctx.get("settings");
|
|
487
|
+
} catch {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
const handlers = makeHandlers(getSettings);
|
|
492
|
+
|
|
493
|
+
const guard = (req, res) => {
|
|
494
|
+
if (!isLoopbackRequest(req)) {
|
|
495
|
+
writeJson(res, 403, { ok: false, code: "forbidden", error: "loopback only" });
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
if (req.method !== "POST") {
|
|
499
|
+
writeJson(res, 405, { ok: false, code: "method", error: "POST only" });
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
return true;
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
const routes = [
|
|
506
|
+
{
|
|
507
|
+
kind: "exact",
|
|
508
|
+
path: API_PREFIX + "/list",
|
|
509
|
+
handler: async (req, res) => {
|
|
510
|
+
if (!guard(req, res)) return;
|
|
511
|
+
try {
|
|
512
|
+
const value = await handlers.list();
|
|
513
|
+
writeJson(res, 200, { ok: true, value });
|
|
514
|
+
} catch (error) {
|
|
515
|
+
writeJson(res, 500, { ok: false, code: "internal", error: error instanceof Error ? error.message : String(error) });
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
kind: "exact",
|
|
521
|
+
path: API_PREFIX + "/save-model",
|
|
522
|
+
handler: async (req, res) => {
|
|
523
|
+
if (!guard(req, res)) return;
|
|
524
|
+
const body = await readJsonBody(req);
|
|
525
|
+
if (body === undefined) {
|
|
526
|
+
writeJson(res, 400, { ok: false, code: "bad-request", error: "invalid JSON body" });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
const value = await handlers.saveModel(body);
|
|
531
|
+
writeJson(res, 200, { ok: true, value });
|
|
532
|
+
} catch (error) {
|
|
533
|
+
const status = error && error.conflict ? 409 : 400;
|
|
534
|
+
writeJson(res, status, { ok: false, code: error && error.conflict ? "conflict" : "rejected", error: error instanceof Error ? error.message : String(error) });
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
kind: "exact",
|
|
540
|
+
path: API_PREFIX + "/save-provider-default",
|
|
541
|
+
handler: async (req, res) => {
|
|
542
|
+
if (!guard(req, res)) return;
|
|
543
|
+
const body = await readJsonBody(req);
|
|
544
|
+
if (body === undefined) {
|
|
545
|
+
writeJson(res, 400, { ok: false, code: "bad-request", error: "invalid JSON body" });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
try {
|
|
549
|
+
const value = await handlers.saveProviderDefault(body);
|
|
550
|
+
writeJson(res, 200, { ok: true, value });
|
|
551
|
+
} catch (error) {
|
|
552
|
+
writeJson(res, 400, { ok: false, code: "rejected", error: error instanceof Error ? error.message : String(error) });
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
];
|
|
557
|
+
|
|
558
|
+
const disposers = routes.map((route) => ctx.webServer.register(route));
|
|
559
|
+
ctx.effect(() => () => {
|
|
560
|
+
for (const dispose of disposers) dispose();
|
|
561
|
+
}, NAME + ": routes");
|
|
562
|
+
ctx.logger?.info?.("[" + NAME + "] mounted " + routes.length + " reasoning routes");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export { apply };
|