@fonlan/dsh-model-router 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.md +65 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +368 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +626 -0
- package/lib/types/index.d.ts +15 -0
- package/lib/types/server/adapter.d.ts +42 -0
- package/lib/types/server/catalog.d.ts +24 -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 +92 -0
- package/package.json +99 -0
- package/src/client/api.ts +56 -0
- package/src/client/index.tsx +35 -0
- package/src/client/locales.ts +39 -0
- package/src/client/settings-section.css +210 -0
- package/src/client/settings-section.tsx +235 -0
- package/src/client/slot-augment.d.ts +14 -0
- package/src/index.ts +37 -0
- package/src/server/adapter.ts +99 -0
- package/src/server/catalog.ts +128 -0
- package/src/server/rpc.ts +147 -0
- package/src/server/service.ts +239 -0
- package/src/shared/config.ts +231 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
4
|
+
import { LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
|
|
5
|
+
//#region src/shared/config.ts
|
|
6
|
+
const ROUTER_PROVIDER_ID = "model-router";
|
|
7
|
+
const ROUTER_SETTINGS_NS = "model-router";
|
|
8
|
+
/** Merge every provider's models into one entry per exact model id. */
|
|
9
|
+
function mergeModels(providers) {
|
|
10
|
+
const byId = /* @__PURE__ */ new Map();
|
|
11
|
+
for (const provider of providers) for (const model of provider.models) {
|
|
12
|
+
let merged = byId.get(model.id);
|
|
13
|
+
if (merged === void 0) {
|
|
14
|
+
merged = {
|
|
15
|
+
id: model.id,
|
|
16
|
+
providers: []
|
|
17
|
+
};
|
|
18
|
+
byId.set(model.id, merged);
|
|
19
|
+
}
|
|
20
|
+
merged.providers.push({
|
|
21
|
+
provider: provider.id,
|
|
22
|
+
id: model.id,
|
|
23
|
+
name: model.name,
|
|
24
|
+
contextWindow: model.contextWindow,
|
|
25
|
+
maxTokens: model.maxTokens
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return [...byId.values()];
|
|
29
|
+
}
|
|
30
|
+
/** The provider view a merged model should display for (follows active). */
|
|
31
|
+
function displayProvider(merged, active) {
|
|
32
|
+
if (merged.providers.length === 0) return null;
|
|
33
|
+
return merged.providers.find((p) => p.provider === active) ?? merged.providers[0];
|
|
34
|
+
}
|
|
35
|
+
/** Initial config for a model the user never configured: first in order wins. */
|
|
36
|
+
function initialConfigFor(providerIds) {
|
|
37
|
+
if (providerIds.length === 0) return null;
|
|
38
|
+
return {
|
|
39
|
+
order: [...providerIds],
|
|
40
|
+
active: providerIds[0]
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Effective active provider for a model id.
|
|
45
|
+
*
|
|
46
|
+
* Rules: a stored active still present in the catalog wins; otherwise the
|
|
47
|
+
* first stored order entry still present wins; otherwise the first provider
|
|
48
|
+
* of the catalog wins; an empty catalog yields null.
|
|
49
|
+
*/
|
|
50
|
+
function resolveActive(config, modelId, catalogProviderIds) {
|
|
51
|
+
if (catalogProviderIds.length === 0) return null;
|
|
52
|
+
const stored = config.models[modelId];
|
|
53
|
+
if (stored !== void 0) {
|
|
54
|
+
if (catalogProviderIds.includes(stored.active)) return stored.active;
|
|
55
|
+
const firstRemaining = stored.order.find((id) => catalogProviderIds.includes(id));
|
|
56
|
+
if (firstRemaining !== void 0) return firstRemaining;
|
|
57
|
+
}
|
|
58
|
+
return catalogProviderIds[0];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Reconcile one model's config against the live catalog: prune vanished
|
|
62
|
+
* providers, append newly discovered ones, and repair a vanished active by
|
|
63
|
+
* falling back to the first remaining provider. Returns the new config, or
|
|
64
|
+
* undefined when the catalog leaves nothing to route to.
|
|
65
|
+
*/
|
|
66
|
+
function syncConfig(config, modelId, catalogProviderIds) {
|
|
67
|
+
const next = { ...config.models };
|
|
68
|
+
let changed = false;
|
|
69
|
+
const existing = next[modelId];
|
|
70
|
+
if (catalogProviderIds.length === 0) {
|
|
71
|
+
if (existing !== void 0) {
|
|
72
|
+
delete next[modelId];
|
|
73
|
+
changed = true;
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
models: next,
|
|
77
|
+
changed
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (existing === void 0) {
|
|
81
|
+
const initial = initialConfigFor(catalogProviderIds);
|
|
82
|
+
if (initial !== null) {
|
|
83
|
+
next[modelId] = initial;
|
|
84
|
+
changed = true;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
models: next,
|
|
88
|
+
changed
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
const catalog = new Set(catalogProviderIds);
|
|
92
|
+
const order = existing.order.filter((id) => catalog.has(id));
|
|
93
|
+
if (order.length !== existing.order.length) changed = true;
|
|
94
|
+
for (const id of catalogProviderIds) if (!order.includes(id)) {
|
|
95
|
+
order.push(id);
|
|
96
|
+
changed = true;
|
|
97
|
+
}
|
|
98
|
+
let active = existing.active;
|
|
99
|
+
if (!catalog.has(active)) {
|
|
100
|
+
active = order[0];
|
|
101
|
+
changed = true;
|
|
102
|
+
}
|
|
103
|
+
if (changed) next[modelId] = {
|
|
104
|
+
order,
|
|
105
|
+
active
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
models: next,
|
|
109
|
+
changed
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/** Switch a model's active provider (no-op when already active). */
|
|
113
|
+
function setActive(config, modelId, providerId, catalogProviderIds) {
|
|
114
|
+
if (!catalogProviderIds.includes(providerId)) throw new Error(`provider ${providerId} is not in the catalog for model ${modelId}`);
|
|
115
|
+
const synced = syncConfig(config, modelId, catalogProviderIds);
|
|
116
|
+
const next = synced.models;
|
|
117
|
+
const entry = next[modelId];
|
|
118
|
+
if (entry === void 0) return {
|
|
119
|
+
models: next,
|
|
120
|
+
changed: synced.changed
|
|
121
|
+
};
|
|
122
|
+
if (entry.active === providerId) return {
|
|
123
|
+
models: next,
|
|
124
|
+
changed: synced.changed
|
|
125
|
+
};
|
|
126
|
+
next[modelId] = {
|
|
127
|
+
...entry,
|
|
128
|
+
active: providerId
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
models: next,
|
|
132
|
+
changed: true
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** Replace a model's provider preference order (active left untouched). */
|
|
136
|
+
function setOrder(config, modelId, order, catalogProviderIds) {
|
|
137
|
+
const catalog = new Set(catalogProviderIds);
|
|
138
|
+
const clean = order.filter((id) => catalog.has(id));
|
|
139
|
+
for (const id of catalogProviderIds) if (!clean.includes(id)) clean.push(id);
|
|
140
|
+
const synced = syncConfig(config, modelId, catalogProviderIds);
|
|
141
|
+
const next = synced.models;
|
|
142
|
+
const entry = next[modelId];
|
|
143
|
+
if (entry === void 0) return {
|
|
144
|
+
models: next,
|
|
145
|
+
changed: synced.changed
|
|
146
|
+
};
|
|
147
|
+
if (entry.order.length === clean.length && entry.order.every((id, i) => id === clean[i])) return {
|
|
148
|
+
models: next,
|
|
149
|
+
changed: synced.changed
|
|
150
|
+
};
|
|
151
|
+
next[modelId] = {
|
|
152
|
+
...entry,
|
|
153
|
+
order: [...clean]
|
|
154
|
+
};
|
|
155
|
+
return {
|
|
156
|
+
models: next,
|
|
157
|
+
changed: true
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** Shape-validate an unknown value as a RouterConfigShape (lenient). */
|
|
161
|
+
function normalizeConfig(value) {
|
|
162
|
+
const models = {};
|
|
163
|
+
if (typeof value !== "object" || value === null) return { models };
|
|
164
|
+
const raw = value.models;
|
|
165
|
+
if (typeof raw !== "object" || raw === null) return { models };
|
|
166
|
+
for (const [id, entry] of Object.entries(raw)) {
|
|
167
|
+
if (typeof entry !== "object" || entry === null) continue;
|
|
168
|
+
const e = entry;
|
|
169
|
+
const order = Array.isArray(e.order) ? e.order.filter((x) => typeof x === "string") : [];
|
|
170
|
+
const active = typeof e.active === "string" ? e.active : order[0] ?? "";
|
|
171
|
+
if (active === "") continue;
|
|
172
|
+
models[id] = {
|
|
173
|
+
order,
|
|
174
|
+
active
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return { models };
|
|
178
|
+
}
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region src/server/catalog.ts
|
|
181
|
+
/** Read the resolved value of another plugin's settings section, if registered. */
|
|
182
|
+
function settingsSection(ctx, ns) {
|
|
183
|
+
try {
|
|
184
|
+
return ctx.settings.get(ns);
|
|
185
|
+
} catch {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Determine whether one provider has usable credentials. Generic: the
|
|
191
|
+
* configurable-provider directory names the settings section + path where the
|
|
192
|
+
* provider's profile (and its optional apiKeyEnv) lives; the credentials seam
|
|
193
|
+
* answers whether that reference currently resolves. Providers without a
|
|
194
|
+
* directory entry or without an apiKeyEnv answer "configured" (unknown —
|
|
195
|
+
* never gray out what we cannot verify).
|
|
196
|
+
*/
|
|
197
|
+
async function detectCredentialConfigured(ctx, entry) {
|
|
198
|
+
if (entry === void 0) return true;
|
|
199
|
+
let profile = settingsSection(ctx, entry.settingsNs);
|
|
200
|
+
for (const key of entry.settingsPath) {
|
|
201
|
+
if (typeof profile !== "object" || profile === null) {
|
|
202
|
+
profile = void 0;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
profile = profile[key];
|
|
206
|
+
}
|
|
207
|
+
const apiKeyEnv = typeof profile === "object" && profile !== null ? profile.apiKeyEnv : void 0;
|
|
208
|
+
if (typeof apiKeyEnv !== "string" || apiKeyEnv.trim() === "") return true;
|
|
209
|
+
let credentials;
|
|
210
|
+
try {
|
|
211
|
+
credentials = ctx.get("credentials");
|
|
212
|
+
} catch {
|
|
213
|
+
credentials = void 0;
|
|
214
|
+
}
|
|
215
|
+
if (credentials === void 0) return true;
|
|
216
|
+
try {
|
|
217
|
+
return (await credentials.describe(credentialRef(apiKeyEnv.trim()))).configured;
|
|
218
|
+
} catch {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Rebuild the live router catalog from the LLM registry. */
|
|
223
|
+
async function buildCatalog(ctx) {
|
|
224
|
+
const routes = ctx.llm.listProviders().filter((provider) => provider.id !== ROUTER_PROVIDER_ID);
|
|
225
|
+
const directory = new Map(ctx.llm.listConfigurableProviders().map((entry) => [entry.provider, {
|
|
226
|
+
provider: entry.provider,
|
|
227
|
+
displayName: entry.displayName,
|
|
228
|
+
settingsNs: entry.settingsNs,
|
|
229
|
+
settingsPath: entry.settingsPath
|
|
230
|
+
}]));
|
|
231
|
+
const providers = [];
|
|
232
|
+
const providerNames = /* @__PURE__ */ new Map();
|
|
233
|
+
const credentialConfigured = /* @__PURE__ */ new Map();
|
|
234
|
+
for (const route of routes) {
|
|
235
|
+
const entry = directory.get(route.id);
|
|
236
|
+
providerNames.set(route.id, entry?.displayName || route.name || route.id);
|
|
237
|
+
const seen = /* @__PURE__ */ new Set();
|
|
238
|
+
const models = [];
|
|
239
|
+
try {
|
|
240
|
+
for (const model of await ctx.llm.listModels(route.id)) {
|
|
241
|
+
if (seen.has(model.id)) continue;
|
|
242
|
+
seen.add(model.id);
|
|
243
|
+
models.push({
|
|
244
|
+
id: model.id,
|
|
245
|
+
name: model.name
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
} catch {}
|
|
249
|
+
providers.push({
|
|
250
|
+
id: route.id,
|
|
251
|
+
name: route.name,
|
|
252
|
+
models
|
|
253
|
+
});
|
|
254
|
+
credentialConfigured.set(route.id, await detectCredentialConfigured(ctx, entry));
|
|
255
|
+
}
|
|
256
|
+
const models = mergeModels(providers);
|
|
257
|
+
return {
|
|
258
|
+
providers,
|
|
259
|
+
providerNames,
|
|
260
|
+
models,
|
|
261
|
+
byId: new Map(models.map((model) => [model.id, model])),
|
|
262
|
+
credentialConfigured
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/server/adapter.ts
|
|
267
|
+
/**
|
|
268
|
+
* The virtual `model-router` LLM adapter. Registered for the single provider
|
|
269
|
+
* route `model-router`, it advertises the merged catalog and, at request time,
|
|
270
|
+
* rewrites the provider field to the model's currently active provider and
|
|
271
|
+
* delegates in-process to `ctx.llm.stream` — credentials, retry, streaming
|
|
272
|
+
* and metering all stay inside the real adapter's pipeline.
|
|
273
|
+
*/
|
|
274
|
+
/**
|
|
275
|
+
* Resolve the effective route for one model id: the stored active provider
|
|
276
|
+
* when still present, otherwise the first remaining order entry, otherwise
|
|
277
|
+
* the catalog head. Unadvertised model ids do not route.
|
|
278
|
+
*/
|
|
279
|
+
function routeFor(facts, modelId) {
|
|
280
|
+
const merged = facts.catalog().byId.get(modelId);
|
|
281
|
+
if (merged === void 0) return null;
|
|
282
|
+
const ids = merged.providers.map((provider) => provider.provider);
|
|
283
|
+
const active = resolveActive(facts.config(), modelId, ids);
|
|
284
|
+
return active === null ? null : { provider: active };
|
|
285
|
+
}
|
|
286
|
+
var ModelRouterAdapter = class extends LlmAdapter {
|
|
287
|
+
facts;
|
|
288
|
+
constructor(facts) {
|
|
289
|
+
super();
|
|
290
|
+
this.facts = facts;
|
|
291
|
+
}
|
|
292
|
+
providerInfo(provider) {
|
|
293
|
+
return {
|
|
294
|
+
id: provider,
|
|
295
|
+
name: "Model Router"
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
async listModels(_provider) {
|
|
299
|
+
const { models } = this.facts.catalog();
|
|
300
|
+
const names = this.facts.providerNames();
|
|
301
|
+
const out = [];
|
|
302
|
+
for (const merged of models) {
|
|
303
|
+
const display = displayProvider(merged, routeFor(this.facts, merged.id)?.provider);
|
|
304
|
+
if (display === null) continue;
|
|
305
|
+
out.push({
|
|
306
|
+
provider: ROUTER_PROVIDER_ID,
|
|
307
|
+
id: merged.id,
|
|
308
|
+
name: display.name,
|
|
309
|
+
description: `via ${names.get(display.provider) ?? display.provider}`
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
async resolveModel(_provider, model, signal) {
|
|
315
|
+
const route = routeFor(this.facts, model);
|
|
316
|
+
if (route === null) throw new LlmError(`model-router: no provider serves model "${model}"`, "NO_ADAPTER");
|
|
317
|
+
return {
|
|
318
|
+
...await this.facts.llm().resolveModelInfo(route.provider, model, signal),
|
|
319
|
+
provider: ROUTER_PROVIDER_ID
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
async *stream(options) {
|
|
323
|
+
const route = routeFor(this.facts, options.model);
|
|
324
|
+
if (route === null) throw new LlmError(`model-router: no provider serves model "${options.model}"`, "NO_ADAPTER");
|
|
325
|
+
yield* this.facts.llm().stream({
|
|
326
|
+
...options,
|
|
327
|
+
provider: route.provider
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region src/server/service.ts
|
|
333
|
+
const NS = settingsNamespace(ROUTER_SETTINGS_NS);
|
|
334
|
+
/** Settings document schema: one entry per model id. */
|
|
335
|
+
const RouterConfigSchema = z.object({ models: z.dict(z.object({
|
|
336
|
+
order: z.array(z.string()),
|
|
337
|
+
active: z.string()
|
|
338
|
+
})) });
|
|
339
|
+
const EMPTY_CONFIG = { models: {} };
|
|
340
|
+
var ModelRouterService = class {
|
|
341
|
+
ctx;
|
|
342
|
+
cached = {
|
|
343
|
+
providers: [],
|
|
344
|
+
providerNames: /* @__PURE__ */ new Map(),
|
|
345
|
+
models: [],
|
|
346
|
+
byId: /* @__PURE__ */ new Map(),
|
|
347
|
+
credentialConfigured: /* @__PURE__ */ new Map()
|
|
348
|
+
};
|
|
349
|
+
inflight = null;
|
|
350
|
+
scope;
|
|
351
|
+
registration;
|
|
352
|
+
constructor(ctx) {
|
|
353
|
+
this.ctx = ctx;
|
|
354
|
+
}
|
|
355
|
+
/** Register settings + adapter and wire topology listeners. */
|
|
356
|
+
start() {
|
|
357
|
+
this.ctx.inject(["settings"], (sctx) => {
|
|
358
|
+
this.scope = sctx.settings.register(NS, RouterConfigSchema);
|
|
359
|
+
});
|
|
360
|
+
const adapter = new ModelRouterAdapter(this);
|
|
361
|
+
this.registration = this.ctx.llm.registerAdapter([ROUTER_PROVIDER_ID], adapter);
|
|
362
|
+
this.ctx.on("llm/adapters-updated", () => {
|
|
363
|
+
this.refreshAndReconcile();
|
|
364
|
+
});
|
|
365
|
+
this.refreshAndReconcile();
|
|
366
|
+
}
|
|
367
|
+
stop() {
|
|
368
|
+
this.registration?.();
|
|
369
|
+
this.registration = void 0;
|
|
370
|
+
}
|
|
371
|
+
catalog() {
|
|
372
|
+
return {
|
|
373
|
+
models: this.cached.models,
|
|
374
|
+
byId: this.cached.byId
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
config() {
|
|
378
|
+
if (this.scope === void 0) return EMPTY_CONFIG;
|
|
379
|
+
try {
|
|
380
|
+
return normalizeConfig(this.scope.get());
|
|
381
|
+
} catch {
|
|
382
|
+
return EMPTY_CONFIG;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
providerNames() {
|
|
386
|
+
return this.cached.providerNames;
|
|
387
|
+
}
|
|
388
|
+
llm() {
|
|
389
|
+
return this.ctx.llm;
|
|
390
|
+
}
|
|
391
|
+
/** Rebuild the catalog (in-flight dedupe; failures keep the last good one). */
|
|
392
|
+
refreshCatalog() {
|
|
393
|
+
if (this.inflight !== null) return this.inflight;
|
|
394
|
+
this.inflight = buildCatalog(this.ctx).then((catalog) => {
|
|
395
|
+
this.cached = catalog;
|
|
396
|
+
return catalog;
|
|
397
|
+
}).catch((error) => {
|
|
398
|
+
this.ctx.logger.warn(`model-router: catalog refresh failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
399
|
+
return this.cached;
|
|
400
|
+
}).finally(() => {
|
|
401
|
+
this.inflight = null;
|
|
402
|
+
});
|
|
403
|
+
return this.inflight;
|
|
404
|
+
}
|
|
405
|
+
/** Refresh, then persist any config drift (new providers/models, vanish). */
|
|
406
|
+
async refreshAndReconcile() {
|
|
407
|
+
const catalog = await this.refreshCatalog();
|
|
408
|
+
if (this.scope === void 0) return;
|
|
409
|
+
const current = this.config();
|
|
410
|
+
const next = { ...current.models };
|
|
411
|
+
let changed = false;
|
|
412
|
+
for (const model of catalog.models) {
|
|
413
|
+
const ids = model.providers.map((provider) => provider.provider);
|
|
414
|
+
const result = syncConfig(current, model.id, ids);
|
|
415
|
+
if (result.changed) changed = true;
|
|
416
|
+
for (const [id, entry] of Object.entries(result.models)) if (entry === void 0) delete next[id];
|
|
417
|
+
else next[id] = entry;
|
|
418
|
+
}
|
|
419
|
+
for (const id of Object.keys(next)) if (!catalog.byId.has(id)) {
|
|
420
|
+
delete next[id];
|
|
421
|
+
changed = true;
|
|
422
|
+
}
|
|
423
|
+
if (!changed) return;
|
|
424
|
+
try {
|
|
425
|
+
await this.scope.replace({ models: next });
|
|
426
|
+
} catch (error) {
|
|
427
|
+
this.ctx.logger.warn(`model-router: reconcile persist failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
/** Effective active provider for one model id (null when unroutable). */
|
|
431
|
+
activeFor(modelId) {
|
|
432
|
+
const ids = this.cached.byId.get(modelId)?.providers.map((provider) => provider.provider) ?? [];
|
|
433
|
+
return resolveActive(this.config(), modelId, ids);
|
|
434
|
+
}
|
|
435
|
+
/** The complete state view for the settings page. */
|
|
436
|
+
async state() {
|
|
437
|
+
await this.refreshCatalog();
|
|
438
|
+
const catalog = this.cached;
|
|
439
|
+
const config = this.config();
|
|
440
|
+
const names = catalog.providerNames;
|
|
441
|
+
const creds = catalog.credentialConfigured;
|
|
442
|
+
return {
|
|
443
|
+
providers: catalog.providers.map((provider) => ({
|
|
444
|
+
id: provider.id,
|
|
445
|
+
name: names.get(provider.id) ?? provider.id,
|
|
446
|
+
credentialConfigured: creds.get(provider.id) ?? true
|
|
447
|
+
})),
|
|
448
|
+
models: catalog.models.map((model) => {
|
|
449
|
+
const ids = model.providers.map((provider) => provider.provider);
|
|
450
|
+
const stored = config.models[model.id] ?? initialConfigFor(ids);
|
|
451
|
+
const active = resolveActive(config, model.id, ids);
|
|
452
|
+
const activeEntry = model.providers.find((provider) => provider.provider === active) ?? model.providers[0];
|
|
453
|
+
return {
|
|
454
|
+
id: model.id,
|
|
455
|
+
name: activeEntry?.name ?? model.id,
|
|
456
|
+
active,
|
|
457
|
+
order: stored?.order ?? [...ids],
|
|
458
|
+
providers: model.providers.map((provider) => ({
|
|
459
|
+
id: provider.provider,
|
|
460
|
+
name: names.get(provider.provider) ?? provider.provider,
|
|
461
|
+
credentialConfigured: creds.get(provider.provider) ?? true
|
|
462
|
+
}))
|
|
463
|
+
};
|
|
464
|
+
})
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
/** Switch the active provider for one model (persisted, live on next request). */
|
|
468
|
+
async switchActive(modelId, providerId) {
|
|
469
|
+
await this.refreshCatalog();
|
|
470
|
+
const ids = this.cached.byId.get(modelId)?.providers.map((provider) => provider.provider);
|
|
471
|
+
if (ids === void 0 || ids.length === 0) throw new Error(`model-router: model "${modelId}" is not served by any provider`);
|
|
472
|
+
const { models, changed } = setActive(this.config(), modelId, providerId, ids);
|
|
473
|
+
if (!changed) return;
|
|
474
|
+
await this.persist(models);
|
|
475
|
+
}
|
|
476
|
+
/** Replace the preference order for one model (active left untouched). */
|
|
477
|
+
async reorder(modelId, order) {
|
|
478
|
+
await this.refreshCatalog();
|
|
479
|
+
const ids = this.cached.byId.get(modelId)?.providers.map((provider) => provider.provider);
|
|
480
|
+
if (ids === void 0 || ids.length === 0) throw new Error(`model-router: model "${modelId}" is not served by any provider`);
|
|
481
|
+
const { models, changed } = setOrder(this.config(), modelId, order, ids);
|
|
482
|
+
if (!changed) return;
|
|
483
|
+
await this.persist(models);
|
|
484
|
+
}
|
|
485
|
+
async persist(models) {
|
|
486
|
+
if (this.scope === void 0) throw new Error("model-router: settings service is not available in this profile");
|
|
487
|
+
await this.scope.replace({ models });
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
//#endregion
|
|
491
|
+
//#region src/server/rpc.ts
|
|
492
|
+
const API_PREFIX = "/plugins/@fonlan/dsh-model-router/api";
|
|
493
|
+
function header(headers, name) {
|
|
494
|
+
const value = headers[name];
|
|
495
|
+
return typeof value === "string" ? value : void 0;
|
|
496
|
+
}
|
|
497
|
+
function isLoopbackHostname(hostname) {
|
|
498
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
499
|
+
const parts = hostname.split(".");
|
|
500
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((p) => /^\d{1,3}$/.test(p) && Number(p) <= 255);
|
|
501
|
+
}
|
|
502
|
+
function isTrustedApiRequest(req, trustedHosts) {
|
|
503
|
+
const host = header(req.headers, "host");
|
|
504
|
+
if (host === void 0) return false;
|
|
505
|
+
let hostUrl;
|
|
506
|
+
try {
|
|
507
|
+
hostUrl = new URL(`http://${host}`);
|
|
508
|
+
} catch {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !trustedHosts.includes(hostUrl.host) && !trustedHosts.includes(hostUrl.hostname)) return false;
|
|
512
|
+
if (header(req.headers, "sec-fetch-site") === "cross-site") return false;
|
|
513
|
+
const origin = header(req.headers, "origin");
|
|
514
|
+
if (origin === void 0) return true;
|
|
515
|
+
try {
|
|
516
|
+
return new URL(origin).host === hostUrl.host;
|
|
517
|
+
} catch {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function trustedHostsOf(ctx) {
|
|
522
|
+
for (const entry of ctx.get("loader")?.entries?.() ?? []) if (entry.options?.name === "connection") return entry.options.config?.trustedHosts ?? [];
|
|
523
|
+
return [];
|
|
524
|
+
}
|
|
525
|
+
function writeJson(res, body) {
|
|
526
|
+
res.writeHead(200, {
|
|
527
|
+
"content-type": "application/json; charset=utf-8",
|
|
528
|
+
"cache-control": "no-store"
|
|
529
|
+
});
|
|
530
|
+
res.end(JSON.stringify(body));
|
|
531
|
+
}
|
|
532
|
+
function writeError(res, code, message, status = 400) {
|
|
533
|
+
res.writeHead(status, {
|
|
534
|
+
"content-type": "application/json; charset=utf-8",
|
|
535
|
+
"cache-control": "no-store"
|
|
536
|
+
});
|
|
537
|
+
res.end(JSON.stringify({
|
|
538
|
+
ok: false,
|
|
539
|
+
error: {
|
|
540
|
+
code,
|
|
541
|
+
message
|
|
542
|
+
}
|
|
543
|
+
}));
|
|
544
|
+
}
|
|
545
|
+
async function readJsonBody(req) {
|
|
546
|
+
const chunks = [];
|
|
547
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
548
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
549
|
+
if (raw.trim() === "") return {};
|
|
550
|
+
try {
|
|
551
|
+
const parsed = JSON.parse(raw);
|
|
552
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
553
|
+
} catch {
|
|
554
|
+
return {};
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
function registerApiRoutes(ctx, service) {
|
|
558
|
+
const fence = (req) => isTrustedApiRequest(req, trustedHostsOf(ctx));
|
|
559
|
+
const disposers = [];
|
|
560
|
+
const route = (name, handler) => {
|
|
561
|
+
disposers.push(ctx.webServer.register({
|
|
562
|
+
kind: "prefix",
|
|
563
|
+
path: `${API_PREFIX}/${name}`,
|
|
564
|
+
handler: async (req, res) => {
|
|
565
|
+
if (!fence(req)) {
|
|
566
|
+
writeError(res, "forbidden", "forbidden", 403);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (req.method !== "POST" && req.method !== "GET") {
|
|
570
|
+
writeError(res, "method", "method not allowed", 405);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const payload = await readJsonBody(req);
|
|
574
|
+
try {
|
|
575
|
+
writeJson(res, {
|
|
576
|
+
ok: true,
|
|
577
|
+
value: await handler(payload)
|
|
578
|
+
});
|
|
579
|
+
} catch (error) {
|
|
580
|
+
writeError(res, "error", error instanceof Error ? error.message : String(error));
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}));
|
|
584
|
+
};
|
|
585
|
+
route("state", async () => service.state());
|
|
586
|
+
route("switch-active", async (p) => {
|
|
587
|
+
const modelId = requireString(p.modelId, "modelId");
|
|
588
|
+
const providerId = requireString(p.providerId, "providerId");
|
|
589
|
+
await service.switchActive(modelId, providerId);
|
|
590
|
+
return service.state();
|
|
591
|
+
});
|
|
592
|
+
route("reorder", async (p) => {
|
|
593
|
+
const modelId = requireString(p.modelId, "modelId");
|
|
594
|
+
if (!Array.isArray(p.order) || !p.order.every((id) => typeof id === "string")) throw new Error("order 必须是提供商 id 数组");
|
|
595
|
+
await service.reorder(modelId, p.order);
|
|
596
|
+
return service.state();
|
|
597
|
+
});
|
|
598
|
+
route("refresh", async () => {
|
|
599
|
+
await service.refreshAndReconcile();
|
|
600
|
+
return service.state();
|
|
601
|
+
});
|
|
602
|
+
return () => {
|
|
603
|
+
for (const dispose of disposers) dispose();
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
function requireString(value, name) {
|
|
607
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${name} 不能为空`);
|
|
608
|
+
return value;
|
|
609
|
+
}
|
|
610
|
+
//#endregion
|
|
611
|
+
//#region src/index.ts
|
|
612
|
+
const name = "@fonlan/dsh-model-router";
|
|
613
|
+
const inject = ["llm"];
|
|
614
|
+
const Config = z.object({});
|
|
615
|
+
function apply(ctx) {
|
|
616
|
+
const service = new ModelRouterService(ctx);
|
|
617
|
+
ctx.effect(() => {
|
|
618
|
+
service.start();
|
|
619
|
+
return () => service.stop();
|
|
620
|
+
}, "model-router: service");
|
|
621
|
+
ctx.inject(["webServer"], (sctx) => {
|
|
622
|
+
sctx.effect(() => registerApiRoutes(sctx, service), "model-router: api routes");
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
//#endregion
|
|
626
|
+
export { Config, apply, inject, name };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fonlan/dsh-model-router host half: the virtual model-router provider.
|
|
3
|
+
*
|
|
4
|
+
* Registers one `model-router` LLM adapter that aggregates every configured
|
|
5
|
+
* provider and model (strict model-id merge), routes each request to the
|
|
6
|
+
* model's currently active provider by in-process delegation, persists the
|
|
7
|
+
* per-model provider order/active config in the `model-router` settings
|
|
8
|
+
* namespace, and serves the fenced JSON API the web settings page calls.
|
|
9
|
+
*/
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
11
|
+
import z from '@deepseek-ai/schemastery';
|
|
12
|
+
export declare const name = "@fonlan/dsh-model-router";
|
|
13
|
+
export declare const inject: string[];
|
|
14
|
+
export declare const Config: z<Schemastery.ObjectS<{}>, Schemastery.ObjectT<{}>>;
|
|
15
|
+
export declare function apply(ctx: Context): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The virtual `model-router` LLM adapter. Registered for the single provider
|
|
3
|
+
* route `model-router`, it advertises the merged catalog and, at request time,
|
|
4
|
+
* rewrites the provider field to the model's currently active provider and
|
|
5
|
+
* delegates in-process to `ctx.llm.stream` — credentials, retry, streaming
|
|
6
|
+
* and metering all stay inside the real adapter's pipeline.
|
|
7
|
+
*/
|
|
8
|
+
import { LlmAdapter, type LlmRuntime } from '@deepseek-ai/dsh-llm';
|
|
9
|
+
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
10
|
+
import { type MergedModel, type RouterConfigShape } from '../shared/config.js';
|
|
11
|
+
/** Facts the adapter reads from the owning service (live, per request). */
|
|
12
|
+
export interface RouterFacts {
|
|
13
|
+
/** Current merged catalog (refreshed on topology changes). */
|
|
14
|
+
catalog(): {
|
|
15
|
+
models: readonly MergedModel[];
|
|
16
|
+
byId: ReadonlyMap<string, MergedModel>;
|
|
17
|
+
};
|
|
18
|
+
/** Current stored router config. */
|
|
19
|
+
config(): RouterConfigShape;
|
|
20
|
+
/** Provider display names by id. */
|
|
21
|
+
providerNames(): ReadonlyMap<string, string>;
|
|
22
|
+
/** The LLM runtime to delegate to. */
|
|
23
|
+
llm(): LlmRuntime;
|
|
24
|
+
}
|
|
25
|
+
/** One resolved route for a model id. */
|
|
26
|
+
export interface ResolvedRoute {
|
|
27
|
+
provider: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the effective route for one model id: the stored active provider
|
|
31
|
+
* when still present, otherwise the first remaining order entry, otherwise
|
|
32
|
+
* the catalog head. Unadvertised model ids do not route.
|
|
33
|
+
*/
|
|
34
|
+
export declare function routeFor(facts: RouterFacts, modelId: string): ResolvedRoute | null;
|
|
35
|
+
export declare class ModelRouterAdapter extends LlmAdapter {
|
|
36
|
+
private readonly facts;
|
|
37
|
+
constructor(facts: RouterFacts);
|
|
38
|
+
providerInfo(provider: string): LlmProviderInfo;
|
|
39
|
+
listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
|
|
40
|
+
resolveModel(_provider: string, model: string, signal?: AbortSignal): Promise<LlmResolvedModelInfo>;
|
|
41
|
+
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider discovery for the router: every registered LLM provider route
|
|
3
|
+
* (minus the router itself) becomes a ProviderInfo, models merge by exact id
|
|
4
|
+
* through the shared core, and per-provider credential status is detected
|
|
5
|
+
* generically through the configurable-provider directory + the credentials
|
|
6
|
+
* seam.
|
|
7
|
+
*/
|
|
8
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
9
|
+
import { type MergedModel, type ProviderInfo } from '../shared/config.js';
|
|
10
|
+
/** Live router catalog: everything the router can advertise and route to. */
|
|
11
|
+
export interface RouterCatalog {
|
|
12
|
+
/** All providers (minus the router) in registration order. */
|
|
13
|
+
providers: ProviderInfo[];
|
|
14
|
+
/** Provider id → human display name (configurable-directory name when known). */
|
|
15
|
+
providerNames: Map<string, string>;
|
|
16
|
+
/** Models merged by exact id across providers. */
|
|
17
|
+
models: MergedModel[];
|
|
18
|
+
/** Model id → merged model. */
|
|
19
|
+
byId: Map<string, MergedModel>;
|
|
20
|
+
/** Provider id → whether the provider currently has usable credentials. */
|
|
21
|
+
credentialConfigured: Map<string, boolean>;
|
|
22
|
+
}
|
|
23
|
+
/** Rebuild the live router catalog from the LLM registry. */
|
|
24
|
+
export declare function buildCatalog(ctx: Context): Promise<RouterCatalog>;
|