@khanglvm/llm-router 2.3.0 → 2.3.2
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/CHANGELOG.md +5 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/cli/router-module.js +32 -5
- package/src/node/coding-tool-config.js +138 -25
- package/src/node/large-request-log.js +54 -0
- package/src/node/litellm-context-catalog.js +13 -1
- package/src/node/local-server.js +10 -0
- package/src/node/ollama-client.js +195 -0
- package/src/node/ollama-hardware.js +94 -0
- package/src/node/ollama-install.js +230 -0
- package/src/node/provider-probe.js +69 -5
- package/src/node/web-console-client.js +36 -36
- package/src/node/web-console-server.js +478 -8
- package/src/node/web-console-styles.generated.js +1 -1
- package/src/node/web-console-ui/amp-utils.js +272 -0
- package/src/node/web-console-ui/api-client.js +128 -0
- package/src/node/web-console-ui/capability-utils.js +36 -0
- package/src/node/web-console-ui/config-editor-utils.js +20 -5
- package/src/node/web-console-ui/constants.js +140 -0
- package/src/node/web-console-ui/context-window-utils.js +262 -0
- package/src/node/web-console-ui/hooks/use-reorder-layout-animation.js +65 -0
- package/src/node/web-console-ui/provider-presets.js +211 -0
- package/src/node/web-console-ui/quick-start-utils.js +790 -0
- package/src/node/web-console-ui/utils.js +353 -0
- package/src/node/web-console-ui/web-search-utils.js +460 -0
- package/src/runtime/config.js +96 -9
- package/src/runtime/handler/fallback.js +71 -0
- package/src/runtime/handler/field-filter.js +39 -0
- package/src/runtime/handler/large-request-log.js +211 -0
- package/src/runtime/handler/provider-call.js +185 -15
- package/src/runtime/handler/reasoning-effort.js +11 -1
- package/src/runtime/handler/tool-name-sanitizer.js +258 -0
- package/src/runtime/handler.js +16 -3
- package/src/shared/coding-tool-bindings.js +3 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { MODEL_ALIAS_STRATEGY_OPTIONS, MODEL_ALIAS_STRATEGY_LABELS, DRAGGING_ROW_CLASSES, ACTIVITY_CATEGORY_META } from "./constants.js";
|
|
2
|
+
import {
|
|
3
|
+
normalizeRateLimitModelSelectors,
|
|
4
|
+
normalizeRateLimitWindowUnit,
|
|
5
|
+
RATE_LIMIT_ALL_MODELS_SELECTOR,
|
|
6
|
+
formatRateLimitBucketCap
|
|
7
|
+
} from "./rate-limit-utils.js";
|
|
8
|
+
import { PROVIDER_PRESET_BY_KEY } from "./provider-presets.js";
|
|
9
|
+
|
|
10
|
+
export function splitListValues(value) {
|
|
11
|
+
return Array.from(new Set(String(value || "")
|
|
12
|
+
.split(/[\s,]+/)
|
|
13
|
+
.map((item) => item.trim())
|
|
14
|
+
.filter(Boolean)));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isLikelyHttpEndpoint(value) {
|
|
18
|
+
const text = String(value || "").trim();
|
|
19
|
+
if (!text) return false;
|
|
20
|
+
try {
|
|
21
|
+
const parsed = new URL(text);
|
|
22
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function mergeChipValuesAndDraft(values = [], draft = "") {
|
|
29
|
+
return Array.from(new Set([
|
|
30
|
+
...(Array.isArray(values) ? values : []).map((value) => String(value || "").trim()).filter(Boolean),
|
|
31
|
+
...splitListValues(draft)
|
|
32
|
+
]));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function moveItemsByKey(items = [], fromKey, toKey) {
|
|
36
|
+
if (!fromKey || !toKey || fromKey === toKey) return items;
|
|
37
|
+
const nextItems = [...items];
|
|
38
|
+
const fromIndex = nextItems.findIndex((item) => item?.key === fromKey);
|
|
39
|
+
const toIndex = nextItems.findIndex((item) => item?.key === toKey);
|
|
40
|
+
if (fromIndex === -1 || toIndex === -1) return items;
|
|
41
|
+
const [movedItem] = nextItems.splice(fromIndex, 1);
|
|
42
|
+
nextItems.splice(toIndex, 0, movedItem);
|
|
43
|
+
return nextItems;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function moveItemUp(items = [], itemKey, getKey = (item) => item?.key) {
|
|
47
|
+
if (!itemKey) return items;
|
|
48
|
+
const currentIndex = items.findIndex((item) => getKey(item) === itemKey);
|
|
49
|
+
if (currentIndex <= 0) return items;
|
|
50
|
+
const nextItems = [...items];
|
|
51
|
+
const [movedItem] = nextItems.splice(currentIndex, 1);
|
|
52
|
+
nextItems.splice(currentIndex - 1, 0, movedItem);
|
|
53
|
+
return nextItems;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function moveItemDown(items = [], itemKey, getKey = (item) => item?.key) {
|
|
57
|
+
if (!itemKey) return items;
|
|
58
|
+
const currentIndex = items.findIndex((item) => getKey(item) === itemKey);
|
|
59
|
+
if (currentIndex === -1 || currentIndex >= items.length - 1) return items;
|
|
60
|
+
const nextItems = [...items];
|
|
61
|
+
const [movedItem] = nextItems.splice(currentIndex, 1);
|
|
62
|
+
nextItems.splice(currentIndex + 1, 0, movedItem);
|
|
63
|
+
return nextItems;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function captureScrollSettleSnapshot(node, scrollContainers = []) {
|
|
67
|
+
const rect = node?.getBoundingClientRect?.();
|
|
68
|
+
return {
|
|
69
|
+
top: Number.isFinite(rect?.top) ? Number(rect.top) : Number.NaN,
|
|
70
|
+
left: Number.isFinite(rect?.left) ? Number(rect.left) : Number.NaN,
|
|
71
|
+
windowX: typeof window === "undefined" ? 0 : Number(window.scrollX || window.pageXOffset || 0),
|
|
72
|
+
windowY: typeof window === "undefined" ? 0 : Number(window.scrollY || window.pageYOffset || 0),
|
|
73
|
+
containers: scrollContainers.map((container) => ({
|
|
74
|
+
top: Number(container?.scrollTop || 0),
|
|
75
|
+
left: Number(container?.scrollLeft || 0)
|
|
76
|
+
}))
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isScrollSettleSnapshotStable(previousSnapshot, nextSnapshot, threshold = 0.5) {
|
|
81
|
+
if (!previousSnapshot || !nextSnapshot) return false;
|
|
82
|
+
if (!Number.isFinite(nextSnapshot.top) || !Number.isFinite(nextSnapshot.left)) return false;
|
|
83
|
+
if (Math.abs(nextSnapshot.top - previousSnapshot.top) > threshold) return false;
|
|
84
|
+
if (Math.abs(nextSnapshot.left - previousSnapshot.left) > threshold) return false;
|
|
85
|
+
if (Math.abs(nextSnapshot.windowX - previousSnapshot.windowX) > threshold) return false;
|
|
86
|
+
if (Math.abs(nextSnapshot.windowY - previousSnapshot.windowY) > threshold) return false;
|
|
87
|
+
if ((previousSnapshot.containers?.length || 0) !== (nextSnapshot.containers?.length || 0)) return false;
|
|
88
|
+
|
|
89
|
+
return nextSnapshot.containers.every((position, index) => {
|
|
90
|
+
const previousPosition = previousSnapshot.containers[index];
|
|
91
|
+
if (!previousPosition) return false;
|
|
92
|
+
return Math.abs(position.top - previousPosition.top) <= threshold
|
|
93
|
+
&& Math.abs(position.left - previousPosition.left) <= threshold;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getActivityEntryCategory(entry) {
|
|
98
|
+
const category = String(entry?.category || "").trim().toLowerCase();
|
|
99
|
+
if (category === "usage" || category === "router") return category;
|
|
100
|
+
const source = String(entry?.source || "").trim().toLowerCase();
|
|
101
|
+
const kind = String(entry?.kind || "").trim().toLowerCase();
|
|
102
|
+
if (source === "runtime" || kind.startsWith("request") || kind.startsWith("fallback")) {
|
|
103
|
+
return "usage";
|
|
104
|
+
}
|
|
105
|
+
return "router";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function setDraggingRowClasses(node, active) {
|
|
109
|
+
if (!node?.classList) return;
|
|
110
|
+
if (active) {
|
|
111
|
+
node.classList.add(...DRAGGING_ROW_CLASSES);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
node.classList.remove(...DRAGGING_ROW_CLASSES);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function getReorderRowNode(node) {
|
|
118
|
+
return typeof node?.closest === "function" ? node.closest("[data-reorder-row='true']") : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function normalizeModelAliasStrategyValue(strategy) {
|
|
122
|
+
const normalized = String(strategy || "").trim().toLowerCase();
|
|
123
|
+
if (normalized === "automatic" || normalized === "smart") return "auto";
|
|
124
|
+
if (normalized === "rr") return "round-robin";
|
|
125
|
+
if (normalized === "weighted-round-robin" || normalized === "weighted_rr") return "weighted-rr";
|
|
126
|
+
if (normalized === "quota-aware-weighted-round-robin") return "quota-aware-weighted-rr";
|
|
127
|
+
return Object.prototype.hasOwnProperty.call(MODEL_ALIAS_STRATEGY_LABELS, normalized) ? normalized : "ordered";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function formatModelAliasStrategyLabel(strategy) {
|
|
131
|
+
const normalized = normalizeModelAliasStrategyValue(strategy);
|
|
132
|
+
return MODEL_ALIAS_STRATEGY_LABELS[normalized] || MODEL_ALIAS_STRATEGY_LABELS.ordered;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function normalizeUniqueTrimmedValues(values = []) {
|
|
136
|
+
return Array.from(new Set((values || []).map((entry) => String(entry || "").trim()).filter(Boolean)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function hasDuplicateTrimmedValues(values = []) {
|
|
140
|
+
const seen = new Set();
|
|
141
|
+
for (const value of (values || []).map((entry) => String(entry || "").trim()).filter(Boolean)) {
|
|
142
|
+
if (seen.has(value)) return true;
|
|
143
|
+
seen.add(value);
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function hasDuplicateHeaderName(rows = [], name = "", exceptIndex = -1) {
|
|
149
|
+
const normalizedName = String(name || "").trim().toLowerCase();
|
|
150
|
+
if (!normalizedName) return false;
|
|
151
|
+
return (rows || []).some((row, index) => index !== exceptIndex && String(row?.name || "").trim().toLowerCase() === normalizedName);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function createPendingAliasSeed() {
|
|
155
|
+
return {
|
|
156
|
+
id: "",
|
|
157
|
+
strategy: "auto",
|
|
158
|
+
targets: [{ ref: "" }],
|
|
159
|
+
fallbackTargets: []
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function buildAliasDraftResetKey(aliasId = "", alias = {}, { isNew = false } = {}) {
|
|
164
|
+
return JSON.stringify({
|
|
165
|
+
aliasId: isNew ? "" : String(aliasId || "").trim(),
|
|
166
|
+
id: String(alias?.id || (isNew ? "" : aliasId) || "").trim(),
|
|
167
|
+
strategy: String(alias?.strategy || "ordered").trim() || "ordered",
|
|
168
|
+
targets: (Array.isArray(alias?.targets) ? alias.targets : []).map((target) => String(target?.ref || "").trim()),
|
|
169
|
+
fallbackTargets: (Array.isArray(alias?.fallbackTargets) ? alias.fallbackTargets : []).map((target) => String(target?.ref || "").trim())
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function createMasterKey() {
|
|
174
|
+
const prefix = "gw_";
|
|
175
|
+
if (typeof globalThis !== "undefined" && globalThis.crypto?.getRandomValues) {
|
|
176
|
+
const bytes = new Uint8Array(18);
|
|
177
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
178
|
+
return `${prefix}${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
179
|
+
}
|
|
180
|
+
return `${prefix}${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function looksLikeEnvVarName(value) {
|
|
184
|
+
return /^[A-Z][A-Z0-9_]*$/.test(String(value || "").trim());
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function slugifyProviderId(value) {
|
|
188
|
+
const normalized = String(value || "")
|
|
189
|
+
.toLowerCase()
|
|
190
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
191
|
+
.replace(/^-+|-+$/g, "")
|
|
192
|
+
.replace(/-{2,}/g, "-");
|
|
193
|
+
if (!normalized) return "";
|
|
194
|
+
if (/^[a-z]/.test(normalized)) return normalized;
|
|
195
|
+
return `provider-${normalized}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function normalizePositiveInteger(value, fallback) {
|
|
199
|
+
const parsed = Number.parseInt(String(value || ""), 10);
|
|
200
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function collectProviderModelIds(provider = {}) {
|
|
204
|
+
return normalizeUniqueTrimmedValues(
|
|
205
|
+
(Array.isArray(provider?.models) ? provider.models : []).map((model) => model?.id)
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function createRateLimitDraftRow(row = {}, {
|
|
210
|
+
keyPrefix = "rate-limit",
|
|
211
|
+
index = 0,
|
|
212
|
+
defaults = PROVIDER_PRESET_BY_KEY.custom.rateLimitDefaults,
|
|
213
|
+
useDefaults = true
|
|
214
|
+
} = {}) {
|
|
215
|
+
const sourceId = String(row?.sourceId || row?.id || "").trim();
|
|
216
|
+
const resolvedRequests = row?.requests ?? row?.limit;
|
|
217
|
+
const resolvedWindowValue = row?.windowValue ?? row?.window?.size ?? row?.window?.value;
|
|
218
|
+
const resolvedWindowUnit = row?.windowUnit ?? row?.window?.unit;
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
key: String(row?.key || "").trim() || `${keyPrefix}-${sourceId || index + 1}`,
|
|
222
|
+
sourceId,
|
|
223
|
+
models: normalizeRateLimitModelSelectors(Array.isArray(row?.models) ? row.models : []),
|
|
224
|
+
modelsDraft: String(row?.modelsDraft || ""),
|
|
225
|
+
requests: resolvedRequests !== undefined
|
|
226
|
+
? String(resolvedRequests)
|
|
227
|
+
: (useDefaults ? String(defaults.limit ?? "") : ""),
|
|
228
|
+
windowValue: resolvedWindowValue !== undefined
|
|
229
|
+
? String(resolvedWindowValue)
|
|
230
|
+
: (useDefaults ? String(defaults.windowValue ?? "") : ""),
|
|
231
|
+
windowUnit: String(
|
|
232
|
+
resolvedWindowUnit !== undefined
|
|
233
|
+
? resolvedWindowUnit
|
|
234
|
+
: (useDefaults ? defaults.windowUnit : "minute")
|
|
235
|
+
).trim() || "minute"
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function createRateLimitDraftRows(rateLimits = [], {
|
|
240
|
+
keyPrefix = "rate-limit",
|
|
241
|
+
defaults = PROVIDER_PRESET_BY_KEY.custom.rateLimitDefaults,
|
|
242
|
+
includeDefault = true
|
|
243
|
+
} = {}) {
|
|
244
|
+
const sourceRows = Array.isArray(rateLimits) && rateLimits.length > 0
|
|
245
|
+
? rateLimits
|
|
246
|
+
: (includeDefault
|
|
247
|
+
? [{
|
|
248
|
+
models: [RATE_LIMIT_ALL_MODELS_SELECTOR],
|
|
249
|
+
requests: defaults.limit,
|
|
250
|
+
window: { size: defaults.windowValue, unit: defaults.windowUnit }
|
|
251
|
+
}]
|
|
252
|
+
: []);
|
|
253
|
+
|
|
254
|
+
return sourceRows.map((row, index) => createRateLimitDraftRow(row, {
|
|
255
|
+
keyPrefix,
|
|
256
|
+
index,
|
|
257
|
+
defaults,
|
|
258
|
+
useDefaults: true
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function isBlankRateLimitDraftRow(row = {}) {
|
|
263
|
+
const models = normalizeRateLimitModelSelectors(mergeChipValuesAndDraft(row?.models, row?.modelsDraft || ""));
|
|
264
|
+
return models.length === 0
|
|
265
|
+
&& !String(row?.requests ?? "").trim()
|
|
266
|
+
&& !String(row?.windowValue ?? "").trim();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function resolveRateLimitDraftRows(rows = []) {
|
|
270
|
+
return (Array.isArray(rows) ? rows : [])
|
|
271
|
+
.map((row, index) => ({
|
|
272
|
+
key: String(row?.key || `rate-limit-${index + 1}`).trim() || `rate-limit-${index + 1}`,
|
|
273
|
+
sourceId: String(row?.sourceId || "").trim(),
|
|
274
|
+
models: normalizeRateLimitModelSelectors(mergeChipValuesAndDraft(row?.models, row?.modelsDraft || "")),
|
|
275
|
+
requests: String(row?.requests ?? "").trim(),
|
|
276
|
+
windowValue: String(row?.windowValue ?? "").trim(),
|
|
277
|
+
windowUnit: normalizeRateLimitWindowUnit(row?.windowUnit, "minute")
|
|
278
|
+
}))
|
|
279
|
+
.filter((row, index) => !isBlankRateLimitDraftRow({ ...(rows?.[index] || {}), ...row }));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function serializeRateLimitDraftRows(rows = []) {
|
|
283
|
+
return resolveRateLimitDraftRows(rows).map((row) => ({
|
|
284
|
+
sourceId: row.sourceId,
|
|
285
|
+
models: row.models,
|
|
286
|
+
requests: row.requests,
|
|
287
|
+
windowValue: row.windowValue,
|
|
288
|
+
windowUnit: row.windowUnit
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function formatRateLimitSummary(rateLimits = []) {
|
|
293
|
+
const buckets = Array.isArray(rateLimits) ? rateLimits.filter(Boolean) : [];
|
|
294
|
+
if (buckets.length === 0) return "";
|
|
295
|
+
if (buckets.length === 1) return formatRateLimitBucketCap(buckets[0]);
|
|
296
|
+
return `${buckets.length} rate limits`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function safeClone(value) {
|
|
300
|
+
return JSON.parse(JSON.stringify(value ?? {}));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function tryParseConfigObject(value, fallback = {}) {
|
|
304
|
+
try {
|
|
305
|
+
const parsed = JSON.parse(String(value || "{}"));
|
|
306
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback;
|
|
307
|
+
} catch {
|
|
308
|
+
return fallback;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function parseDraftConfigText(rawText, fallback = {}) {
|
|
313
|
+
try {
|
|
314
|
+
const parsed = String(rawText || "").trim() ? JSON.parse(rawText) : {};
|
|
315
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
316
|
+
return {
|
|
317
|
+
value: fallback,
|
|
318
|
+
parseError: "Config root must be a JSON object."
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
value: parsed,
|
|
323
|
+
parseError: ""
|
|
324
|
+
};
|
|
325
|
+
} catch (error) {
|
|
326
|
+
return {
|
|
327
|
+
value: fallback,
|
|
328
|
+
parseError: error instanceof Error ? error.message : String(error)
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function formatTime(value) {
|
|
334
|
+
if (!value) return "—";
|
|
335
|
+
return new Date(value).toLocaleTimeString([], {
|
|
336
|
+
hour: "2-digit",
|
|
337
|
+
minute: "2-digit",
|
|
338
|
+
second: "2-digit"
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function detectValidationVariant(summary) {
|
|
343
|
+
if (summary?.parseError) return "danger";
|
|
344
|
+
if ((summary?.validationErrors || []).length > 0) return "warning";
|
|
345
|
+
return "success";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function describeConfigStatus(summary) {
|
|
349
|
+
const variant = detectValidationVariant(summary);
|
|
350
|
+
if (variant === "danger") return { variant, label: "Config: invalid" };
|
|
351
|
+
if (variant === "warning") return { variant, label: "Config: invalid" };
|
|
352
|
+
return { variant: "success", label: "Config: valid" };
|
|
353
|
+
}
|