dsh-openrouter-provider-advisor 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,954 @@
1
+ import { credentialRef } from "@deepseek-ai/dsh-credentials";
2
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
+ import { defineTool } from "@deepseek-ai/dsh-tools";
4
+ import z from "@deepseek-ai/schemastery";
5
+
6
+ //#region src/config.ts
7
+ const STRATEGY_WEIGHTS = {
8
+ price: {
9
+ quantization: .1,
10
+ speed: .1,
11
+ price: .7,
12
+ context: .1
13
+ },
14
+ speed: {
15
+ quantization: .15,
16
+ speed: .65,
17
+ price: .1,
18
+ context: .1
19
+ },
20
+ context: {
21
+ quantization: .15,
22
+ speed: .1,
23
+ price: .1,
24
+ context: .65
25
+ }
26
+ };
27
+ const DEFAULT_CONFIG = Object.freeze({
28
+ minContextTokens: 1e5,
29
+ cacheTtlMs: 3e5,
30
+ recommendedCount: 5,
31
+ weights: Object.freeze({
32
+ quantization: .3,
33
+ speed: .3,
34
+ price: .3,
35
+ context: .1
36
+ }),
37
+ priceBlend: Object.freeze({
38
+ input: .02,
39
+ output: .08,
40
+ cache: .9
41
+ }),
42
+ uptimePenaltyThreshold: 99,
43
+ uptimePenaltyFactor: .85
44
+ });
45
+ function assertUnitSum(label, values) {
46
+ if (values.some((value) => !Number.isFinite(value) || value < 0) || Math.abs(values.reduce((sum, value) => sum + value, 0) - 1) > .001) throw new Error(`${label} must contain non-negative values summing to 1`);
47
+ }
48
+ function validateConfig(config) {
49
+ if (!Number.isInteger(config.recommendedCount) || config.recommendedCount < 1 || config.recommendedCount > 10) throw new Error("recommendedCount must be an integer from 1 to 10");
50
+ if (!Number.isFinite(config.minContextTokens) || config.minContextTokens < 1) throw new Error("minContextTokens must be positive");
51
+ if (!Number.isFinite(config.cacheTtlMs) || config.cacheTtlMs < 0) throw new Error("cacheTtlMs must be non-negative");
52
+ if (config.uptimePenaltyThreshold < 0 || config.uptimePenaltyThreshold > 100) throw new Error("uptimePenaltyThreshold must be from 0 to 100");
53
+ if (config.uptimePenaltyFactor < 0 || config.uptimePenaltyFactor > 1) throw new Error("uptimePenaltyFactor must be from 0 to 1");
54
+ assertUnitSum("weights", Object.values(config.weights));
55
+ assertUnitSum("priceBlend", Object.values(config.priceBlend));
56
+ }
57
+ function configForStrategy(config, strategy) {
58
+ return strategy === "balanced" ? config : {
59
+ ...config,
60
+ weights: STRATEGY_WEIGHTS[strategy]
61
+ };
62
+ }
63
+
64
+ //#endregion
65
+ //#region src/apply-provider.ts
66
+ var ProviderApplyError = class extends Error {
67
+ constructor(message, stage, partial, options) {
68
+ super(message, options);
69
+ this.stage = stage;
70
+ this.partial = partial;
71
+ this.name = "ProviderApplyError";
72
+ }
73
+ };
74
+ function effortFor(models, sourceModel, requested) {
75
+ const declaration = models.find((model) => model.id === sourceModel)?.reasoningEfforts ?? {
76
+ off: null,
77
+ high: "high"
78
+ };
79
+ const offered = declaration === false ? [] : Object.keys(declaration);
80
+ const selected = requested && offered.includes(requested) ? requested : offered[0];
81
+ return selected === void 0 ? { declaration } : {
82
+ declaration,
83
+ selected
84
+ };
85
+ }
86
+ async function applyProvider(deps, input) {
87
+ let slug;
88
+ try {
89
+ slug = (await deps.upsertPreset(input.openrouterModel, input.endpoint.tag)).slug;
90
+ } catch (error) {
91
+ throw new ProviderApplyError("OpenRouter preset 创建失败;DSH 设置未修改", "preset", false, { cause: error });
92
+ }
93
+ const modelId = `@preset/${slug}`;
94
+ let selectedEffort;
95
+ try {
96
+ const models = await deps.readModels(input.route);
97
+ const effort = effortFor(models, input.sourceModel, input.reasoningEffort);
98
+ selectedEffort = effort.selected;
99
+ const entry = {
100
+ id: modelId,
101
+ name: `${input.openrouterModel.split("/").at(-1)} · ${input.endpoint.provider_name}`,
102
+ contextWindow: input.endpoint.context_length,
103
+ compat: { thinkingFormat: "openrouter" },
104
+ reasoningEfforts: effort.declaration,
105
+ openrouterModel: input.openrouterModel
106
+ };
107
+ const index = models.findIndex((model) => model.id === modelId);
108
+ const next = [...models];
109
+ if (index >= 0) next[index] = {
110
+ ...next[index],
111
+ ...entry
112
+ };
113
+ else next.push(entry);
114
+ await deps.writeModels(input.route, next);
115
+ } catch (error) {
116
+ throw new ProviderApplyError("DSH 模型设置写入失败;OpenRouter preset 已保留", "settings", true, { cause: error });
117
+ }
118
+ const selection = {
119
+ provider: input.route,
120
+ model: modelId,
121
+ ...selectedEffort ? { reasoningEffort: selectedEffort } : {}
122
+ };
123
+ try {
124
+ await deps.selectModel({
125
+ sessionId: input.sessionId,
126
+ ...selection
127
+ });
128
+ } catch (error) {
129
+ throw new ProviderApplyError("当前会话切换失败;模型条目已写入,请用 /model 手动选择新 preset", "select-model", true, { cause: error });
130
+ }
131
+ try {
132
+ await deps.saveDefault(selection);
133
+ } catch (error) {
134
+ throw new ProviderApplyError("默认模型保存失败;当前会话已切换", "default", true, { cause: error });
135
+ }
136
+ return {
137
+ ok: true,
138
+ provider: input.route,
139
+ model: modelId,
140
+ tag: input.endpoint.tag,
141
+ openrouterModel: input.openrouterModel
142
+ };
143
+ }
144
+
145
+ //#endregion
146
+ //#region src/resolve-model.ts
147
+ const PROVIDER_SUFFIXES = new Set([
148
+ "novita",
149
+ "novitaai",
150
+ "coreweave",
151
+ "deepinfra",
152
+ "silflow",
153
+ "siliconflow",
154
+ "or",
155
+ "openrouter"
156
+ ]);
157
+ const separators = /[-_.·・/\s]+/u;
158
+ function normalizeModelName(value) {
159
+ const withoutPreset = value.trim().replace(/^@preset\//i, "");
160
+ const tokens = withoutPreset.split(separators).filter(Boolean);
161
+ if (tokens.length > 1 && PROVIDER_SUFFIXES.has(tokens.at(-1).toLowerCase())) tokens.pop();
162
+ const delimiter = /[·・\s]/u.test(withoutPreset) ? " " : "-";
163
+ return tokens.join(delimiter).toLowerCase();
164
+ }
165
+ function compact(value) {
166
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
167
+ }
168
+ function authorHint(input) {
169
+ const haystack = `${input.model} ${input.name ?? ""}`.toLowerCase();
170
+ return [
171
+ "deepseek",
172
+ "qwen",
173
+ "anthropic",
174
+ "openai",
175
+ "google",
176
+ "meta-llama",
177
+ "mistralai"
178
+ ].find((author) => haystack.includes(author.replace("-llama", "")));
179
+ }
180
+ function resolveOpenRouterModel(input, catalog) {
181
+ if (!input.model.startsWith("@preset/") && !input.baseURL?.toLowerCase().includes("openrouter.ai")) return { kind: "not-openrouter" };
182
+ if (input.presetModel) {
183
+ const preset = catalog.find((item) => item.id.toLowerCase() === input.presetModel.toLowerCase());
184
+ if (preset) return {
185
+ kind: "matched",
186
+ model: preset
187
+ };
188
+ }
189
+ const normalized = normalizeModelName(input.model);
190
+ const normalizedCompact = compact(normalized);
191
+ const exact = catalog.filter((item) => {
192
+ const slug = item.id.split("/").at(-1);
193
+ return item.id.toLowerCase() === normalized || slug.toLowerCase() === normalized;
194
+ });
195
+ if (exact.length === 1) return {
196
+ kind: "matched",
197
+ model: exact[0]
198
+ };
199
+ if (exact.length > 1) return {
200
+ kind: "ambiguous",
201
+ candidates: exact
202
+ };
203
+ const hint = authorHint(input);
204
+ let stable = catalog.filter((item) => {
205
+ const slugCompact = compact(item.id.split("/").at(-1));
206
+ return slugCompact.includes(normalizedCompact) || normalizedCompact.includes(slugCompact);
207
+ });
208
+ if (hint) {
209
+ const hinted = stable.filter((item) => item.id.toLowerCase().startsWith(`${hint}/`));
210
+ if (hinted.length > 0) stable = hinted;
211
+ }
212
+ if (stable.length > 1 && input.model.startsWith("@preset/")) {
213
+ const prefixMatches = stable.filter((item) => normalizedCompact.startsWith(compact(item.id.split("/").at(-1))));
214
+ const longest = Math.max(0, ...prefixMatches.map((item) => compact(item.id.split("/").at(-1)).length));
215
+ const longestMatches = prefixMatches.filter((item) => compact(item.id.split("/").at(-1)).length === longest);
216
+ if (longestMatches.length === 1) return {
217
+ kind: "matched",
218
+ model: longestMatches[0]
219
+ };
220
+ if (longestMatches.length > 1) stable = longestMatches;
221
+ }
222
+ const compactExact = stable.filter((item) => compact(item.id.split("/").at(-1)) === normalizedCompact);
223
+ if (compactExact.length === 1) return {
224
+ kind: "matched",
225
+ model: compactExact[0]
226
+ };
227
+ if (compactExact.length > 1) stable = compactExact;
228
+ if (stable.length === 1) return {
229
+ kind: "matched",
230
+ model: stable[0]
231
+ };
232
+ if (stable.length > 1) return {
233
+ kind: "ambiguous",
234
+ candidates: stable
235
+ };
236
+ const display = compact(input.name ?? input.model);
237
+ const fuzzy = catalog.filter((item) => compact(item.name).includes(display) || display.includes(compact(item.name)));
238
+ if (fuzzy.length === 1) return {
239
+ kind: "matched",
240
+ model: fuzzy[0]
241
+ };
242
+ if (fuzzy.length > 1) return {
243
+ kind: "ambiguous",
244
+ candidates: fuzzy
245
+ };
246
+ return {
247
+ kind: "not-found",
248
+ normalized
249
+ };
250
+ }
251
+
252
+ //#endregion
253
+ //#region src/score.ts
254
+ const MAX_CONTEXT = 1048576;
255
+ function finite(value) {
256
+ const number = typeof value === "number" ? value : Number(value ?? 0);
257
+ return Number.isFinite(number) ? number : 0;
258
+ }
259
+ function scale(value, min, max, invert = false) {
260
+ if (max === min) return 100;
261
+ const score = 100 * (value - min) / (max - min);
262
+ return invert ? 100 - score : score;
263
+ }
264
+ function quantizationScore(value) {
265
+ switch (value?.toLowerCase()) {
266
+ case "bf16":
267
+ case "fp16": return 100;
268
+ case "fp8": return 80;
269
+ case "fp4": return 45;
270
+ default: return 40;
271
+ }
272
+ }
273
+ function round(value, digits = 2) {
274
+ const factor = 10 ** digits;
275
+ return Math.round((value + Number.EPSILON) * factor) / factor;
276
+ }
277
+ function uptimeMultiplier(uptime, config) {
278
+ const safety = uptime === null ? .7 : uptime >= 99.5 ? 1 : uptime >= 99 ? .85 : uptime >= 97 ? .6 : uptime >= 90 ? .3 : .05;
279
+ const configured = uptime !== null && uptime < config.uptimePenaltyThreshold ? config.uptimePenaltyFactor : 1;
280
+ return Math.min(safety, configured);
281
+ }
282
+ function rankEndpoints(endpoints, config, currentTag) {
283
+ validateConfig(config);
284
+ const raw = endpoints.filter((endpoint) => endpoint.status === 0 && endpoint.context_length >= config.minContextTokens).map((endpoint) => {
285
+ const input = finite(endpoint.pricing.prompt) * 1e6;
286
+ const output = finite(endpoint.pricing.completion) * 1e6;
287
+ const cache = finite(endpoint.pricing.input_cache_read) * 1e6;
288
+ return {
289
+ endpoint,
290
+ tps: finite(endpoint.throughput_last_30m?.p50),
291
+ price: {
292
+ input,
293
+ output,
294
+ cache
295
+ },
296
+ cost: config.priceBlend.input * input + config.priceBlend.output * output + config.priceBlend.cache * cache
297
+ };
298
+ });
299
+ const speeds = raw.map((item) => item.tps);
300
+ const costs = raw.map((item) => item.cost);
301
+ const minSpeed = Math.min(...speeds);
302
+ const maxSpeed = Math.max(...speeds);
303
+ const minCost = Math.min(...costs);
304
+ const maxCost = Math.max(...costs);
305
+ const ranked = raw.map((item) => {
306
+ const dimensions = {
307
+ quantization: quantizationScore(item.endpoint.quantization),
308
+ speed: scale(item.tps, minSpeed, maxSpeed),
309
+ price: scale(item.cost, minCost, maxCost, true),
310
+ context: 100 * Math.min(1, Math.max(0, (item.endpoint.context_length - config.minContextTokens) / (MAX_CONTEXT - config.minContextTokens)))
311
+ };
312
+ let score = config.weights.quantization * dimensions.quantization + config.weights.speed * dimensions.speed + config.weights.price * dimensions.price + config.weights.context * dimensions.context;
313
+ const uptime = item.endpoint.uptime_last_30m ?? null;
314
+ score *= uptimeMultiplier(uptime, config);
315
+ const quant = item.endpoint.quantization?.toLowerCase() || "unknown";
316
+ return {
317
+ rank: 0,
318
+ score: round(score, 1),
319
+ providerName: item.endpoint.provider_name,
320
+ tag: item.endpoint.tag,
321
+ quantization: quant,
322
+ tps: item.tps,
323
+ latency: item.endpoint.latency_last_30m?.p50 ?? null,
324
+ price: {
325
+ input: round(item.price.input, 6),
326
+ output: round(item.price.output, 6),
327
+ cache: round(item.price.cache, 6)
328
+ },
329
+ contextLength: item.endpoint.context_length,
330
+ uptime,
331
+ current: item.endpoint.tag === currentTag,
332
+ reasons: [
333
+ quant,
334
+ `${round(item.tps, 1)} t/s`,
335
+ `${uptime === null ? "unknown" : round(uptime, 2)}% uptime`
336
+ ],
337
+ dimensions
338
+ };
339
+ }).sort((a, b) => b.score - a.score || b.dimensions.speed - a.dimensions.speed || b.dimensions.price - a.dimensions.price || a.providerName.localeCompare(b.providerName));
340
+ ranked.forEach((item, index) => {
341
+ item.rank = index + 1;
342
+ });
343
+ return {
344
+ recommended: ranked.slice(0, config.recommendedCount),
345
+ rest: ranked.slice(config.recommendedCount)
346
+ };
347
+ }
348
+
349
+ //#endregion
350
+ //#region src/directory.ts
351
+ var ProviderDirectoryError = class extends Error {
352
+ constructor(message, code, details) {
353
+ super(message);
354
+ this.code = code;
355
+ this.details = details;
356
+ this.name = "ProviderDirectoryError";
357
+ }
358
+ };
359
+ var OpenRouterProviderDirectory = class {
360
+ constructor(deps) {
361
+ this.deps = deps;
362
+ }
363
+ async recommend(input, options = {}) {
364
+ const resolved = resolveOpenRouterModel(input, await this.deps.listModels());
365
+ if (resolved.kind === "not-openrouter") throw new ProviderDirectoryError("当前模型不在 OpenRouter,无法推荐供应商", "NOT_OPENROUTER");
366
+ if (resolved.kind === "not-found") throw new ProviderDirectoryError(`无法匹配 OpenRouter 模型:${resolved.normalized}`, "MODEL_NOT_FOUND", { normalized: resolved.normalized });
367
+ if (resolved.kind === "ambiguous") throw new ProviderDirectoryError("无法唯一匹配 OpenRouter 模型", "MODEL_AMBIGUOUS", { candidates: resolved.candidates.map((item) => item.id) });
368
+ const ranked = rankEndpoints(await this.deps.getEndpoints(resolved.model.id, options), this.deps.config(), input.currentTag);
369
+ if (ranked.recommended.length === 0) throw new ProviderDirectoryError("没有满足上下文下限的可用供应商", "NO_ELIGIBLE_ENDPOINTS");
370
+ return {
371
+ ok: true,
372
+ dsh: {
373
+ provider: input.provider,
374
+ model: input.model
375
+ },
376
+ openrouterModel: resolved.model.id,
377
+ ...input.currentTag === void 0 ? {} : { currentTag: input.currentTag },
378
+ ...ranked
379
+ };
380
+ }
381
+ refresh(input) {
382
+ return this.recommend(input, { refresh: true });
383
+ }
384
+ };
385
+
386
+ //#endregion
387
+ //#region src/controller.ts
388
+ var OpenRouterProviderController = class {
389
+ state = /* @__PURE__ */ new Map();
390
+ constructor(deps) {
391
+ this.deps = deps;
392
+ }
393
+ async key(provider) {
394
+ const credential = await this.deps.credential(provider);
395
+ if (!credential.value) throw new Error(`请在 DSH 模型设置中配置 OpenRouter 凭据 ${credential.ref}`);
396
+ return {
397
+ ...credential,
398
+ configured: true,
399
+ value: credential.value
400
+ };
401
+ }
402
+ async connection() {
403
+ const entries = Object.entries(this.deps.providerProfiles()).filter(([, profile]) => profile.baseURL?.toLowerCase().includes("openrouter.ai") === true || profile.models?.some((model) => model.id.startsWith("@preset/")) === true);
404
+ const routes = await Promise.all(entries.map(async ([provider]) => {
405
+ const credential = await this.deps.credential(provider);
406
+ return {
407
+ provider,
408
+ credentialRef: credential.ref,
409
+ configured: credential.configured,
410
+ source: credential.source
411
+ };
412
+ }));
413
+ return {
414
+ ok: true,
415
+ configured: routes.some((route) => route.configured),
416
+ routes
417
+ };
418
+ }
419
+ async models(sessionId) {
420
+ const directory = await this.deps.sessionDirectory(sessionId);
421
+ const current = directory.current;
422
+ const openrouterRoutes = Object.entries(this.deps.providerProfiles()).filter(([, profile]) => profile.baseURL?.toLowerCase().includes("openrouter.ai") === true || profile.models?.some((model) => model.id.startsWith("@preset/")) === true);
423
+ const targetProvider = openrouterRoutes.some(([provider]) => provider === current.provider) ? current.provider : openrouterRoutes[0]?.[0];
424
+ const models = targetProvider === void 0 ? [] : directory.groups.flatMap((group) => group.models.map((model) => ({
425
+ provider: targetProvider,
426
+ model: model.id,
427
+ name: model.name,
428
+ matchName: `${group.id} ${model.name}`,
429
+ current: group.id === current.provider && model.id === current.model
430
+ })));
431
+ if (!models.some((model) => model.current)) {
432
+ if (this.deps.providerProfile(current.provider)?.baseURL?.toLowerCase().includes("openrouter.ai") || current.model.startsWith("@preset/")) models.unshift({
433
+ provider: current.provider,
434
+ model: current.model,
435
+ name: current.model,
436
+ matchName: `${current.provider} ${current.model}`,
437
+ current: true
438
+ });
439
+ }
440
+ const credential = await this.deps.credential(targetProvider ?? current.provider);
441
+ return {
442
+ ok: true,
443
+ credential: {
444
+ configured: credential.configured,
445
+ ref: credential.ref,
446
+ source: credential.source
447
+ },
448
+ current: {
449
+ provider: current.provider,
450
+ model: current.model
451
+ },
452
+ models
453
+ };
454
+ }
455
+ async recommend(sessionId, refresh = false, override = {}) {
456
+ const selection = {
457
+ ...await this.deps.sessionSelection(sessionId),
458
+ ...override
459
+ };
460
+ const credential = await this.key(selection.provider);
461
+ const profile = this.deps.providerProfile(selection.provider);
462
+ const presetModel = profile?.models?.find((model) => model.id === selection.model)?.openrouterModel;
463
+ const input = {
464
+ ...selection,
465
+ ...profile?.baseURL === void 0 ? {} : { baseURL: profile.baseURL },
466
+ ...typeof presetModel === "string" ? { presetModel } : {}
467
+ };
468
+ let fetchedEndpoints = [];
469
+ const response = await new OpenRouterProviderDirectory({
470
+ config: () => configForStrategy(this.deps.config(), override.strategy ?? "balanced"),
471
+ listModels: () => this.deps.listModels(credential),
472
+ getEndpoints: async (modelId) => {
473
+ fetchedEndpoints = await this.deps.getEndpoints(credential, modelId, refresh);
474
+ return fetchedEndpoints;
475
+ }
476
+ }).recommend(input, { refresh });
477
+ const suffix = input.model.replace(/^@preset\//i, "").split(/[-_.·・/\s]+/u).at(-1)?.toLowerCase();
478
+ const inferred = suffix === void 0 ? [] : fetchedEndpoints.filter((endpoint) => endpoint.tag.split("/")[0]?.toLowerCase() === suffix);
479
+ if (inferred.length === 1) {
480
+ response.currentTag = inferred[0].tag;
481
+ for (const row of [...response.recommended, ...response.rest]) row.current = row.tag === response.currentTag;
482
+ }
483
+ this.state.set(sessionId, {
484
+ input,
485
+ response,
486
+ endpoints: fetchedEndpoints
487
+ });
488
+ return response;
489
+ }
490
+ async apply(sessionId, tag, override = {}) {
491
+ await this.recommend(sessionId, false, override);
492
+ const state = this.state.get(sessionId);
493
+ if (![...state.response.recommended, ...state.response.rest].find((item) => item.tag === tag)) throw new Error("所选 tag 不属于该模型当前合格供应商");
494
+ const endpoint = state.endpoints.find((item) => item.tag === tag);
495
+ if (!endpoint) throw new Error("所选 tag 的 endpoint 数据已失效,请刷新后重试");
496
+ const profile = this.deps.providerProfile(state.input.provider);
497
+ const credential = await this.key(state.input.provider);
498
+ return applyProvider({
499
+ upsertPreset: (modelId, providerTag) => this.deps.upsertPreset(credential, modelId, providerTag),
500
+ readModels: async () => [...profile?.models ?? []],
501
+ writeModels: this.deps.writeModels,
502
+ selectModel: this.deps.selectModel,
503
+ saveDefault: this.deps.saveDefault
504
+ }, {
505
+ sessionId,
506
+ route: state.input.provider,
507
+ sourceModel: state.input.model,
508
+ openrouterModel: state.response.openrouterModel,
509
+ endpoint,
510
+ ...state.input.reasoningEffort === void 0 ? {} : { reasoningEffort: state.input.reasoningEffort }
511
+ });
512
+ }
513
+ };
514
+
515
+ //#endregion
516
+ //#region src/openrouter.ts
517
+ const BASE_URL = "https://openrouter.ai/api/v1";
518
+ var OpenRouterError = class extends Error {
519
+ constructor(message, status, body) {
520
+ super(message);
521
+ this.status = status;
522
+ this.body = body;
523
+ this.name = "OpenRouterError";
524
+ }
525
+ };
526
+ var OpenRouterClient = class {
527
+ fetchImpl;
528
+ cache = /* @__PURE__ */ new Map();
529
+ modelCatalogCache;
530
+ ttl;
531
+ now;
532
+ baseURL;
533
+ constructor(options) {
534
+ this.options = options;
535
+ if (!options.apiKey) throw new Error("请配置 OPENROUTER_API_KEY");
536
+ this.fetchImpl = options.fetch ?? globalThis.fetch;
537
+ this.ttl = options.cacheTtlMs ?? 3e5;
538
+ this.now = options.now ?? Date.now;
539
+ this.baseURL = (options.baseURL ?? BASE_URL).replace(/\/$/, "");
540
+ }
541
+ async request(path, init) {
542
+ const timeout = AbortSignal.timeout(init?.method === "POST" ? 3e4 : 15e3);
543
+ const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
544
+ const response = await this.fetchImpl(`${this.baseURL}${path}`, {
545
+ ...init,
546
+ signal,
547
+ headers: {
548
+ authorization: `Bearer ${this.options.apiKey}`,
549
+ "content-type": "application/json",
550
+ ...init?.headers
551
+ }
552
+ });
553
+ const body = await response.json().catch(() => void 0);
554
+ if (!response.ok) throw new OpenRouterError((body && typeof body === "object" && "error" in body ? body.error?.message : void 0) ?? `OpenRouter request failed with HTTP ${response.status}`, response.status, body);
555
+ return body;
556
+ }
557
+ async listModels() {
558
+ const cached = this.modelCatalogCache;
559
+ if (cached && this.now() - cached.fetchedAt < this.ttl) return cached.models;
560
+ try {
561
+ const body = await this.request("/models");
562
+ if (!Array.isArray(body?.data)) throw new OpenRouterError("OpenRouter models response is malformed", 502, body);
563
+ const models = body.data.filter((item) => Boolean(item && typeof item === "object" && typeof item.id === "string" && typeof item.name === "string"));
564
+ this.modelCatalogCache = {
565
+ fetchedAt: this.now(),
566
+ models
567
+ };
568
+ return models;
569
+ } catch (error) {
570
+ if (cached) return cached.models;
571
+ throw error;
572
+ }
573
+ }
574
+ async getEndpoints(modelId, options = {}) {
575
+ const cached = this.cache.get(modelId);
576
+ if (!options.refresh && cached && this.now() - cached.fetchedAt < this.ttl) return cached.endpoints;
577
+ try {
578
+ const encoded = modelId.split("/").map(encodeURIComponent).join("/");
579
+ const body = await this.request(`/models/${encoded}/endpoints`);
580
+ const candidate = Array.isArray(body?.data) ? body.data : body?.data?.endpoints;
581
+ if (!Array.isArray(candidate)) throw new OpenRouterError("OpenRouter endpoints response is malformed", 502, body);
582
+ const endpoints = candidate;
583
+ this.cache.set(modelId, {
584
+ fetchedAt: this.now(),
585
+ endpoints
586
+ });
587
+ return endpoints;
588
+ } catch (error) {
589
+ if (cached) return cached.endpoints;
590
+ throw error;
591
+ }
592
+ }
593
+ clearEndpoints(modelId) {
594
+ this.cache.delete(modelId);
595
+ }
596
+ async upsertPreset(modelId, tag) {
597
+ const modelSlug = modelId.split("/").at(-1);
598
+ const provider = tag.split("/")[0];
599
+ if (!modelSlug || !provider) throw new Error("modelId and tag must contain non-empty slugs");
600
+ const slug = `${modelSlug}-${provider}`.toLowerCase().replace(/[^a-z0-9-]+/g, "-");
601
+ return {
602
+ slug,
603
+ response: await this.request(`/presets/${encodeURIComponent(slug)}/chat/completions`, {
604
+ method: "POST",
605
+ body: JSON.stringify({
606
+ model: modelId,
607
+ provider: {
608
+ order: [tag],
609
+ allow_fallbacks: false
610
+ },
611
+ messages: [{
612
+ role: "user",
613
+ content: ""
614
+ }]
615
+ })
616
+ })
617
+ };
618
+ }
619
+ };
620
+
621
+ //#endregion
622
+ //#region src/host-adapter.ts
623
+ function unwrap(operation, response) {
624
+ if (!response.result.ok) throw new Error(`${operation} failed: ${response.result.error.code}: ${response.result.error.message}`);
625
+ return response.result.value;
626
+ }
627
+ function createHostController(ctx, config, credentialRef$1) {
628
+ const clients = /* @__PURE__ */ new Map();
629
+ const client = (credential) => {
630
+ let found = clients.get(credential.ref);
631
+ if (!found || found.value !== credential.value) {
632
+ found = {
633
+ value: credential.value,
634
+ client: new OpenRouterClient({
635
+ apiKey: credential.value,
636
+ cacheTtlMs: config().cacheTtlMs
637
+ })
638
+ };
639
+ clients.set(credential.ref, found);
640
+ }
641
+ return found.client;
642
+ };
643
+ const profiles = () => ctx.settings.get("llm-pi-ai")?.providers ?? {};
644
+ const fallbackRef = "OPENROUTER_API_KEY";
645
+ return new OpenRouterProviderController({
646
+ config,
647
+ credential: async (provider) => {
648
+ const providerRef = profiles()[provider]?.apiKeyEnv;
649
+ if (providerRef !== void 0) {
650
+ const configured = await ctx.credentials.resolve(credentialRef$1(providerRef));
651
+ if (configured?.value) return {
652
+ configured: true,
653
+ ref: providerRef,
654
+ value: configured.value,
655
+ source: "provider"
656
+ };
657
+ }
658
+ const fallback = await ctx.credentials.resolve(credentialRef$1(fallbackRef));
659
+ if (fallback?.value) return {
660
+ configured: true,
661
+ ref: fallbackRef,
662
+ value: fallback.value,
663
+ source: "fallback"
664
+ };
665
+ return {
666
+ configured: false,
667
+ ref: providerRef ?? fallbackRef,
668
+ source: providerRef === void 0 ? "fallback" : "provider"
669
+ };
670
+ },
671
+ sessionSelection: async (sessionId) => unwrap("session.models", await ctx.apiProxy.sessions.models({ payload: { sessionId } })).current,
672
+ sessionDirectory: async (sessionId) => {
673
+ const value = unwrap("session.models", await ctx.apiProxy.sessions.models({ payload: { sessionId } }));
674
+ return {
675
+ current: value.current,
676
+ groups: value.groups ?? []
677
+ };
678
+ },
679
+ providerProfile: (provider) => profiles()[provider],
680
+ providerProfiles: profiles,
681
+ listModels: (credential) => client(credential).listModels(),
682
+ getEndpoints: (credential, modelId, refresh) => client(credential).getEndpoints(modelId, { refresh }),
683
+ upsertPreset: (credential, modelId, tag) => client(credential).upsertPreset(modelId, tag),
684
+ writeModels: async (route, models) => ctx.settings.mutate("llm-pi-ai", [{
685
+ op: "set",
686
+ path: [
687
+ "providers",
688
+ route,
689
+ "models"
690
+ ],
691
+ value: models
692
+ }]),
693
+ selectModel: async (selection) => {
694
+ unwrap("session.selectModel", await ctx.apiProxy.sessions.selectModel({ payload: selection }));
695
+ },
696
+ saveDefault: (selection) => ctx.agentDefaultModel.saveSelection(selection)
697
+ });
698
+ }
699
+
700
+ //#endregion
701
+ //#region src/host-routes.ts
702
+ const STRATEGIES = new Set([
703
+ "balanced",
704
+ "price",
705
+ "speed",
706
+ "context"
707
+ ]);
708
+ const MAX_BODY_BYTES = 64 * 1024;
709
+ var RouteError = class extends Error {
710
+ constructor(message, status) {
711
+ super(message);
712
+ this.status = status;
713
+ }
714
+ };
715
+ function strategyOf(value) {
716
+ return typeof value === "string" && STRATEGIES.has(value) ? value : void 0;
717
+ }
718
+ async function bodyOf(req) {
719
+ const chunks = [];
720
+ let size = 0;
721
+ for await (const chunk of req) {
722
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
723
+ size += buffer.byteLength;
724
+ if (size > MAX_BODY_BYTES) throw new RouteError("request body is too large", 413);
725
+ chunks.push(buffer);
726
+ }
727
+ if (chunks.length === 0) return {};
728
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
729
+ }
730
+ function header(req, name$1) {
731
+ const value = req.headers[name$1];
732
+ return Array.isArray(value) ? value[0] : value;
733
+ }
734
+ function assertMutationRequest(req) {
735
+ if (header(req, "content-type")?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") throw new RouteError("content-type must be application/json", 415);
736
+ const origin = header(req, "origin");
737
+ const host = header(req, "host");
738
+ if (origin) {
739
+ let originHost;
740
+ try {
741
+ originHost = new URL(origin).host;
742
+ } catch {
743
+ throw new RouteError("cross-origin mutation is forbidden", 403);
744
+ }
745
+ if (!host || originHost !== host) throw new RouteError("cross-origin mutation is forbidden", 403);
746
+ }
747
+ const declared = Number(header(req, "content-length"));
748
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) throw new RouteError("request body is too large", 413);
749
+ }
750
+ function send(res, status, body) {
751
+ res.statusCode = status;
752
+ res.setHeader("content-type", "application/json; charset=utf-8");
753
+ res.end(JSON.stringify(body));
754
+ }
755
+ function createProviderRoute(controller) {
756
+ return async (req, res) => {
757
+ try {
758
+ const url = new URL(req.url ?? "/", "http://localhost");
759
+ const path = url.pathname.replace(/^\/api\/openrouter-providers/, "") || "/";
760
+ if (req.method === "GET" && path === "/connection") return send(res, 200, await controller.connection());
761
+ if (req.method === "GET" && path === "/models") {
762
+ const sessionId = url.searchParams.get("sessionId");
763
+ if (!sessionId) return send(res, 400, {
764
+ ok: false,
765
+ error: "sessionId is required"
766
+ });
767
+ return send(res, 200, await controller.models(sessionId));
768
+ }
769
+ if (req.method === "GET" && path === "/recommend") {
770
+ const sessionId = url.searchParams.get("sessionId");
771
+ if (!sessionId) return send(res, 400, {
772
+ ok: false,
773
+ error: "sessionId is required"
774
+ });
775
+ const provider = url.searchParams.get("provider") ?? void 0;
776
+ const model = url.searchParams.get("model") ?? void 0;
777
+ const name$1 = url.searchParams.get("name") ?? void 0;
778
+ const rawStrategy = url.searchParams.get("strategy") ?? void 0;
779
+ const strategy = strategyOf(rawStrategy);
780
+ if (rawStrategy !== void 0 && strategy === void 0) return send(res, 400, {
781
+ ok: false,
782
+ error: "unknown ranking strategy"
783
+ });
784
+ return send(res, 200, await controller.recommend(sessionId, false, {
785
+ ...provider ? { provider } : {},
786
+ ...model ? { model } : {},
787
+ ...name$1 ? { name: name$1 } : {},
788
+ ...strategy ? { strategy } : {}
789
+ }));
790
+ }
791
+ if (req.method === "POST" && (path === "/refresh" || path === "/apply")) {
792
+ assertMutationRequest(req);
793
+ const body = await bodyOf(req);
794
+ if (typeof body.sessionId !== "string" || !body.sessionId) return send(res, 400, {
795
+ ok: false,
796
+ error: "sessionId is required"
797
+ });
798
+ const strategy = strategyOf(body.strategy);
799
+ if (body.strategy !== void 0 && strategy === void 0) return send(res, 400, {
800
+ ok: false,
801
+ error: "unknown ranking strategy"
802
+ });
803
+ const override = {
804
+ ...typeof body.provider === "string" && body.provider ? { provider: body.provider } : {},
805
+ ...typeof body.model === "string" && body.model ? { model: body.model } : {},
806
+ ...typeof body.name === "string" && body.name ? { name: body.name } : {},
807
+ ...strategy ? { strategy } : {}
808
+ };
809
+ if (path === "/refresh") return send(res, 200, await controller.recommend(body.sessionId, true, override));
810
+ if (typeof body.tag !== "string" || !body.tag) return send(res, 400, {
811
+ ok: false,
812
+ error: "tag is required"
813
+ });
814
+ return send(res, 200, await controller.apply(body.sessionId, body.tag, override));
815
+ }
816
+ send(res, 404, {
817
+ ok: false,
818
+ error: "not found"
819
+ });
820
+ } catch (error) {
821
+ const message = error instanceof Error ? error.message : String(error);
822
+ send(res, error instanceof RouteError ? error.status : 502, {
823
+ ok: false,
824
+ error: message
825
+ });
826
+ }
827
+ };
828
+ }
829
+
830
+ //#endregion
831
+ //#region src/tools.ts
832
+ const recommendationSchema = {
833
+ type: "object",
834
+ additionalProperties: true
835
+ };
836
+ async function providerToolApproval(exec, next) {
837
+ const downstream = await next();
838
+ if (downstream.kind !== "allow" || exec.name !== "switch_openrouter_provider") return downstream;
839
+ return {
840
+ kind: "ask",
841
+ reason: "切换 OpenRouter 供应商将修改当前会话和默认模型"
842
+ };
843
+ }
844
+ function createProviderTools(defineTool$1, controller) {
845
+ return [defineTool$1({
846
+ name: "recommend_openrouter_providers",
847
+ description: "Recommend ranked OpenRouter upstream providers for the current DSH model.",
848
+ parameters: {
849
+ provider: {
850
+ type: "string",
851
+ description: "Optional DSH provider route; defaults to current session"
852
+ },
853
+ model: {
854
+ type: "string",
855
+ description: "Optional DSH model id; defaults to current session"
856
+ }
857
+ },
858
+ output: {
859
+ schema: recommendationSchema,
860
+ render: (_args, value) => [{
861
+ type: "text",
862
+ text: value.recommended.map((item) => `${item.rank}. ${item.providerName} · ${item.quantization} · ${item.tps} t/s · score ${item.score}`).join("\n")
863
+ }]
864
+ },
865
+ execute: async (args, exec) => {
866
+ const sessionId = exec.agent?.session?.id;
867
+ if (!sessionId) throw new Error("工具执行上下文没有当前会话");
868
+ return controller.recommend(sessionId, false, args);
869
+ }
870
+ }), defineTool$1({
871
+ name: "switch_openrouter_provider",
872
+ description: "Switch the current DSH session and default model to one eligible OpenRouter provider tag.",
873
+ parameters: {
874
+ tag: {
875
+ type: "string",
876
+ required: true,
877
+ description: "Eligible OpenRouter endpoint tag"
878
+ },
879
+ sessionId: {
880
+ type: "string",
881
+ description: "Optional target session id; defaults to current session"
882
+ }
883
+ },
884
+ output: {
885
+ schema: {
886
+ type: "object",
887
+ additionalProperties: true
888
+ },
889
+ render: (_args, value) => [{
890
+ type: "text",
891
+ text: `已切换到 ${value.tag}(${value.provider}/${value.model})`
892
+ }]
893
+ },
894
+ execute: async (args, exec) => {
895
+ const sessionId = args.sessionId ?? exec.agent?.session?.id;
896
+ if (!sessionId) throw new Error("工具执行上下文没有当前会话");
897
+ return controller.apply(sessionId, args.tag);
898
+ }
899
+ })];
900
+ }
901
+
902
+ //#endregion
903
+ //#region src/index.ts
904
+ const name = "openrouter-providers";
905
+ const inject = [
906
+ "tools",
907
+ "settings",
908
+ "credentials",
909
+ "agentDefaultModel",
910
+ "apiProxy",
911
+ "webServer"
912
+ ];
913
+ const NS = settingsNamespace("openrouter-providers");
914
+ const weights = z.object({
915
+ quantization: z.number().min(0).max(1).default(.3),
916
+ speed: z.number().min(0).max(1).default(.3),
917
+ price: z.number().min(0).max(1).default(.3),
918
+ context: z.number().min(0).max(1).default(.1)
919
+ });
920
+ const Config = z.object({
921
+ minContextTokens: z.number().step(1).min(1).default(1e5),
922
+ cacheTtlMs: z.number().step(1).min(0).default(3e5),
923
+ recommendedCount: z.number().step(1).min(1).max(10).default(5),
924
+ weights,
925
+ priceBlend: z.object({
926
+ input: z.number().min(0).max(1).default(.02),
927
+ output: z.number().min(0).max(1).default(.08),
928
+ cache: z.number().min(0).max(1).default(.9)
929
+ }),
930
+ uptimePenaltyThreshold: z.number().min(0).max(100).default(99),
931
+ uptimePenaltyFactor: z.number().min(0).max(1).default(.85)
932
+ });
933
+ function apply(ctx, initial = DEFAULT_CONFIG) {
934
+ let source = () => initial;
935
+ installSettingsSection(ctx, NS, Config, initial, {
936
+ validate: validateConfig,
937
+ setSource: (current) => {
938
+ source = current;
939
+ },
940
+ onChange: () => {}
941
+ });
942
+ const host = ctx;
943
+ const controller = createHostController(host, () => source(), credentialRef);
944
+ ctx.effect(() => ctx.on("tools/pre-execute", providerToolApproval), "openrouter-providers: approve provider switch");
945
+ ctx.effect(() => host.webServer.register({
946
+ kind: "prefix",
947
+ path: "/api/openrouter-providers",
948
+ handler: createProviderRoute(controller)
949
+ }), "openrouter-providers: host routes");
950
+ for (const tool of createProviderTools(defineTool, controller)) ctx.effect(() => host.tools.register(tool), "openrouter-providers: tool");
951
+ }
952
+
953
+ //#endregion
954
+ export { Config, NS, OpenRouterProviderDirectory, apply, inject, name };