@khanglvm/llm-router 2.3.1 → 2.3.4

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +2 -2
  3. package/package.json +1 -1
  4. package/src/cli/router-module.js +32 -5
  5. package/src/node/coding-tool-config.js +138 -25
  6. package/src/node/large-request-log.js +54 -0
  7. package/src/node/litellm-context-catalog.js +13 -1
  8. package/src/node/local-server.js +10 -0
  9. package/src/node/ollama-client.js +195 -0
  10. package/src/node/ollama-hardware.js +94 -0
  11. package/src/node/ollama-install.js +230 -0
  12. package/src/node/provider-probe.js +69 -5
  13. package/src/node/web-console-client.js +36 -36
  14. package/src/node/web-console-server.js +478 -8
  15. package/src/node/web-console-styles.generated.js +1 -1
  16. package/src/node/web-console-ui/amp-utils.js +272 -0
  17. package/src/node/web-console-ui/api-client.js +128 -0
  18. package/src/node/web-console-ui/capability-utils.js +36 -0
  19. package/src/node/web-console-ui/config-editor-utils.js +20 -5
  20. package/src/node/web-console-ui/constants.js +140 -0
  21. package/src/node/web-console-ui/context-window-utils.js +262 -0
  22. package/src/node/web-console-ui/hooks/use-reorder-layout-animation.js +65 -0
  23. package/src/node/web-console-ui/provider-presets.js +211 -0
  24. package/src/node/web-console-ui/quick-start-utils.js +790 -0
  25. package/src/node/web-console-ui/utils.js +353 -0
  26. package/src/node/web-console-ui/web-search-utils.js +460 -0
  27. package/src/runtime/config.js +96 -9
  28. package/src/runtime/handler/fallback.js +71 -0
  29. package/src/runtime/handler/field-filter.js +39 -0
  30. package/src/runtime/handler/large-request-log.js +211 -0
  31. package/src/runtime/handler/provider-call.js +276 -15
  32. package/src/runtime/handler/reasoning-effort.js +11 -1
  33. package/src/runtime/handler/tool-name-sanitizer.js +258 -0
  34. package/src/runtime/handler.js +16 -3
  35. package/src/shared/coding-tool-bindings.js +3 -0
@@ -0,0 +1,128 @@
1
+ import { JSON_HEADERS, CONTEXT_LOOKUP_SUGGESTION_LIMIT } from "./constants.js";
2
+ import { pickFreeTierProbeModels } from "./quick-start-utils.js";
3
+ import { looksLikeEnvVarName } from "./utils.js";
4
+
5
+ export async function fetchJson(url, options = {}) {
6
+ const response = await fetch(url, options);
7
+ const payload = await response.json().catch(() => ({}));
8
+ if (!response.ok) {
9
+ const error = new Error(payload?.error || `Request failed (${response.status})`);
10
+ error.statusCode = response.status;
11
+ error.payload = payload;
12
+ throw error;
13
+ }
14
+ return payload;
15
+ }
16
+
17
+ export async function fetchJsonLineStream(url, options = {}, { onMessage } = {}) {
18
+ const response = await fetch(url, options);
19
+ if (!response.ok) {
20
+ const payload = await response.json().catch(() => ({}));
21
+ const error = new Error(payload?.error || `Request failed (${response.status})`);
22
+ error.statusCode = response.status;
23
+ error.payload = payload;
24
+ throw error;
25
+ }
26
+
27
+ const reader = response.body?.getReader();
28
+ if (!reader) return null;
29
+ const decoder = new TextDecoder();
30
+ let buffer = "";
31
+ let finalResult = null;
32
+
33
+ while (true) {
34
+ const { value, done } = await reader.read();
35
+ buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
36
+
37
+ let newlineIndex = buffer.indexOf("\n");
38
+ while (newlineIndex >= 0) {
39
+ const rawLine = buffer.slice(0, newlineIndex).trim();
40
+ buffer = buffer.slice(newlineIndex + 1);
41
+ if (rawLine) {
42
+ const message = JSON.parse(rawLine);
43
+ onMessage?.(message);
44
+ if (message?.type === "result") {
45
+ finalResult = message.result;
46
+ }
47
+ if (message?.type === "error") {
48
+ const error = new Error(message.error || "Request failed.");
49
+ error.statusCode = message.statusCode || 500;
50
+ throw error;
51
+ }
52
+ }
53
+ newlineIndex = buffer.indexOf("\n");
54
+ }
55
+
56
+ if (done) break;
57
+ }
58
+
59
+ const finalLine = buffer.trim();
60
+ if (finalLine) {
61
+ const message = JSON.parse(finalLine);
62
+ onMessage?.(message);
63
+ if (message?.type === "result") finalResult = message.result;
64
+ if (message?.type === "error") {
65
+ const error = new Error(message.error || "Request failed.");
66
+ error.statusCode = message.statusCode || 500;
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ return finalResult;
72
+ }
73
+
74
+ export async function probeFreeTierModels(baseUrl, credential, modelIds) {
75
+ const sampleIds = pickFreeTierProbeModels(modelIds);
76
+ if (sampleIds.length === 0) return null;
77
+ try {
78
+ const payload = await fetchJson("/api/config/probe-free-tier-models", {
79
+ method: "POST",
80
+ headers: JSON_HEADERS,
81
+ body: JSON.stringify({
82
+ baseUrl,
83
+ ...(looksLikeEnvVarName(credential) ? { apiKeyEnv: credential } : { apiKey: credential }),
84
+ modelIds: sampleIds
85
+ })
86
+ });
87
+ if (!payload?.result) return null;
88
+ const freeTiers = new Set();
89
+ const paidTiers = new Set();
90
+ for (const [id, info] of Object.entries(payload.result)) {
91
+ const lower = id.toLowerCase();
92
+ const tier = lower.includes("flash-lite") ? "flash-lite"
93
+ : lower.includes("flash") ? "flash"
94
+ : lower.includes("pro") ? "pro"
95
+ : lower;
96
+ if (info?.freeTier) freeTiers.add(tier);
97
+ else paidTiers.add(tier);
98
+ }
99
+ return modelIds.filter((id) => {
100
+ const lower = id.toLowerCase();
101
+ const tier = lower.includes("flash-lite") ? "flash-lite"
102
+ : lower.includes("flash") ? "flash"
103
+ : lower.includes("pro") ? "pro"
104
+ : lower;
105
+ if (freeTiers.has(tier)) return true;
106
+ if (paidTiers.has(tier)) return false;
107
+ return true;
108
+ });
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ export async function lookupLiteLlmContextWindow(models = []) {
115
+ const normalizedModels = [...new Set((Array.isArray(models) ? models : [])
116
+ .map((model) => String(model || "").trim())
117
+ .filter(Boolean))];
118
+ if (normalizedModels.length === 0) return [];
119
+ const payload = await fetchJson("/api/config/litellm-context-lookup", {
120
+ method: "POST",
121
+ headers: JSON_HEADERS,
122
+ body: JSON.stringify({
123
+ models: normalizedModels,
124
+ limit: CONTEXT_LOOKUP_SUGGESTION_LIMIT
125
+ })
126
+ });
127
+ return Array.isArray(payload?.result) ? payload.result : [];
128
+ }
@@ -0,0 +1,36 @@
1
+ // Keep in sync with MODEL_CAPABILITY_KEYS in src/runtime/config.js
2
+ export const CAPABILITY_DEFINITIONS = [
3
+ { key: "supportsReasoning", label: "Reasoning", field: "reasoning_effort" },
4
+ { key: "supportsThinking", label: "Thinking", field: "thinking" },
5
+ { key: "supportsResponseFormat", label: "Response Format", field: "response_format" },
6
+ { key: "supportsLogprobs", label: "Logprobs", field: "logprobs" },
7
+ { key: "supportsServiceTier", label: "Service Tier", field: "service_tier" },
8
+ { key: "supportsPrediction", label: "Prediction", field: "prediction" },
9
+ { key: "supportsStreamOptions", label: "Stream Options", field: "stream_options" }
10
+ ];
11
+
12
+ /** Merge litellm capabilities into existing row capabilities (don't overwrite user-set values) */
13
+ export function mergeLiteLlmCapabilities(existing = {}, litellm = {}) {
14
+ if (!litellm || typeof litellm !== "object") return existing;
15
+ const merged = { ...existing };
16
+ for (const key of Object.keys(litellm)) {
17
+ if (typeof litellm[key] !== "boolean") continue;
18
+ if (merged[key] === undefined) {
19
+ merged[key] = litellm[key];
20
+ }
21
+ }
22
+ return merged;
23
+ }
24
+
25
+ /** Cycle capability value: undefined → true → false → undefined */
26
+ export function cycleCapabilityValue(current) {
27
+ if (current === undefined) return true;
28
+ if (current === true) return false;
29
+ return undefined;
30
+ }
31
+
32
+ /** Check if any capabilities are explicitly set */
33
+ export function hasExplicitCapabilities(capabilities) {
34
+ if (!capabilities || typeof capabilities !== "object") return false;
35
+ return Object.values(capabilities).some((v) => typeof v === "boolean");
36
+ }
@@ -354,10 +354,15 @@ function normalizeProviderModelRows(rows = []) {
354
354
  if (!id || seenIds.has(id)) continue;
355
355
  seenIds.add(id);
356
356
  const parsedContextWindow = Number.parseInt(String(row?.contextWindow || "").trim(), 10);
357
+ const capabilities = row?.capabilities && typeof row.capabilities === "object"
358
+ && Object.keys(row.capabilities).length > 0
359
+ ? row.capabilities
360
+ : undefined;
357
361
  normalizedRows.push({
358
362
  id,
359
363
  sourceId,
360
- contextWindow: Number.isFinite(parsedContextWindow) && parsedContextWindow > 0 ? parsedContextWindow : null
364
+ contextWindow: Number.isFinite(parsedContextWindow) && parsedContextWindow > 0 ? parsedContextWindow : null,
365
+ ...(capabilities ? { capabilities } : {})
361
366
  });
362
367
  }
363
368
 
@@ -413,16 +418,22 @@ export function applyProviderModelEdits(config = {}, providerId, rows = []) {
413
418
  if (matchedSourceId !== row.id) {
414
419
  renameMap.set(matchedSourceId, row.id);
415
420
  }
416
- nextModels.push({
421
+ const matchedModel = {
417
422
  ...existingModelMap.get(matchedSourceId),
418
423
  id: row.id,
419
424
  ...(row.contextWindow
420
425
  ? { contextWindow: row.contextWindow }
421
426
  : {})
422
- });
427
+ };
423
428
  if (!row.contextWindow) {
424
- delete nextModels[nextModels.length - 1].contextWindow;
429
+ delete matchedModel.contextWindow;
430
+ }
431
+ if (row.capabilities && typeof row.capabilities === "object" && Object.keys(row.capabilities).length > 0) {
432
+ matchedModel.capabilities = row.capabilities;
433
+ } else {
434
+ delete matchedModel.capabilities;
425
435
  }
436
+ nextModels.push(matchedModel);
426
437
  continue;
427
438
  }
428
439
 
@@ -430,6 +441,9 @@ export function applyProviderModelEdits(config = {}, providerId, rows = []) {
430
441
  id: row.id,
431
442
  ...(row.contextWindow
432
443
  ? { contextWindow: row.contextWindow }
444
+ : {}),
445
+ ...(row.capabilities && typeof row.capabilities === "object" && Object.keys(row.capabilities).length > 0
446
+ ? { capabilities: row.capabilities }
433
447
  : {})
434
448
  });
435
449
  }
@@ -685,6 +699,7 @@ export function createProviderModelDraftRows(provider = {}) {
685
699
  key: `model-${provider?.id || "provider"}-${index}-${String(model?.id || "").trim() || "empty"}`,
686
700
  id: String(model?.id || "").trim(),
687
701
  sourceId: String(model?.id || "").trim(),
688
- contextWindow: Number.isFinite(model?.contextWindow) ? String(Math.floor(Number(model.contextWindow))) : ""
702
+ contextWindow: Number.isFinite(model?.contextWindow) ? String(Math.floor(Number(model.contextWindow))) : "",
703
+ ...(model?.capabilities ? { capabilities: { ...model.capabilities } } : {})
689
704
  }));
690
705
  }
@@ -0,0 +1,140 @@
1
+ import { RATE_LIMIT_WINDOW_OPTIONS } from "./rate-limit-utils.js";
2
+
3
+ export const JSON_HEADERS = { "content-type": "application/json" };
4
+ export const LOG_LEVEL_STYLES = {
5
+ info: "bg-sky-50 text-sky-700 ring-sky-100",
6
+ success: "bg-emerald-50 text-emerald-700 ring-emerald-100",
7
+ warn: "bg-amber-50 text-amber-700 ring-amber-100",
8
+ error: "bg-rose-50 text-rose-700 ring-rose-100"
9
+ };
10
+ export const ACTIVITY_FILTER_OPTIONS = [
11
+ { value: "usage", label: "Request / response" },
12
+ { value: "router", label: "LLM Router" },
13
+ { value: "all", label: "All categories" }
14
+ ];
15
+ export const ACTIVITY_CATEGORY_META = {
16
+ usage: {
17
+ label: "Request / response",
18
+ badgeVariant: "info",
19
+ emptyLabel: "request/response"
20
+ },
21
+ router: {
22
+ label: "LLM Router",
23
+ badgeVariant: "outline",
24
+ emptyLabel: "LLM Router"
25
+ }
26
+ };
27
+ export const GITHUB_REPO_URL = "https://github.com/khanglvm/llm-router";
28
+ export const GITHUB_SPONSORS_URL = "https://github.com/sponsors/khanglvm";
29
+
30
+ export const QUICK_START_FALLBACK_USER_AGENT = "AICodeClient/1.0.0";
31
+ export const LIVE_UPDATES_RETRY_MS = 3000;
32
+ export const TOAST_DURATION_MS = 4000;
33
+ export const TOAST_STATUS_TICK_MS = 100;
34
+ export const CONTEXT_LOOKUP_SUGGESTION_LIMIT = 6;
35
+ export const QUICK_START_PROVIDER_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
36
+ export const QUICK_START_ALIAS_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:\-\[\]]*$/;
37
+ export const QUICK_START_CONNECTION_CATEGORIES = [
38
+ {
39
+ value: "api",
40
+ label: "API Key",
41
+ description: "Test endpoint + model candidates with an API key env before saving."
42
+ },
43
+ {
44
+ value: "subscription",
45
+ label: "Subscription",
46
+ description: "Use an OAuth subscription login with ChatGPT or Claude models."
47
+ }
48
+ ];
49
+ export const MODEL_ALIAS_STRATEGY_OPTIONS = [
50
+ { value: "auto", label: "Auto" },
51
+ { value: "ordered", label: "Ordered" },
52
+ { value: "round-robin", label: "Round robin" },
53
+ { value: "weighted-rr", label: "Weighted RR" },
54
+ { value: "quota-aware-weighted-rr", label: "Quota-aware weighted RR" }
55
+ ];
56
+ export const MODEL_ALIAS_STRATEGY_LABELS = Object.fromEntries(MODEL_ALIAS_STRATEGY_OPTIONS.map((option) => [option.value, option.label]));
57
+ export const CODEX_THINKING_LEVEL_OPTIONS = Object.freeze([
58
+ { value: "minimal", label: "Minimal", hint: "Fastest supported reasoning" },
59
+ { value: "low", label: "Low", hint: "Lighter reasoning" },
60
+ { value: "medium", label: "Medium", hint: "Balanced depth" },
61
+ { value: "high", label: "High", hint: "Deeper reasoning" },
62
+ { value: "xhigh", label: "XHigh", hint: "Model-dependent extra depth" }
63
+ ]);
64
+ export const CLAUDE_THINKING_LEVEL_OPTIONS = Object.freeze([
65
+ { value: "low", label: "Low", hint: "Sets CLAUDE_CODE_EFFORT_LEVEL=low" },
66
+ { value: "medium", label: "Medium", hint: "Sets CLAUDE_CODE_EFFORT_LEVEL=medium" },
67
+ { value: "high", label: "High", hint: "Sets CLAUDE_CODE_EFFORT_LEVEL=high" },
68
+ { value: "xhigh", label: "XHigh", hint: "Sets CLAUDE_CODE_EFFORT_LEVEL=xhigh" },
69
+ { value: "max", label: "Max", hint: "Sets CLAUDE_CODE_EFFORT_LEVEL=max" }
70
+ ]);
71
+ export const FACTORY_DROID_REASONING_EFFORT_OPTIONS = Object.freeze([
72
+ { value: "off", label: "Off", hint: "Disable reasoning" },
73
+ { value: "none", label: "None", hint: "No extended reasoning" },
74
+ { value: "low", label: "Low", hint: "Lighter reasoning" },
75
+ { value: "medium", label: "Medium", hint: "Balanced depth" },
76
+ { value: "high", label: "High", hint: "Maximum reasoning depth" }
77
+ ]);
78
+ export const QUICK_START_WINDOW_OPTIONS = RATE_LIMIT_WINDOW_OPTIONS;
79
+ export const QUICK_START_DEFAULT_ENDPOINT_BY_PROTOCOL = {
80
+ openai: "https://api.openai.com/v1",
81
+ claude: "https://api.anthropic.com"
82
+ };
83
+
84
+ export const AMP_WEB_SEARCH_STRATEGY_OPTIONS = Object.freeze([
85
+ { value: "ordered", label: "Ordered" },
86
+ { value: "quota-balance", label: "Quota balance" }
87
+ ]);
88
+ export const AMP_WEB_SEARCH_PROVIDER_OPTIONS = Object.freeze([
89
+ Object.freeze({
90
+ id: "brave",
91
+ label: "Brave",
92
+ credentialField: "apiKey",
93
+ credentialLabel: "API key",
94
+ credentialPlaceholder: "brv_...",
95
+ defaultLimit: 1000
96
+ }),
97
+ Object.freeze({
98
+ id: "tavily",
99
+ label: "Tavily",
100
+ credentialField: "apiKey",
101
+ credentialLabel: "API key",
102
+ credentialPlaceholder: "tvly-...",
103
+ defaultLimit: 1000
104
+ }),
105
+ Object.freeze({
106
+ id: "exa",
107
+ label: "Exa",
108
+ credentialField: "apiKey",
109
+ credentialLabel: "API key",
110
+ credentialPlaceholder: "exa_...",
111
+ defaultLimit: 1000
112
+ }),
113
+ Object.freeze({
114
+ id: "searxng",
115
+ label: "SearXNG",
116
+ credentialField: "url",
117
+ credentialLabel: "Base URL",
118
+ credentialPlaceholder: "https://searx.example.com",
119
+ defaultLimit: 0
120
+ })
121
+ ]);
122
+ export const AMP_WEB_SEARCH_PROVIDER_META = Object.fromEntries(
123
+ AMP_WEB_SEARCH_PROVIDER_OPTIONS.map((provider) => [provider.id, provider])
124
+ );
125
+ export const AMP_WEB_SEARCH_DEFAULT_COUNT = 5;
126
+ export const AMP_WEB_SEARCH_MIN_COUNT = 1;
127
+ export const AMP_WEB_SEARCH_MAX_COUNT = 20;
128
+
129
+ export const ROW_REMOVE_BUTTON_CLASS = "w-[5.5rem] justify-self-end";
130
+ export const DRAGGING_ROW_CLASSES = ["border-primary/45", "bg-primary/5"];
131
+
132
+ export const OLLAMA_KEEP_ALIVE_OPTIONS = [
133
+ { value: "5m", label: "5 minutes" },
134
+ { value: "10m", label: "10 minutes" },
135
+ { value: "30m", label: "30 minutes" },
136
+ { value: "1h", label: "1 hour" },
137
+ { value: "24h", label: "24 hours" },
138
+ { value: "-1", label: "Forever (blocks eviction)" },
139
+ { value: "0", label: "Disabled (unload immediately)" }
140
+ ];
@@ -0,0 +1,262 @@
1
+ import { CONTEXT_LOOKUP_SUGGESTION_LIMIT } from "./constants.js";
2
+
3
+ export function formatContextWindow(value) {
4
+ const normalized = Number(value);
5
+ if (!Number.isFinite(normalized) || normalized <= 0) return "Unknown";
6
+ if (normalized >= 1000) {
7
+ const roundedK = normalized / 1000;
8
+ const rendered = Number.isInteger(roundedK) ? String(roundedK) : roundedK.toFixed(1).replace(/\.0$/, "");
9
+ return `${rendered}K`;
10
+ }
11
+ return String(normalized);
12
+ }
13
+
14
+ export function formatCompactContextWindowInput(value) {
15
+ const normalized = Number(value);
16
+ if (!Number.isFinite(normalized) || normalized <= 0) return "";
17
+
18
+ const units = [
19
+ { suffix: "M", divisor: 1000 * 1000 },
20
+ { suffix: "K", divisor: 1000 }
21
+ ];
22
+
23
+ for (const unit of units) {
24
+ if (normalized < unit.divisor) continue;
25
+ const scaledValue = normalized / unit.divisor;
26
+ const renderedValue = scaledValue >= 10
27
+ ? String(Math.round(scaledValue))
28
+ : scaledValue.toFixed(1).replace(/\.0$/, "");
29
+ return `${renderedValue}${unit.suffix}`;
30
+ }
31
+
32
+ return String(normalized);
33
+ }
34
+
35
+ export function formatEditableContextWindowInput(value) {
36
+ const normalizedValue = normalizeContextWindowInput(value);
37
+ const normalized = Number(normalizedValue);
38
+ if (!Number.isFinite(normalized) || normalized <= 0) return String(value || "");
39
+ return new Intl.NumberFormat().format(normalized);
40
+ }
41
+
42
+ export function buildProviderModelContextWindowMap(config = {}) {
43
+ const map = new Map();
44
+ for (const provider of (Array.isArray(config?.providers) ? config.providers : [])) {
45
+ const providerId = String(provider?.id || "").trim();
46
+ if (!providerId) continue;
47
+ for (const model of (Array.isArray(provider?.models) ? provider.models : [])) {
48
+ const modelId = String(model?.id || "").trim();
49
+ if (!modelId) continue;
50
+ map.set(`${providerId}/${modelId}`, {
51
+ ref: `${providerId}/${modelId}`,
52
+ providerId,
53
+ providerName: String(provider?.name || providerId).trim() || providerId,
54
+ modelId,
55
+ contextWindow: Number.isFinite(model?.contextWindow) ? Number(model.contextWindow) : null
56
+ });
57
+ }
58
+ }
59
+ return map;
60
+ }
61
+
62
+ export function buildAliasContextWindowSummary(aliasId = "", config = {}) {
63
+ const aliases = config?.modelAliases && typeof config.modelAliases === "object" && !Array.isArray(config.modelAliases)
64
+ ? config.modelAliases
65
+ : {};
66
+ const modelContextMap = buildProviderModelContextWindowMap(config);
67
+ const seenAliases = new Set();
68
+ const seenModelRefs = new Set();
69
+ const models = [];
70
+ const unknownRefs = [];
71
+
72
+ function visitRouteRef(ref) {
73
+ const normalizedRef = String(ref || "").trim();
74
+ if (!normalizedRef) return;
75
+
76
+ if (modelContextMap.has(normalizedRef)) {
77
+ if (seenModelRefs.has(normalizedRef)) return;
78
+ seenModelRefs.add(normalizedRef);
79
+ models.push(modelContextMap.get(normalizedRef));
80
+ return;
81
+ }
82
+
83
+ const normalizedAliasRef = normalizedRef.startsWith("alias:") ? normalizedRef.slice("alias:".length).trim() : normalizedRef;
84
+ if (!normalizedAliasRef || !Object.prototype.hasOwnProperty.call(aliases, normalizedAliasRef)) {
85
+ if (!unknownRefs.includes(normalizedRef)) unknownRefs.push(normalizedRef);
86
+ return;
87
+ }
88
+
89
+ if (seenAliases.has(normalizedAliasRef)) return;
90
+ seenAliases.add(normalizedAliasRef);
91
+ const nestedAlias = aliases[normalizedAliasRef];
92
+ for (const target of [...(nestedAlias?.targets || []), ...(nestedAlias?.fallbackTargets || [])]) {
93
+ visitRouteRef(target?.ref);
94
+ }
95
+ }
96
+
97
+ visitRouteRef(aliasId);
98
+
99
+ const knownModels = models.filter((model) => Number.isFinite(model?.contextWindow));
100
+ const uniqueWindows = [...new Set(knownModels.map((model) => model.contextWindow))].sort((left, right) => left - right);
101
+ const smallestContextWindow = uniqueWindows[0] ?? null;
102
+ const largestContextWindow = uniqueWindows[uniqueWindows.length - 1] ?? null;
103
+
104
+ return {
105
+ aliasId,
106
+ models,
107
+ unknownRefs,
108
+ smallestContextWindow,
109
+ largestContextWindow,
110
+ hasMixedContextWindows: uniqueWindows.length > 1
111
+ };
112
+ }
113
+
114
+ export function buildAliasGuideContextNotes(config = {}) {
115
+ const aliases = config?.modelAliases && typeof config.modelAliases === "object" && !Array.isArray(config.modelAliases)
116
+ ? config.modelAliases
117
+ : {};
118
+
119
+ return Object.keys(aliases)
120
+ .map((aliasId) => buildAliasContextWindowSummary(aliasId, config))
121
+ .filter((summary) => summary.hasMixedContextWindows)
122
+ .sort((left, right) => String(left.aliasId || "").localeCompare(String(right.aliasId || "")));
123
+ }
124
+
125
+ export function measureAliasSwitcherWidth(aliasLabel = "") {
126
+ const label = String(aliasLabel || "Select alias").trim() || "Select alias";
127
+ const fallbackWidth = Math.min(Math.max(Math.ceil(label.length * 8.5 + 62), 160), 520);
128
+
129
+ if (typeof document === "undefined") return fallbackWidth;
130
+
131
+ const canvas = measureAliasSwitcherWidth.canvas
132
+ || (measureAliasSwitcherWidth.canvas = document.createElement("canvas"));
133
+ const context = canvas.getContext("2d");
134
+ if (!context) return fallbackWidth;
135
+
136
+ context.font = '500 14px "Inter", "SF Pro Display", ui-sans-serif, system-ui, sans-serif';
137
+ return Math.min(Math.max(Math.ceil(context.measureText(label).width + 62), 160), 520);
138
+ }
139
+
140
+ export function buildLiteLlmContextSuggestionKey(result = {}) {
141
+ return `${String(result?.model || "").trim()}::${String(result?.contextWindow || "").trim()}`;
142
+ }
143
+
144
+ export function stripContextWindowFormatting(value) {
145
+ return String(value || "").trim().replace(/[.,\s_'`]/g, "");
146
+ }
147
+
148
+ export function normalizeContextWindowInput(value) {
149
+ const text = stripContextWindowFormatting(value);
150
+ if (!text) return "";
151
+ const parsed = Number.parseInt(text, 10);
152
+ return Number.isFinite(parsed) && parsed > 0 ? String(parsed) : text;
153
+ }
154
+
155
+ export function normalizeLiteLlmContextCandidate(candidate = {}) {
156
+ const model = String(candidate?.model || "").trim();
157
+ const provider = String(candidate?.provider || "").trim();
158
+ const mode = String(candidate?.mode || "").trim();
159
+ const contextWindow = Number(candidate?.contextWindow);
160
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return null;
161
+ return {
162
+ model,
163
+ provider,
164
+ mode,
165
+ contextWindow: Math.floor(contextWindow),
166
+ ...(candidate?.capabilities ? { capabilities: candidate.capabilities } : {})
167
+ };
168
+ }
169
+
170
+ export function simplifyLiteLlmContextLabel(model = "", provider = "") {
171
+ const normalizedModel = String(model || "").trim();
172
+ const normalizedProvider = String(provider || "").trim();
173
+ if (!normalizedModel || !normalizedProvider) return normalizedModel;
174
+
175
+ const lowercaseModel = normalizedModel.toLowerCase();
176
+ const lowercaseProvider = normalizedProvider.toLowerCase();
177
+ const knownPrefixes = [`${lowercaseProvider}/`, `${lowercaseProvider}:`];
178
+
179
+ for (const prefix of knownPrefixes) {
180
+ if (!lowercaseModel.startsWith(prefix)) continue;
181
+ const simplifiedLabel = normalizedModel.slice(prefix.length).trim();
182
+ return simplifiedLabel || normalizedModel;
183
+ }
184
+
185
+ return normalizedModel;
186
+ }
187
+
188
+ export function buildLiteLlmContextLookupState(result = {}, { fallbackQuery = "" } = {}) {
189
+ const query = String(result?.query || fallbackQuery || "").trim();
190
+ const exactMatch = normalizeLiteLlmContextCandidate(result?.exactMatch);
191
+ const suggestions = (Array.isArray(result?.suggestions) ? result.suggestions : [])
192
+ .map((candidate) => normalizeLiteLlmContextCandidate(candidate))
193
+ .filter(Boolean);
194
+ const medianContextWindow = Number(result?.medianContextWindow);
195
+ const normalizedMedianContextWindow = Number.isFinite(medianContextWindow) && medianContextWindow > 0
196
+ ? Math.floor(medianContextWindow)
197
+ : (exactMatch?.contextWindow || null);
198
+
199
+ const options = [];
200
+ const seenKeys = new Set();
201
+
202
+ function addOption(option) {
203
+ if (!option?.key || seenKeys.has(option.key)) return;
204
+ seenKeys.add(option.key);
205
+ options.push(option);
206
+ }
207
+
208
+ if (exactMatch) {
209
+ addOption({
210
+ key: `exact::${buildLiteLlmContextSuggestionKey(exactMatch)}`,
211
+ label: simplifyLiteLlmContextLabel(exactMatch.model, exactMatch.provider) || "Exact match",
212
+ detail: exactMatch.provider ? `Exact · ${exactMatch.provider}` : "Exact",
213
+ contextWindow: exactMatch.contextWindow
214
+ });
215
+ }
216
+
217
+ for (const suggestion of suggestions) {
218
+ addOption({
219
+ key: buildLiteLlmContextSuggestionKey(suggestion),
220
+ label: simplifyLiteLlmContextLabel(suggestion.model, suggestion.provider) || "Suggestion",
221
+ detail: suggestion.provider || "Known model",
222
+ contextWindow: suggestion.contextWindow
223
+ });
224
+ }
225
+
226
+ return {
227
+ query,
228
+ exactMatch,
229
+ suggestions,
230
+ medianContextWindow: normalizedMedianContextWindow,
231
+ options,
232
+ status: options.length > 0 ? "ready" : "miss"
233
+ };
234
+ }
235
+
236
+ export function resolveLiteLlmPrefillContextWindow(result = {}) {
237
+ const state = buildLiteLlmContextLookupState(result);
238
+ if (state.exactMatch?.contextWindow) return String(state.exactMatch.contextWindow);
239
+ if (state.medianContextWindow) return String(state.medianContextWindow);
240
+ return "";
241
+ }
242
+
243
+ export function buildLiteLlmContextLookupMap(results = []) {
244
+ const lookupMap = new Map();
245
+ for (const result of (Array.isArray(results) ? results : [])) {
246
+ const state = buildLiteLlmContextLookupState(result);
247
+ if (!state.query) continue;
248
+ lookupMap.set(state.query, state);
249
+ }
250
+ return lookupMap;
251
+ }
252
+
253
+ export function buildLiteLlmModelContextWindowMap(results = []) {
254
+ const next = {};
255
+ for (const result of (Array.isArray(results) ? results : [])) {
256
+ const query = String(result?.query || "").trim();
257
+ const prefill = resolveLiteLlmPrefillContextWindow(result);
258
+ if (!query || !prefill) continue;
259
+ next[query] = Number(prefill);
260
+ }
261
+ return next;
262
+ }
@@ -0,0 +1,65 @@
1
+ import { useLayoutEffect, useRef, useMemo } from "react";
2
+ import { captureScrollSettleSnapshot, isScrollSettleSnapshotStable } from "../utils.js";
3
+
4
+ export function useReorderLayoutAnimation(itemKeys = []) {
5
+ const itemRefs = useRef(new Map());
6
+ const refCallbacksRef = useRef(new Map());
7
+ const previousRectsRef = useRef(new Map());
8
+ const previousSignatureRef = useRef("");
9
+ const keySignature = JSON.stringify(itemKeys);
10
+
11
+ useLayoutEffect(() => {
12
+ const nextRects = new Map();
13
+ for (const itemKey of itemKeys) {
14
+ const node = itemRefs.current.get(itemKey);
15
+ if (node) nextRects.set(itemKey, node.getBoundingClientRect());
16
+ }
17
+
18
+ const prefersReducedMotion = typeof window !== "undefined"
19
+ && typeof window.matchMedia === "function"
20
+ && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
21
+ const shouldAnimate = previousSignatureRef.current !== "" && previousSignatureRef.current !== keySignature;
22
+
23
+ if (shouldAnimate && !prefersReducedMotion) {
24
+ for (const itemKey of itemKeys) {
25
+ const previousRect = previousRectsRef.current.get(itemKey);
26
+ const nextRect = nextRects.get(itemKey);
27
+ const node = itemRefs.current.get(itemKey);
28
+ if (!previousRect || !nextRect || !node) continue;
29
+ const deltaY = previousRect.top - nextRect.top;
30
+ if (Math.abs(deltaY) < 1 || typeof node.animate !== "function") continue;
31
+ node.animate(
32
+ [
33
+ { transform: `translateY(${deltaY}px)` },
34
+ { transform: "translateY(0)" }
35
+ ],
36
+ {
37
+ duration: 220,
38
+ easing: "cubic-bezier(0.22, 1, 0.36, 1)"
39
+ }
40
+ );
41
+ }
42
+ }
43
+
44
+ const activeKeys = new Set(itemKeys);
45
+ for (const itemKey of refCallbacksRef.current.keys()) {
46
+ if (!activeKeys.has(itemKey)) refCallbacksRef.current.delete(itemKey);
47
+ }
48
+
49
+ previousRectsRef.current = nextRects;
50
+ previousSignatureRef.current = keySignature;
51
+ }, [keySignature]);
52
+
53
+ return useMemo(() => (itemKey) => {
54
+ if (!refCallbacksRef.current.has(itemKey)) {
55
+ refCallbacksRef.current.set(itemKey, (node) => {
56
+ if (node) {
57
+ itemRefs.current.set(itemKey, node);
58
+ return;
59
+ }
60
+ itemRefs.current.delete(itemKey);
61
+ });
62
+ }
63
+ return refCallbacksRef.current.get(itemKey);
64
+ }, []);
65
+ }