@fonlan/dsh-model-parameters 0.1.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/LICENSE +21 -0
- package/README.en.md +140 -0
- package/README.md +112 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +586 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +637 -0
- package/lib/types/index.d.ts +20 -0
- package/lib/types/server/catalog.d.ts +83 -0
- package/lib/types/server/rpc.d.ts +3 -0
- package/lib/types/server/service.d.ts +66 -0
- package/lib/types/shared/config.d.ts +53 -0
- package/package.json +98 -0
- package/src/client/api.ts +59 -0
- package/src/client/index.tsx +33 -0
- package/src/client/locales.ts +95 -0
- package/src/client/settings-section.css +172 -0
- package/src/client/settings-section.tsx +310 -0
- package/src/client/slot-augment.d.ts +14 -0
- package/src/index.ts +47 -0
- package/src/server/catalog.ts +300 -0
- package/src/server/rpc.ts +162 -0
- package/src/server/service.ts +258 -0
- package/src/shared/config.ts +106 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,637 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
//#region src/shared/config.ts
|
|
7
|
+
/**
|
|
8
|
+
* Plugin configuration and shared constants for @fonlan/dsh-model-parameters.
|
|
9
|
+
*
|
|
10
|
+
* The plugin owns the `model-parameters` settings namespace (stored in
|
|
11
|
+
* settings.yaml) and OBSERVES the `llm-pi-ai` namespace: whenever provider
|
|
12
|
+
* models are written there with missing capability fields (which is the norm
|
|
13
|
+
* for a freshly synced gateway), it patches the missing fields from the cached
|
|
14
|
+
* models.dev catalog.
|
|
15
|
+
*/
|
|
16
|
+
/** This plugin's own settings namespace. */
|
|
17
|
+
const PLUGIN_NS = "model-parameters";
|
|
18
|
+
/** The namespace whose models this plugin completes: pi-ai provider profiles. */
|
|
19
|
+
const TARGET_NS = "llm-pi-ai";
|
|
20
|
+
/** models.dev catalog endpoint. */
|
|
21
|
+
const CATALOG_URL = "https://models.dev/api.json";
|
|
22
|
+
/** Default provider ids treated as first-party vendors (tie-break for id collisions). */
|
|
23
|
+
const DEFAULT_OFFICIAL_PROVIDERS = [
|
|
24
|
+
"deepseek",
|
|
25
|
+
"openai",
|
|
26
|
+
"anthropic",
|
|
27
|
+
"google",
|
|
28
|
+
"xai",
|
|
29
|
+
"moonshotai",
|
|
30
|
+
"zai",
|
|
31
|
+
"zhipuai",
|
|
32
|
+
"minimax",
|
|
33
|
+
"stepfun",
|
|
34
|
+
"xiaomi",
|
|
35
|
+
"meta",
|
|
36
|
+
"cohere",
|
|
37
|
+
"nvidia",
|
|
38
|
+
"alibaba"
|
|
39
|
+
];
|
|
40
|
+
/**
|
|
41
|
+
* dsh pi-ai only accepts these request modalities; models.dev may also list
|
|
42
|
+
* pdf/audio/video, which must be filtered out before writing (schema rejects
|
|
43
|
+
* unknown modality values).
|
|
44
|
+
*/
|
|
45
|
+
const ACCEPTED_INPUT_MODALITIES = ["text", "image"];
|
|
46
|
+
/**
|
|
47
|
+
* Valid `reasoningEfforts` keys in pi-ai, in escalation order. models.dev
|
|
48
|
+
* effort values like `default`/`none`/empty are filtered out; only values in
|
|
49
|
+
* this set are written.
|
|
50
|
+
*/
|
|
51
|
+
const REASONING_LEVELS = [
|
|
52
|
+
"off",
|
|
53
|
+
"minimal",
|
|
54
|
+
"low",
|
|
55
|
+
"medium",
|
|
56
|
+
"high",
|
|
57
|
+
"xhigh",
|
|
58
|
+
"max"
|
|
59
|
+
];
|
|
60
|
+
const Config = z.object({
|
|
61
|
+
enabled: z.boolean().default(true),
|
|
62
|
+
ttlDays: z.number().min(1).max(90).default(7),
|
|
63
|
+
fillName: z.boolean().default(true),
|
|
64
|
+
fillContext: z.boolean().default(true),
|
|
65
|
+
fillMaxTokens: z.boolean().default(true),
|
|
66
|
+
fillReasoning: z.boolean().default(true),
|
|
67
|
+
fillInput: z.boolean().default(true),
|
|
68
|
+
providerMap: z.dict(z.string()).default({}),
|
|
69
|
+
officialProviders: z.array(z.string()).default([...DEFAULT_OFFICIAL_PROVIDERS])
|
|
70
|
+
});
|
|
71
|
+
const DEFAULT_CONFIG = {
|
|
72
|
+
enabled: true,
|
|
73
|
+
ttlDays: 7,
|
|
74
|
+
fillName: true,
|
|
75
|
+
fillContext: true,
|
|
76
|
+
fillMaxTokens: true,
|
|
77
|
+
fillReasoning: true,
|
|
78
|
+
fillInput: true,
|
|
79
|
+
providerMap: {},
|
|
80
|
+
officialProviders: [...DEFAULT_OFFICIAL_PROVIDERS]
|
|
81
|
+
};
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/server/catalog.ts
|
|
84
|
+
/**
|
|
85
|
+
* models.dev catalog: fetch, disk cache, lazy TTL refresh, and id matching.
|
|
86
|
+
*
|
|
87
|
+
* The catalog is flattened into a list of entries keyed by provider, then
|
|
88
|
+
* indexed by the last path segment of the model id (case-insensitive) so a
|
|
89
|
+
* synced bare id like `qwen3.7-max` can resolve `Qwen/Qwen3.7-Max` wherever
|
|
90
|
+
* models.dev carries it.
|
|
91
|
+
*
|
|
92
|
+
* Collisions are real: the same bare id appears under dozens of providers with
|
|
93
|
+
* different limits. Resolution is deterministic and provider-aware:
|
|
94
|
+
* 1. the models.dev provider mapped for the dsh provider (providerMap
|
|
95
|
+
* override, then same provider id);
|
|
96
|
+
* 2. an exact full-id match (the dsh model id already carries a vendor/);
|
|
97
|
+
* 3. first-party vendor list position (officialProviders);
|
|
98
|
+
* 4. entry completeness (both context and output limits known);
|
|
99
|
+
* 5. alphabetical provider id (stable last resort).
|
|
100
|
+
*/
|
|
101
|
+
const DAY_MS = 864e5;
|
|
102
|
+
const FETCH_TIMEOUT_MS = 2e4;
|
|
103
|
+
function strippedId(id) {
|
|
104
|
+
return (id.split("/").pop() ?? id).toLowerCase();
|
|
105
|
+
}
|
|
106
|
+
function isOfficial(provider, official) {
|
|
107
|
+
const index = official.indexOf(provider);
|
|
108
|
+
return index === -1 ? official.length : index;
|
|
109
|
+
}
|
|
110
|
+
function completeness(entry) {
|
|
111
|
+
return entry.context !== void 0 && entry.output !== void 0 ? 0 : 1;
|
|
112
|
+
}
|
|
113
|
+
var CatalogManager = class {
|
|
114
|
+
logger;
|
|
115
|
+
snapshot = {
|
|
116
|
+
entries: [],
|
|
117
|
+
providers: 0,
|
|
118
|
+
fetchedAt: 0
|
|
119
|
+
};
|
|
120
|
+
loaded = false;
|
|
121
|
+
inflight = null;
|
|
122
|
+
dir;
|
|
123
|
+
cachePath;
|
|
124
|
+
constructor(logger, dataDir) {
|
|
125
|
+
this.logger = logger;
|
|
126
|
+
this.dir = dataDir ?? join(homedir(), ".dsh", "model-parameters");
|
|
127
|
+
this.cachePath = join(this.dir, "catalog.json");
|
|
128
|
+
}
|
|
129
|
+
/** The current snapshot (empty until the first load/fetch completes). */
|
|
130
|
+
snapshotOf() {
|
|
131
|
+
return this.snapshot;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Load the disk cache once, then fetch when stale (or `force`). A failed
|
|
135
|
+
* fetch keeps the last good cache; with no cache at all the snapshot stays
|
|
136
|
+
* empty and the caller simply fills nothing. Concurrent callers share one
|
|
137
|
+
* in-flight refresh.
|
|
138
|
+
* @returns true when the snapshot may have changed.
|
|
139
|
+
*/
|
|
140
|
+
async ensureFresh(config, force = false) {
|
|
141
|
+
if (!this.loaded) {
|
|
142
|
+
await this.loadCache();
|
|
143
|
+
this.loaded = true;
|
|
144
|
+
}
|
|
145
|
+
const stale = Date.now() - this.snapshot.fetchedAt >= config.ttlDays * DAY_MS;
|
|
146
|
+
if (!force && !stale) return false;
|
|
147
|
+
if (this.inflight !== null) {
|
|
148
|
+
await this.inflight;
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
this.inflight = this.fetchAndPersist().catch((error) => {
|
|
152
|
+
this.logger.warn(`model-parameters: catalog fetch failed (${error instanceof Error ? error.message : String(error)}); using cached copy`);
|
|
153
|
+
}).finally(() => {
|
|
154
|
+
this.inflight = null;
|
|
155
|
+
});
|
|
156
|
+
await this.inflight;
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
/** Force a fetch now; never throws (falls back to cache or keeps empty). */
|
|
160
|
+
async refresh(ttlDays) {
|
|
161
|
+
return this.ensureFresh({ ttlDays }, true);
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Resolve one dsh model id to the best catalog entry for its provider.
|
|
165
|
+
* @param modelId - the model id stored in the dsh provider profile.
|
|
166
|
+
* @param dshProvider - the dsh provider route id.
|
|
167
|
+
* @param config - plugin config (providerMap + officialProviders).
|
|
168
|
+
*/
|
|
169
|
+
resolve(modelId, dshProvider, config) {
|
|
170
|
+
const mapped = config.providerMap[dshProvider];
|
|
171
|
+
const candidates = this.candidatesFor(modelId);
|
|
172
|
+
if (candidates.length === 0) return void 0;
|
|
173
|
+
const official = config.officialProviders;
|
|
174
|
+
const score = (entry) => {
|
|
175
|
+
return [
|
|
176
|
+
entry.provider === mapped || entry.provider === dshProvider ? 0 : 1,
|
|
177
|
+
modelId.includes("/") && strippedId(entry.id) === modelId.toLowerCase() ? 0 : 1,
|
|
178
|
+
isOfficial(entry.provider, official),
|
|
179
|
+
completeness(entry)
|
|
180
|
+
];
|
|
181
|
+
};
|
|
182
|
+
candidates.sort((a, b) => {
|
|
183
|
+
const sa = score(a);
|
|
184
|
+
const sb = score(b);
|
|
185
|
+
for (let i = 0; i < sa.length; i++) if (sa[i] !== sb[i]) return sa[i] - sb[i];
|
|
186
|
+
if (a.provider !== b.provider) return a.provider < b.provider ? -1 : 1;
|
|
187
|
+
return a.id < b.id ? -1 : 1;
|
|
188
|
+
});
|
|
189
|
+
return candidates[0];
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* All entries whose stripped id matches, plus any whose full id matches
|
|
193
|
+
* exactly (a dsh model id may already carry a vendor/ prefix that models.dev
|
|
194
|
+
* keys verbatim).
|
|
195
|
+
*/
|
|
196
|
+
candidatesFor(modelId) {
|
|
197
|
+
const stripped = strippedId(modelId);
|
|
198
|
+
const out = [];
|
|
199
|
+
for (const entry of this.snapshot.entries) if (strippedId(entry.id) === stripped) out.push(entry);
|
|
200
|
+
else if (modelId.includes("/") && entry.id.toLowerCase() === modelId.toLowerCase()) out.push(entry);
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
async loadCache() {
|
|
204
|
+
try {
|
|
205
|
+
const raw = await readFile(this.cachePath, "utf8");
|
|
206
|
+
const doc = JSON.parse(raw);
|
|
207
|
+
if (typeof doc.fetchedAt !== "number" || typeof doc.body !== "object" || doc.body === null) {
|
|
208
|
+
this.logger.warn("model-parameters: catalog cache is malformed; ignoring");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.snapshot = this.buildSnapshot(doc.body, doc.fetchedAt);
|
|
212
|
+
this.logger.info(`model-parameters: loaded ${this.snapshot.entries.length} catalog entries from cache (fetched ${new Date(doc.fetchedAt).toISOString()})`);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
if (error.code !== "ENOENT") this.logger.warn(`model-parameters: catalog cache read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
async fetchAndPersist() {
|
|
218
|
+
const controller = new AbortController();
|
|
219
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
220
|
+
let response;
|
|
221
|
+
try {
|
|
222
|
+
response = await fetch(CATALOG_URL, {
|
|
223
|
+
signal: controller.signal,
|
|
224
|
+
headers: { accept: "application/json" }
|
|
225
|
+
});
|
|
226
|
+
} finally {
|
|
227
|
+
clearTimeout(timer);
|
|
228
|
+
}
|
|
229
|
+
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
230
|
+
const body = await response.json();
|
|
231
|
+
const fetchedAt = Date.now();
|
|
232
|
+
this.snapshot = this.buildSnapshot(body, fetchedAt);
|
|
233
|
+
await mkdir(this.dir, { recursive: true });
|
|
234
|
+
const doc = {
|
|
235
|
+
fetchedAt,
|
|
236
|
+
body
|
|
237
|
+
};
|
|
238
|
+
await writeFile(this.cachePath, JSON.stringify(doc), "utf8");
|
|
239
|
+
this.logger.info(`model-parameters: catalog refreshed from models.dev (${this.snapshot.providers} providers, ${this.snapshot.entries.length} models)`);
|
|
240
|
+
}
|
|
241
|
+
buildSnapshot(body, fetchedAt) {
|
|
242
|
+
const entries = [];
|
|
243
|
+
for (const [provider, raw] of Object.entries(body)) {
|
|
244
|
+
if (typeof raw !== "object" || raw === null) continue;
|
|
245
|
+
const models = raw.models;
|
|
246
|
+
if (typeof models !== "object" || models === null) continue;
|
|
247
|
+
for (const [id, rawModel] of Object.entries(models)) {
|
|
248
|
+
if (typeof rawModel !== "object" || rawModel === null) continue;
|
|
249
|
+
const model = rawModel;
|
|
250
|
+
const limit = typeof model.limit === "object" && model.limit !== null ? model.limit : {};
|
|
251
|
+
const entry = {
|
|
252
|
+
provider,
|
|
253
|
+
id
|
|
254
|
+
};
|
|
255
|
+
if (typeof model.name === "string" && model.name.length > 0) entry.name = model.name;
|
|
256
|
+
if (typeof limit.context === "number") entry.context = limit.context;
|
|
257
|
+
if (typeof limit.output === "number") entry.output = limit.output;
|
|
258
|
+
if (typeof model.reasoning === "boolean") entry.reasoning = model.reasoning;
|
|
259
|
+
const effortValues = [];
|
|
260
|
+
if (Array.isArray(model.reasoning_options)) {
|
|
261
|
+
for (const option of model.reasoning_options) if (typeof option === "object" && option !== null && option.type === "effort") {
|
|
262
|
+
const values = option.values;
|
|
263
|
+
if (Array.isArray(values)) {
|
|
264
|
+
for (const value of values) if (typeof value === "string") effortValues.push(value);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (effortValues.length > 0) entry.effortValues = effortValues;
|
|
269
|
+
const modalities = typeof model.modalities === "object" && model.modalities !== null ? model.modalities : {};
|
|
270
|
+
if (Array.isArray(modalities.input)) {
|
|
271
|
+
const input = modalities.input.filter((value) => typeof value === "string");
|
|
272
|
+
if (input.length > 0) entry.input = input;
|
|
273
|
+
}
|
|
274
|
+
entries.push(entry);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
entries,
|
|
279
|
+
providers: Object.keys(body).length,
|
|
280
|
+
fetchedAt
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* Build the dsh `reasoningEfforts` identity dict from models.dev effort
|
|
286
|
+
* values, filtered to the levels dsh accepts. Returns undefined when nothing
|
|
287
|
+
* valid is offered (or the model does not state reasoning).
|
|
288
|
+
*/
|
|
289
|
+
function identityReasoningEfforts(entry) {
|
|
290
|
+
if (entry.reasoning === false) return void 0;
|
|
291
|
+
if (entry.effortValues === void 0 || entry.effortValues.length === 0) return void 0;
|
|
292
|
+
const efforts = {};
|
|
293
|
+
for (const value of entry.effortValues) if (REASONING_LEVELS.includes(value)) efforts[value] = value;
|
|
294
|
+
return Object.keys(efforts).length > 0 ? efforts : void 0;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* The dsh-writable input modality list for one entry: models.dev values
|
|
298
|
+
* intersected with what pi-ai accepts. Returns undefined when nothing is
|
|
299
|
+
* accepted (writing an empty list is semantically "keep defaults", so absent
|
|
300
|
+
* is the honest fill).
|
|
301
|
+
*/
|
|
302
|
+
function acceptedInputModalities(entry) {
|
|
303
|
+
if (entry.input === void 0 || entry.input.length === 0) return void 0;
|
|
304
|
+
const accepted = entry.input.filter((value) => ACCEPTED_INPUT_MODALITIES.includes(value));
|
|
305
|
+
return accepted.length > 0 ? accepted : void 0;
|
|
306
|
+
}
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/server/service.ts
|
|
309
|
+
const TARGET = settingsNamespace(TARGET_NS);
|
|
310
|
+
const OWN = settingsNamespace(PLUGIN_NS);
|
|
311
|
+
var ModelParametersService = class {
|
|
312
|
+
ctx;
|
|
313
|
+
/**
|
|
314
|
+
* The settings service value captured from the inject callback. The plugin
|
|
315
|
+
* itself declares `inject: []` (settings is optional — headless profiles
|
|
316
|
+
* have no settings service), so `this.ctx.settings` is never resolvable:
|
|
317
|
+
* the context proxy only walks ancestor fibers, and the provider lives on
|
|
318
|
+
* a sibling fiber. The injected child ctx itself is only valid while its
|
|
319
|
+
* fiber is active — the provider can be reloaded (and every dependent
|
|
320
|
+
* fiber torn down) while an async reconcile is in flight — so all reads
|
|
321
|
+
* and writes go through the captured service value, which is a plain
|
|
322
|
+
* object and never walks the fiber chain.
|
|
323
|
+
*/
|
|
324
|
+
settings;
|
|
325
|
+
scope;
|
|
326
|
+
lastReport = null;
|
|
327
|
+
catalog;
|
|
328
|
+
reconciling = null;
|
|
329
|
+
constructor(ctx) {
|
|
330
|
+
this.ctx = ctx;
|
|
331
|
+
this.catalog = new CatalogManager(ctx.logger);
|
|
332
|
+
}
|
|
333
|
+
start() {
|
|
334
|
+
this.ctx.inject(["settings"], (sctx) => {
|
|
335
|
+
this.settings = sctx.settings;
|
|
336
|
+
this.scope = this.settings.register(OWN, Config);
|
|
337
|
+
this.reconcile();
|
|
338
|
+
});
|
|
339
|
+
this.ctx.on("settings/updated", (ns) => {
|
|
340
|
+
if (ns !== TARGET) return;
|
|
341
|
+
this.reconcile();
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
stop() {
|
|
345
|
+
this.scope = void 0;
|
|
346
|
+
this.settings = void 0;
|
|
347
|
+
}
|
|
348
|
+
config() {
|
|
349
|
+
if (this.scope !== void 0) try {
|
|
350
|
+
return this.scope.get();
|
|
351
|
+
} catch {}
|
|
352
|
+
return {
|
|
353
|
+
...DEFAULT_CONFIG,
|
|
354
|
+
providerMap: {},
|
|
355
|
+
officialProviders: [...DEFAULT_CONFIG.officialProviders]
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
catalogSnapshot() {
|
|
359
|
+
return this.catalog.snapshotOf();
|
|
360
|
+
}
|
|
361
|
+
report() {
|
|
362
|
+
return this.lastReport;
|
|
363
|
+
}
|
|
364
|
+
/** Force a catalog refresh, then reconcile and report. */
|
|
365
|
+
async refreshAndReconcile() {
|
|
366
|
+
await this.catalog.refresh(this.config().ttlDays);
|
|
367
|
+
await this.reconcile();
|
|
368
|
+
}
|
|
369
|
+
/** Update plugin config toggles from the settings page. */
|
|
370
|
+
async updateConfig(patch) {
|
|
371
|
+
if (this.scope === void 0) throw new Error("model-parameters: settings service is not available in this profile");
|
|
372
|
+
const current = this.config();
|
|
373
|
+
await this.scope.update({
|
|
374
|
+
...current,
|
|
375
|
+
...patch
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Fill missing fields on every provider model from the catalog. Serialized:
|
|
380
|
+
* concurrent triggers (event + backfill) coalesce into one run. Fill-only
|
|
381
|
+
* guarantees termination: after a write the fields are present, so the next
|
|
382
|
+
* event produces no ops.
|
|
383
|
+
*/
|
|
384
|
+
async reconcile() {
|
|
385
|
+
if (this.reconciling !== null) return this.reconciling;
|
|
386
|
+
this.reconciling = this.runReconcile().finally(() => {
|
|
387
|
+
this.reconciling = null;
|
|
388
|
+
});
|
|
389
|
+
return this.reconciling;
|
|
390
|
+
}
|
|
391
|
+
async runReconcile() {
|
|
392
|
+
const config = this.config();
|
|
393
|
+
if (!config.enabled) return;
|
|
394
|
+
const settings = this.settings;
|
|
395
|
+
if (settings === void 0) return;
|
|
396
|
+
try {
|
|
397
|
+
await this.catalog.ensureFresh(config);
|
|
398
|
+
} catch (error) {
|
|
399
|
+
this.ctx.logger.warn(`model-parameters: reconcile skipped, catalog unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const section = settings.describe({ redactSecrets: false }).find((candidate) => candidate.ns === TARGET)?.user;
|
|
403
|
+
if (section === void 0 || typeof section !== "object" || section.providers === void 0) return;
|
|
404
|
+
const ops = [];
|
|
405
|
+
const report = {
|
|
406
|
+
at: Date.now(),
|
|
407
|
+
filled: 0,
|
|
408
|
+
touched: 0,
|
|
409
|
+
unmatched: []
|
|
410
|
+
};
|
|
411
|
+
for (const [providerId, profile] of Object.entries(section.providers)) {
|
|
412
|
+
if (typeof profile !== "object" || profile === null) continue;
|
|
413
|
+
const models = profile.models;
|
|
414
|
+
if (!Array.isArray(models) || models.length === 0) continue;
|
|
415
|
+
const nextModels = [];
|
|
416
|
+
let changed = false;
|
|
417
|
+
for (const model of models) {
|
|
418
|
+
const next = this.fillModel(model, providerId, config, report);
|
|
419
|
+
if (next !== model) changed = true;
|
|
420
|
+
nextModels.push(next);
|
|
421
|
+
}
|
|
422
|
+
if (changed) {
|
|
423
|
+
ops.push({
|
|
424
|
+
op: "set",
|
|
425
|
+
path: [
|
|
426
|
+
"providers",
|
|
427
|
+
providerId,
|
|
428
|
+
"models"
|
|
429
|
+
],
|
|
430
|
+
value: nextModels
|
|
431
|
+
});
|
|
432
|
+
report.touched++;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (ops.length > 0) try {
|
|
436
|
+
await settings.mutate(TARGET, ops);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
this.ctx.logger.warn(`model-parameters: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
439
|
+
}
|
|
440
|
+
this.lastReport = report;
|
|
441
|
+
if (report.filled > 0 || report.unmatched.length > 0) this.ctx.logger.info(`model-parameters: reconcile filled ${report.filled} fields across ${report.touched} providers; ${report.unmatched.length} unmatched (${report.unmatched.slice(0, 5).join(", ")}${report.unmatched.length > 5 ? ", …" : ""})`);
|
|
442
|
+
}
|
|
443
|
+
/** Fill one model entry (fill-only), returning the same object when untouched. */
|
|
444
|
+
fillModel(model, providerId, config, report) {
|
|
445
|
+
const entry = this.catalog.resolve(model.id, providerId, config);
|
|
446
|
+
if (entry === void 0) {
|
|
447
|
+
report.unmatched.push(`${providerId}/${model.id}`);
|
|
448
|
+
return model;
|
|
449
|
+
}
|
|
450
|
+
const next = { ...model };
|
|
451
|
+
let filledFields = 0;
|
|
452
|
+
if (config.fillName && next.name === void 0) {
|
|
453
|
+
const name = catalogName(entry);
|
|
454
|
+
if (name !== void 0) {
|
|
455
|
+
next.name = name;
|
|
456
|
+
filledFields++;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (config.fillContext && next.contextWindow === void 0 && entry.context !== void 0 && entry.context > 0) {
|
|
460
|
+
next.contextWindow = entry.context;
|
|
461
|
+
filledFields++;
|
|
462
|
+
}
|
|
463
|
+
if (config.fillMaxTokens && next.maxTokens === void 0 && entry.output !== void 0 && entry.output > 0) {
|
|
464
|
+
next.maxTokens = entry.output;
|
|
465
|
+
filledFields++;
|
|
466
|
+
}
|
|
467
|
+
if (config.fillReasoning && next.reasoningEfforts === void 0) {
|
|
468
|
+
const efforts = identityReasoningEfforts(entry);
|
|
469
|
+
if (efforts !== void 0) {
|
|
470
|
+
next.reasoningEfforts = efforts;
|
|
471
|
+
filledFields++;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (config.fillInput && next.input === void 0) {
|
|
475
|
+
const input = acceptedInputModalities(entry);
|
|
476
|
+
if (input !== void 0) {
|
|
477
|
+
next.input = input;
|
|
478
|
+
filledFields++;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (filledFields === 0) return model;
|
|
482
|
+
report.filled += filledFields;
|
|
483
|
+
return next;
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
function catalogName(entry) {
|
|
487
|
+
return entry.name !== void 0 && entry.name.length > 0 ? entry.name : void 0;
|
|
488
|
+
}
|
|
489
|
+
//#endregion
|
|
490
|
+
//#region src/server/rpc.ts
|
|
491
|
+
const API_PREFIX = "/plugins/@fonlan/dsh-model-parameters/api";
|
|
492
|
+
function header(headers, name) {
|
|
493
|
+
const value = headers[name];
|
|
494
|
+
return typeof value === "string" ? value : void 0;
|
|
495
|
+
}
|
|
496
|
+
function isLoopbackHostname(hostname) {
|
|
497
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
498
|
+
const parts = hostname.split(".");
|
|
499
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
|
|
500
|
+
}
|
|
501
|
+
function isTrustedApiRequest(req, trustedHosts) {
|
|
502
|
+
const host = header(req.headers, "host");
|
|
503
|
+
if (host === void 0) return false;
|
|
504
|
+
let hostUrl;
|
|
505
|
+
try {
|
|
506
|
+
hostUrl = new URL(`http://${host}`);
|
|
507
|
+
} catch {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !trustedHosts.includes(hostUrl.host) && !trustedHosts.includes(hostUrl.hostname)) return false;
|
|
511
|
+
if (header(req.headers, "sec-fetch-site") === "cross-site") return false;
|
|
512
|
+
const origin = header(req.headers, "origin");
|
|
513
|
+
if (origin === void 0) return true;
|
|
514
|
+
try {
|
|
515
|
+
return new URL(origin).host === hostUrl.host;
|
|
516
|
+
} catch {
|
|
517
|
+
return false;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function trustedHostsOf(ctx) {
|
|
521
|
+
for (const entry of ctx.get("loader")?.entries?.() ?? []) if (entry.options?.name === "connection") return entry.options.config?.trustedHosts ?? [];
|
|
522
|
+
return [];
|
|
523
|
+
}
|
|
524
|
+
function writeJson(res, body) {
|
|
525
|
+
res.writeHead(200, {
|
|
526
|
+
"content-type": "application/json; charset=utf-8",
|
|
527
|
+
"cache-control": "no-store"
|
|
528
|
+
});
|
|
529
|
+
res.end(JSON.stringify(body));
|
|
530
|
+
}
|
|
531
|
+
function writeError(res, code, message, status = 400) {
|
|
532
|
+
res.writeHead(status, {
|
|
533
|
+
"content-type": "application/json; charset=utf-8",
|
|
534
|
+
"cache-control": "no-store"
|
|
535
|
+
});
|
|
536
|
+
res.end(JSON.stringify({
|
|
537
|
+
ok: false,
|
|
538
|
+
error: {
|
|
539
|
+
code,
|
|
540
|
+
message
|
|
541
|
+
}
|
|
542
|
+
}));
|
|
543
|
+
}
|
|
544
|
+
async function readJsonBody(req) {
|
|
545
|
+
const chunks = [];
|
|
546
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
547
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
548
|
+
if (raw.trim() === "") return {};
|
|
549
|
+
try {
|
|
550
|
+
const parsed = JSON.parse(raw);
|
|
551
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
552
|
+
} catch {
|
|
553
|
+
return {};
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
function registerApiRoutes(ctx, service) {
|
|
557
|
+
const fence = (req) => isTrustedApiRequest(req, trustedHostsOf(ctx));
|
|
558
|
+
const disposers = [];
|
|
559
|
+
const route = (name, handler) => {
|
|
560
|
+
disposers.push(ctx.webServer.register({
|
|
561
|
+
kind: "prefix",
|
|
562
|
+
path: `${API_PREFIX}/${name}`,
|
|
563
|
+
handler: async (req, res) => {
|
|
564
|
+
if (!fence(req)) {
|
|
565
|
+
writeError(res, "forbidden", "forbidden", 403);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (req.method !== "POST" && req.method !== "GET") {
|
|
569
|
+
writeError(res, "method", "method not allowed", 405);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const payload = await readJsonBody(req);
|
|
573
|
+
try {
|
|
574
|
+
writeJson(res, {
|
|
575
|
+
ok: true,
|
|
576
|
+
value: await handler(payload)
|
|
577
|
+
});
|
|
578
|
+
} catch (error) {
|
|
579
|
+
writeError(res, "error", error instanceof Error ? error.message : String(error));
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}));
|
|
583
|
+
};
|
|
584
|
+
route("state", async () => stateOf(service));
|
|
585
|
+
route("refresh", async () => {
|
|
586
|
+
await service.refreshAndReconcile();
|
|
587
|
+
return stateOf(service);
|
|
588
|
+
});
|
|
589
|
+
route("reconcile", async () => {
|
|
590
|
+
await service.reconcile();
|
|
591
|
+
return stateOf(service);
|
|
592
|
+
});
|
|
593
|
+
route("set-config", async (payload) => {
|
|
594
|
+
const patch = payload.patch;
|
|
595
|
+
if (typeof patch !== "object" || patch === null) throw new Error("patch must be an object");
|
|
596
|
+
await service.updateConfig(patch);
|
|
597
|
+
return stateOf(service);
|
|
598
|
+
});
|
|
599
|
+
return () => {
|
|
600
|
+
for (const dispose of disposers) dispose();
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* The settings-page view. Deliberately ships only catalog COUNTS — the full
|
|
605
|
+
* entries array (thousands of models) must never cross the wire or render.
|
|
606
|
+
*/
|
|
607
|
+
function stateOf(service) {
|
|
608
|
+
const config = service.config();
|
|
609
|
+
const snapshot = service.catalogSnapshot();
|
|
610
|
+
return {
|
|
611
|
+
config,
|
|
612
|
+
catalog: {
|
|
613
|
+
entries: snapshot.entries.length,
|
|
614
|
+
providers: snapshot.providers,
|
|
615
|
+
fetchedAt: snapshot.fetchedAt,
|
|
616
|
+
fresh: snapshot.fetchedAt > 0 && Date.now() - snapshot.fetchedAt < config.ttlDays * 24 * 60 * 60 * 1e3,
|
|
617
|
+
ttlDays: config.ttlDays
|
|
618
|
+
},
|
|
619
|
+
report: service.report()
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
//#endregion
|
|
623
|
+
//#region src/index.ts
|
|
624
|
+
const name = "@fonlan/dsh-model-parameters";
|
|
625
|
+
const inject = [];
|
|
626
|
+
function apply(ctx) {
|
|
627
|
+
const service = new ModelParametersService(ctx);
|
|
628
|
+
ctx.effect(() => {
|
|
629
|
+
service.start();
|
|
630
|
+
return () => service.stop();
|
|
631
|
+
}, "model-parameters: service");
|
|
632
|
+
ctx.inject(["webServer"], (sctx) => {
|
|
633
|
+
sctx.effect(() => registerApiRoutes(sctx, service), "model-parameters: api routes");
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
//#endregion
|
|
637
|
+
export { CatalogManager, Config, acceptedInputModalities, apply, identityReasoningEfforts, inject, name };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fonlan/dsh-model-parameters host half.
|
|
3
|
+
*
|
|
4
|
+
* Watches the `llm-pi-ai` settings namespace: whenever provider models are
|
|
5
|
+
* written there with missing display name / context window / max output
|
|
6
|
+
* tokens / reasoning efforts / input modalities (the norm for a freshly
|
|
7
|
+
* synced gateway), it fills the missing fields from a cached models.dev
|
|
8
|
+
* catalog (TTL-refreshed), and serves the fenced JSON API the web settings
|
|
9
|
+
* page calls. The plugin's own toggles live in the `model-parameters`
|
|
10
|
+
* settings namespace (settings.yaml).
|
|
11
|
+
*/
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
13
|
+
import { Config } from './shared/config.js';
|
|
14
|
+
import { CatalogManager, acceptedInputModalities, identityReasoningEfforts, type CatalogEntry, type CatalogSnapshot } from './server/catalog.js';
|
|
15
|
+
export declare const name = "@fonlan/dsh-model-parameters";
|
|
16
|
+
export declare const inject: string[];
|
|
17
|
+
export { Config };
|
|
18
|
+
export { CatalogManager, acceptedInputModalities, identityReasoningEfforts };
|
|
19
|
+
export type { CatalogEntry, CatalogSnapshot };
|
|
20
|
+
export declare function apply(ctx: Context): void;
|