@khanglvm/llm-router 2.0.0-beta.1 → 2.0.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +163 -426
  3. package/package.json +3 -3
  4. package/src/cli/router-module.js +2773 -2587
  5. package/src/cli-entry.js +32 -103
  6. package/src/node/activity-log.js +119 -0
  7. package/src/node/coding-tool-config.js +85 -11
  8. package/src/node/config-workflows.js +51 -12
  9. package/src/node/instance-state.js +1 -1
  10. package/src/node/litellm-context-catalog.js +184 -0
  11. package/src/node/local-server.js +23 -3
  12. package/src/node/port-reclaim.js +2 -2
  13. package/src/node/start-command.js +22 -22
  14. package/src/node/startup-manager.js +3 -3
  15. package/src/node/web-command.js +1 -1
  16. package/src/node/web-console-assets.js +1 -1
  17. package/src/node/web-console-client.js +34 -29
  18. package/src/node/web-console-server.js +420 -38
  19. package/src/node/web-console-styles.generated.js +1 -1
  20. package/src/node/web-console-ui/buffered-text-input.js +133 -0
  21. package/src/node/web-console-ui/config-editor-utils.js +57 -4
  22. package/src/node/web-console-ui/dropdown-placement.js +153 -0
  23. package/src/node/web-console-ui/select-search-utils.js +6 -0
  24. package/src/node/web-console-ui/transient-integer-input-utils.js +12 -0
  25. package/src/runtime/balancer.js +78 -1
  26. package/src/runtime/codex-request-transformer.js +16 -7
  27. package/src/runtime/config.js +448 -12
  28. package/src/runtime/handler/amp-response.js +5 -3
  29. package/src/runtime/handler/amp-web-search.js +2232 -0
  30. package/src/runtime/handler/fallback.js +30 -2
  31. package/src/runtime/handler/provider-call.js +353 -36
  32. package/src/runtime/handler/provider-translation.js +14 -0
  33. package/src/runtime/handler/request.js +128 -2
  34. package/src/runtime/handler/route-debug.js +36 -0
  35. package/src/runtime/handler.js +210 -20
  36. package/src/runtime/subscription-provider.js +1 -1
  37. package/src/shared/coding-tool-bindings.js +49 -0
  38. package/src/shared/local-router-defaults.js +62 -0
  39. package/src/translator/request/claude-to-openai.js +43 -0
@@ -0,0 +1,153 @@
1
+ import { useLayoutEffect, useState } from "react";
2
+
3
+ const DEFAULT_VIEWPORT_PADDING = 12;
4
+
5
+ function isScrollableOverflow(value) {
6
+ return /auto|scroll|hidden|clip|overlay/.test(String(value || "").toLowerCase());
7
+ }
8
+
9
+ function intersectRects(baseRect, nextRect) {
10
+ return {
11
+ top: Math.max(baseRect.top, nextRect.top),
12
+ right: Math.min(baseRect.right, nextRect.right),
13
+ bottom: Math.min(baseRect.bottom, nextRect.bottom),
14
+ left: Math.max(baseRect.left, nextRect.left)
15
+ };
16
+ }
17
+
18
+ export function getClippingAncestors(node) {
19
+ if (typeof window === "undefined") return [];
20
+
21
+ const ancestors = [];
22
+ let current = node?.parentElement || null;
23
+
24
+ while (current && current !== document.body && current !== document.documentElement) {
25
+ const style = window.getComputedStyle(current);
26
+ if (
27
+ isScrollableOverflow(style.overflow)
28
+ || isScrollableOverflow(style.overflowY)
29
+ || isScrollableOverflow(style.overflowX)
30
+ ) {
31
+ ancestors.push(current);
32
+ }
33
+ current = current.parentElement;
34
+ }
35
+
36
+ return ancestors;
37
+ }
38
+
39
+ export function getDropdownBoundaryRect(node, { viewportPadding = DEFAULT_VIEWPORT_PADDING } = {}) {
40
+ if (typeof window === "undefined") return null;
41
+
42
+ let boundaryRect = {
43
+ top: viewportPadding,
44
+ right: window.innerWidth - viewportPadding,
45
+ bottom: window.innerHeight - viewportPadding,
46
+ left: viewportPadding
47
+ };
48
+
49
+ for (const ancestor of getClippingAncestors(node)) {
50
+ boundaryRect = intersectRects(boundaryRect, ancestor.getBoundingClientRect());
51
+ }
52
+
53
+ return boundaryRect;
54
+ }
55
+
56
+ export function calculateDropdownPlacement({
57
+ anchorRect,
58
+ boundaryRect,
59
+ preferredSide = "bottom",
60
+ offset = 4,
61
+ desiredHeight = 288
62
+ } = {}) {
63
+ if (!anchorRect || !boundaryRect) {
64
+ return {
65
+ side: preferredSide === "top" ? "top" : "bottom",
66
+ maxHeight: desiredHeight
67
+ };
68
+ }
69
+
70
+ const spaceAbove = Math.max(0, anchorRect.top - boundaryRect.top - offset);
71
+ const spaceBelow = Math.max(0, boundaryRect.bottom - anchorRect.bottom - offset);
72
+ const resolvedPreferredSide = preferredSide === "top" ? "top" : "bottom";
73
+ const side = spaceAbove === spaceBelow
74
+ ? resolvedPreferredSide
75
+ : spaceAbove > spaceBelow
76
+ ? "top"
77
+ : "bottom";
78
+ const maxHeight = Math.max(
79
+ 0,
80
+ Math.min(
81
+ desiredHeight,
82
+ side === "top" ? spaceAbove : spaceBelow
83
+ )
84
+ );
85
+
86
+ return {
87
+ side,
88
+ maxHeight
89
+ };
90
+ }
91
+
92
+ export function useDropdownPlacement({
93
+ open = false,
94
+ anchorRef,
95
+ preferredSide = "bottom",
96
+ offset = 4,
97
+ desiredHeight = 288
98
+ } = {}) {
99
+ const [placement, setPlacement] = useState(() => ({
100
+ side: preferredSide === "top" ? "top" : "bottom",
101
+ maxHeight: desiredHeight
102
+ }));
103
+
104
+ useLayoutEffect(() => {
105
+ if (!open || typeof window === "undefined") return undefined;
106
+
107
+ const anchorNode = anchorRef?.current || null;
108
+ if (!anchorNode) {
109
+ setPlacement({
110
+ side: preferredSide === "top" ? "top" : "bottom",
111
+ maxHeight: desiredHeight
112
+ });
113
+ return undefined;
114
+ }
115
+
116
+ const updatePlacement = () => {
117
+ setPlacement(calculateDropdownPlacement({
118
+ anchorRect: anchorNode.getBoundingClientRect(),
119
+ boundaryRect: getDropdownBoundaryRect(anchorNode),
120
+ preferredSide,
121
+ offset,
122
+ desiredHeight
123
+ }));
124
+ };
125
+
126
+ updatePlacement();
127
+
128
+ const ancestors = getClippingAncestors(anchorNode);
129
+ window.addEventListener("resize", updatePlacement);
130
+ for (const ancestor of ancestors) {
131
+ ancestor.addEventListener("scroll", updatePlacement, { passive: true });
132
+ }
133
+
134
+ let resizeObserver = null;
135
+ if (typeof ResizeObserver === "function") {
136
+ resizeObserver = new ResizeObserver(updatePlacement);
137
+ resizeObserver.observe(anchorNode);
138
+ for (const ancestor of ancestors) {
139
+ resizeObserver.observe(ancestor);
140
+ }
141
+ }
142
+
143
+ return () => {
144
+ window.removeEventListener("resize", updatePlacement);
145
+ for (const ancestor of ancestors) {
146
+ ancestor.removeEventListener("scroll", updatePlacement);
147
+ }
148
+ resizeObserver?.disconnect?.();
149
+ };
150
+ }, [open, anchorRef, preferredSide, offset, desiredHeight]);
151
+
152
+ return placement;
153
+ }
@@ -6,6 +6,12 @@ export function hasSelectSearchQuery(value) {
6
6
  return normalizeSelectSearchText(value).length > 0;
7
7
  }
8
8
 
9
+ export function shouldShowSelectSearchInput({
10
+ searchEnabled = true,
11
+ } = {}) {
12
+ return Boolean(searchEnabled);
13
+ }
14
+
9
15
  export function getSelectSearchKey(event) {
10
16
  const key = String(event?.key || "");
11
17
  if (!key || key.length !== 1) return "";
@@ -0,0 +1,12 @@
1
+ const TRANSIENT_INTEGER_INPUT_PATTERN = /^\d*$/;
2
+
3
+ export function classifyTransientIntegerInput(rawValue) {
4
+ const nextValue = String(rawValue ?? "");
5
+ const accepted = TRANSIENT_INTEGER_INPUT_PATTERN.test(nextValue);
6
+ return {
7
+ accepted,
8
+ draftValue: accepted ? nextValue : "",
9
+ shouldCommit: accepted && nextValue !== "",
10
+ commitValue: accepted && nextValue !== "" ? nextValue : ""
11
+ };
12
+ }
@@ -55,6 +55,66 @@ function resolveCandidateWeight(candidate) {
55
55
  return Math.floor(parsed);
56
56
  }
57
57
 
58
+ function resolveCandidateContextWindow(candidate) {
59
+ const raw = candidate?.contextWindow ?? candidate?.model?.contextWindow;
60
+ const parsed = Number(raw);
61
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
62
+ return Math.floor(parsed);
63
+ }
64
+
65
+ function shouldApplyContextAwareOrdering(route, estimatedRequiredTokens) {
66
+ if (!Number.isFinite(estimatedRequiredTokens) || estimatedRequiredTokens <= 0) return false;
67
+ return Boolean(route?.routeStrategy || route?.routeType === "alias");
68
+ }
69
+
70
+ function partitionEligibleEntriesByContextWindow(eligibleEntries, estimatedRequiredTokens) {
71
+ if (!Array.isArray(eligibleEntries) || eligibleEntries.length <= 1) {
72
+ return {
73
+ prioritizedEntries: [...(eligibleEntries || [])],
74
+ deferredEntries: []
75
+ };
76
+ }
77
+
78
+ const fittingKnown = [];
79
+ const unknownWindow = [];
80
+ const tooSmallKnown = [];
81
+
82
+ for (const entry of eligibleEntries) {
83
+ const contextWindow = resolveCandidateContextWindow(entry?.candidate);
84
+ if (!contextWindow) {
85
+ unknownWindow.push(entry);
86
+ continue;
87
+ }
88
+
89
+ if (contextWindow >= estimatedRequiredTokens) {
90
+ fittingKnown.push(entry);
91
+ continue;
92
+ }
93
+
94
+ tooSmallKnown.push(entry);
95
+ }
96
+
97
+ const prioritizedEntries = [...fittingKnown, ...unknownWindow];
98
+ if (prioritizedEntries.length === 0) {
99
+ return {
100
+ prioritizedEntries: [...tooSmallKnown].sort((left, right) => {
101
+ const leftWindow = resolveCandidateContextWindow(left?.candidate) || 0;
102
+ const rightWindow = resolveCandidateContextWindow(right?.candidate) || 0;
103
+ if (rightWindow !== leftWindow) {
104
+ return rightWindow - leftWindow;
105
+ }
106
+ return sortEntriesByOriginalOrder(left, right);
107
+ }),
108
+ deferredEntries: []
109
+ };
110
+ }
111
+
112
+ return {
113
+ prioritizedEntries,
114
+ deferredEntries: tooSmallKnown
115
+ };
116
+ }
117
+
58
118
  function resolveHealthState(candidateState, now) {
59
119
  const openUntil = Math.max(
60
120
  normalizeNonNegativeInteger(candidateState?.openUntil),
@@ -229,6 +289,7 @@ export async function rankRouteCandidates({
229
289
  stateStore,
230
290
  config,
231
291
  rateLimitEvaluations,
292
+ requestContext,
232
293
  now = Date.now()
233
294
  }) {
234
295
  const normalizedStrategy = normalizeStrategyName(strategy || route?.routeStrategy || route?.strategy);
@@ -254,14 +315,30 @@ export async function rankRouteCandidates({
254
315
  const ineligibleEntries = entries
255
316
  .filter((entry) => !entry.eligible)
256
317
  .sort(sortEntriesByOriginalOrder);
318
+ const estimatedRequiredTokens = normalizeNonNegativeInteger(
319
+ requestContext?.estimatedRequiredTokens ??
320
+ requestContext?.requiredTokens ??
321
+ requestContext?.totalTokensEstimate
322
+ );
257
323
 
258
324
  const routeCursor = stateStore
259
325
  ? await stateStore.getRouteCursor(resolvedRouteKey)
260
326
  : 0;
261
- const ranking = rankEligibleEntries(normalizedStrategy, eligibleEntries, routeCursor);
327
+ const contextAwareGroups = shouldApplyContextAwareOrdering(route, estimatedRequiredTokens)
328
+ ? partitionEligibleEntriesByContextWindow(eligibleEntries, estimatedRequiredTokens)
329
+ : {
330
+ prioritizedEntries: eligibleEntries,
331
+ deferredEntries: []
332
+ };
333
+ const ranking = rankEligibleEntries(
334
+ normalizedStrategy,
335
+ contextAwareGroups.prioritizedEntries,
336
+ routeCursor
337
+ );
262
338
 
263
339
  const rankedEntries = [
264
340
  ...ranking.orderedEligible,
341
+ ...contextAwareGroups.deferredEntries,
265
342
  ...ineligibleEntries
266
343
  ];
267
344
 
@@ -193,10 +193,15 @@ function normalizeMessageRole(role) {
193
193
  return 'user';
194
194
  }
195
195
 
196
- function normalizeInputMessageContent(content) {
196
+ function getResponsesTextPartTypeForRole(role) {
197
+ return normalizeMessageRole(role) === 'assistant' ? 'output_text' : 'input_text';
198
+ }
199
+
200
+ function normalizeInputMessageContent(content, role) {
201
+ const textPartType = getResponsesTextPartTypeForRole(role);
197
202
  if (typeof content === 'string') {
198
203
  return content
199
- ? [{ type: 'input_text', text: content }]
204
+ ? [{ type: textPartType, text: content }]
200
205
  : [];
201
206
  }
202
207
 
@@ -208,7 +213,7 @@ function normalizeInputMessageContent(content) {
208
213
 
209
214
  if ((part.type === 'text' || part.type === 'input_text' || part.type === 'output_text') && typeof part.text === 'string') {
210
215
  parts.push({
211
- type: 'input_text',
216
+ type: textPartType,
212
217
  text: part.text
213
218
  });
214
219
  continue;
@@ -284,11 +289,12 @@ function convertMessagesToResponseInput(messages) {
284
289
  continue;
285
290
  }
286
291
 
287
- const contentParts = normalizeInputMessageContent(normalizedMessage.content);
292
+ const normalizedRole = normalizeMessageRole(normalizedMessage.role);
293
+ const contentParts = normalizeInputMessageContent(normalizedMessage.content, normalizedRole);
288
294
  if (contentParts.length > 0) {
289
295
  items.push({
290
296
  type: 'message',
291
- role: normalizeMessageRole(normalizedMessage.role),
297
+ role: normalizedRole,
292
298
  content: contentParts
293
299
  });
294
300
  } else {
@@ -296,8 +302,11 @@ function convertMessagesToResponseInput(messages) {
296
302
  if (fallbackText) {
297
303
  items.push({
298
304
  type: 'message',
299
- role: normalizeMessageRole(normalizedMessage.role),
300
- content: [{ type: 'input_text', text: fallbackText }]
305
+ role: normalizedRole,
306
+ content: [{
307
+ type: getResponsesTextPartTypeForRole(normalizedRole),
308
+ text: fallbackText
309
+ }]
301
310
  });
302
311
  }
303
312
  }