@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/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;