@juspay/neurolink 10.3.0 → 10.4.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 (127) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +430 -417
  3. package/dist/cli/loop/optionsSchema.js +4 -0
  4. package/dist/constants/contextWindows.d.ts +24 -0
  5. package/dist/constants/contextWindows.js +50 -0
  6. package/dist/context/errorDetection.d.ts +6 -0
  7. package/dist/context/errorDetection.js +18 -0
  8. package/dist/context/stepBudgetGuard.d.ts +17 -3
  9. package/dist/context/stepBudgetGuard.js +53 -14
  10. package/dist/core/baseProvider.d.ts +12 -0
  11. package/dist/core/baseProvider.js +18 -0
  12. package/dist/core/modules/GenerationHandler.js +208 -42
  13. package/dist/core/modules/ToolsManager.d.ts +17 -0
  14. package/dist/core/modules/ToolsManager.js +99 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +3 -0
  17. package/dist/knowledge/context.d.ts +18 -0
  18. package/dist/knowledge/context.js +91 -0
  19. package/dist/knowledge/defaults.d.ts +24 -0
  20. package/dist/knowledge/defaults.js +29 -0
  21. package/dist/knowledge/engine.d.ts +35 -0
  22. package/dist/knowledge/engine.js +179 -0
  23. package/dist/knowledge/index.d.ts +15 -0
  24. package/dist/knowledge/index.js +15 -0
  25. package/dist/knowledge/indexCache.d.ts +19 -0
  26. package/dist/knowledge/indexCache.js +109 -0
  27. package/dist/knowledge/knowledgeIndex.d.ts +41 -0
  28. package/dist/knowledge/knowledgeIndex.js +204 -0
  29. package/dist/knowledge/normalize.d.ts +32 -0
  30. package/dist/knowledge/normalize.js +74 -0
  31. package/dist/knowledge/resolve.d.ts +18 -0
  32. package/dist/knowledge/resolve.js +156 -0
  33. package/dist/knowledge/retrieval.d.ts +16 -0
  34. package/dist/knowledge/retrieval.js +221 -0
  35. package/dist/lib/constants/contextWindows.d.ts +24 -0
  36. package/dist/lib/constants/contextWindows.js +50 -0
  37. package/dist/lib/context/errorDetection.d.ts +6 -0
  38. package/dist/lib/context/errorDetection.js +18 -0
  39. package/dist/lib/context/stepBudgetGuard.d.ts +17 -3
  40. package/dist/lib/context/stepBudgetGuard.js +53 -14
  41. package/dist/lib/core/baseProvider.d.ts +12 -0
  42. package/dist/lib/core/baseProvider.js +18 -0
  43. package/dist/lib/core/modules/GenerationHandler.js +208 -42
  44. package/dist/lib/core/modules/ToolsManager.d.ts +17 -0
  45. package/dist/lib/core/modules/ToolsManager.js +99 -1
  46. package/dist/lib/files/fileTools.d.ts +1 -1
  47. package/dist/lib/index.d.ts +1 -0
  48. package/dist/lib/index.js +3 -0
  49. package/dist/lib/knowledge/context.d.ts +18 -0
  50. package/dist/lib/knowledge/context.js +92 -0
  51. package/dist/lib/knowledge/defaults.d.ts +24 -0
  52. package/dist/lib/knowledge/defaults.js +30 -0
  53. package/dist/lib/knowledge/engine.d.ts +35 -0
  54. package/dist/lib/knowledge/engine.js +180 -0
  55. package/dist/lib/knowledge/index.d.ts +15 -0
  56. package/dist/lib/knowledge/index.js +16 -0
  57. package/dist/lib/knowledge/indexCache.d.ts +19 -0
  58. package/dist/lib/knowledge/indexCache.js +110 -0
  59. package/dist/lib/knowledge/knowledgeIndex.d.ts +41 -0
  60. package/dist/lib/knowledge/knowledgeIndex.js +205 -0
  61. package/dist/lib/knowledge/normalize.d.ts +32 -0
  62. package/dist/lib/knowledge/normalize.js +75 -0
  63. package/dist/lib/knowledge/resolve.d.ts +18 -0
  64. package/dist/lib/knowledge/resolve.js +157 -0
  65. package/dist/lib/knowledge/retrieval.d.ts +16 -0
  66. package/dist/lib/knowledge/retrieval.js +222 -0
  67. package/dist/lib/mcp/toolDiscoveryService.js +59 -20
  68. package/dist/lib/neurolink.d.ts +14 -1
  69. package/dist/lib/neurolink.js +142 -12
  70. package/dist/lib/providers/litellm.d.ts +27 -19
  71. package/dist/lib/providers/litellm.js +171 -92
  72. package/dist/lib/providers/openaiChatCompletionsBase.d.ts +37 -1
  73. package/dist/lib/providers/openaiChatCompletionsBase.js +201 -33
  74. package/dist/lib/providers/openaiChatCompletionsClient.d.ts +23 -5
  75. package/dist/lib/providers/openaiChatCompletionsClient.js +94 -14
  76. package/dist/lib/proxy/proxyFetch.d.ts +17 -0
  77. package/dist/lib/proxy/proxyFetch.js +42 -4
  78. package/dist/lib/types/config.d.ts +12 -0
  79. package/dist/lib/types/conversation.d.ts +1 -1
  80. package/dist/lib/types/dynamic.d.ts +12 -0
  81. package/dist/lib/types/generate.d.ts +32 -2
  82. package/dist/lib/types/index.d.ts +1 -0
  83. package/dist/lib/types/index.js +1 -0
  84. package/dist/lib/types/knowledge.d.ts +342 -0
  85. package/dist/lib/types/knowledge.js +30 -0
  86. package/dist/lib/types/openaiCompatible.d.ts +2 -0
  87. package/dist/lib/types/providers.d.ts +9 -0
  88. package/dist/lib/types/stream.d.ts +14 -0
  89. package/dist/lib/utils/errorHandling.js +8 -2
  90. package/dist/lib/utils/schemaConversion.d.ts +16 -0
  91. package/dist/lib/utils/schemaConversion.js +165 -8
  92. package/dist/lib/utils/timeout.d.ts +22 -0
  93. package/dist/lib/utils/timeout.js +72 -12
  94. package/dist/lib/utils/tokenLimits.js +22 -0
  95. package/dist/lib/utils/toolCallRepair.d.ts +8 -0
  96. package/dist/lib/utils/toolCallRepair.js +4 -1
  97. package/dist/mcp/toolDiscoveryService.js +59 -20
  98. package/dist/neurolink.d.ts +14 -1
  99. package/dist/neurolink.js +142 -12
  100. package/dist/providers/litellm.d.ts +27 -19
  101. package/dist/providers/litellm.js +171 -92
  102. package/dist/providers/openaiChatCompletionsBase.d.ts +37 -1
  103. package/dist/providers/openaiChatCompletionsBase.js +201 -33
  104. package/dist/providers/openaiChatCompletionsClient.d.ts +23 -5
  105. package/dist/providers/openaiChatCompletionsClient.js +94 -14
  106. package/dist/proxy/proxyFetch.d.ts +17 -0
  107. package/dist/proxy/proxyFetch.js +42 -4
  108. package/dist/types/config.d.ts +12 -0
  109. package/dist/types/conversation.d.ts +1 -1
  110. package/dist/types/dynamic.d.ts +12 -0
  111. package/dist/types/generate.d.ts +32 -2
  112. package/dist/types/index.d.ts +1 -0
  113. package/dist/types/index.js +1 -0
  114. package/dist/types/knowledge.d.ts +342 -0
  115. package/dist/types/knowledge.js +29 -0
  116. package/dist/types/openaiCompatible.d.ts +2 -0
  117. package/dist/types/providers.d.ts +9 -0
  118. package/dist/types/stream.d.ts +14 -0
  119. package/dist/utils/errorHandling.js +8 -2
  120. package/dist/utils/schemaConversion.d.ts +16 -0
  121. package/dist/utils/schemaConversion.js +165 -8
  122. package/dist/utils/timeout.d.ts +22 -0
  123. package/dist/utils/timeout.js +72 -12
  124. package/dist/utils/tokenLimits.js +22 -0
  125. package/dist/utils/toolCallRepair.d.ts +8 -0
  126. package/dist/utils/toolCallRepair.js +4 -1
  127. package/package.json +4 -1
package/dist/neurolink.js CHANGED
@@ -32,6 +32,7 @@ import { SYSTEM_LIMITS, DEFAULT_TOOL_ROUTING_TIMEOUT_MS, MIN_RECOVERY_TURN_BUDGE
32
32
  import { ConversationMemoryManager } from "./core/conversationMemoryManager.js";
33
33
  import { buildToolRoutingCatalog, buildRoutingQueryFromHistory, resolveToolRoutingExclusions, } from "./core/toolRouting.js";
34
34
  import { ToolRoutingCache } from "./core/toolRoutingCache.js";
35
+ import { DEFAULT_RECENT_TURNS, KnowledgeGroundingEngine, } from "./knowledge/index.js";
35
36
  import { AIProviderFactory } from "./core/factory.js";
36
37
  import { createToolEventPayload } from "./core/toolEvents.js";
37
38
  import { ProviderRegistry } from "./factories/providerRegistry.js";
@@ -405,6 +406,9 @@ export class NeuroLink {
405
406
  // vectors are computed once. Cleared when the catalog changes via
406
407
  // setToolRoutingServers() so stale vectors are never reused.
407
408
  toolRoutingVectorCache;
409
+ // Knowledge grounding: lexical-first host-supplied retrieval engine. Built
410
+ // once from constructor config and undefined unless grounding is enabled.
411
+ knowledgeGroundingEngine;
408
412
  // Opt-in tool-signature deduplication config.
409
413
  toolDedupConfig;
410
414
  toolsConfig;
@@ -837,6 +841,18 @@ export class NeuroLink {
837
841
  // multiple NeuroLink instances.
838
842
  this.toolRoutingConfig = { ...config.toolRouting };
839
843
  }
844
+ const knowledgeGroundingConfig = config?.knowledgeGrounding;
845
+ if (knowledgeGroundingConfig?.enabled) {
846
+ if (Array.isArray(knowledgeGroundingConfig.sources) &&
847
+ knowledgeGroundingConfig.sources.length > 0) {
848
+ // The engine builds its immutable index asynchronously; eligible
849
+ // generate() and stream() calls await readiness before retrieval.
850
+ this.knowledgeGroundingEngine = new KnowledgeGroundingEngine(knowledgeGroundingConfig);
851
+ }
852
+ else {
853
+ logger.warn("[KnowledgeGrounding] enabled but no sources were provided; grounding disabled for this instance");
854
+ }
855
+ }
840
856
  if (config?.toolDedup) {
841
857
  this.toolDedupConfig = { ...config.toolDedup };
842
858
  }
@@ -853,7 +869,9 @@ export class NeuroLink {
853
869
  // generate() (marked so it never recursively re-routes). Fails open.
854
870
  this.classifierRouter = config?.classifierRouter?.enabled
855
871
  ? new ClassifierRouter(config.classifierRouter, {
856
- generate: (genOptions) => this.generate(genOptions),
872
+ generate: (genOptions) => this.generate({
873
+ ...genOptions,
874
+ }),
857
875
  logger: {
858
876
  debug: (message, meta) => logger.debug(message, meta),
859
877
  warn: (message, meta) => logger.warn(message, meta),
@@ -3045,6 +3063,7 @@ Current user's request: ${currentInput}`;
3045
3063
  * @param optionsOrPrompt.maxTokens - Maximum tokens to generate
3046
3064
  * @param optionsOrPrompt.thinkingConfig - Extended thinking configuration (thinkingLevel: 'minimal'|'low'|'medium'|'high')
3047
3065
  * @param optionsOrPrompt.context - Context with conversationId and userId for memory
3066
+ * @param optionsOrPrompt.useKnowledgeGrounding - Whether to use the instance's configured knowledge for this call
3048
3067
  * @returns Promise resolving to generation result with content and metadata
3049
3068
  *
3050
3069
  * @example Basic text generation
@@ -3131,9 +3150,22 @@ Current user's request: ${currentInput}`;
3131
3150
  if (typeof optionsOrPrompt !== "string") {
3132
3151
  optionsOrPrompt = cloneOptionsForCallIsolation(optionsOrPrompt);
3133
3152
  }
3153
+ // Retrieve once at the public call boundary so fallback attempts reuse the
3154
+ // same grounding block and internal preparation cannot inject it twice.
3155
+ const groundingOptions = typeof optionsOrPrompt === "string"
3156
+ ? { input: { text: optionsOrPrompt } }
3157
+ : optionsOrPrompt;
3158
+ const knowledgeOutcome = await this.retrieveKnowledgeGrounding(groundingOptions);
3159
+ if (knowledgeOutcome?.ephemeralContext) {
3160
+ const block = knowledgeOutcome.ephemeralContext.content;
3161
+ optionsOrPrompt = {
3162
+ ...groundingOptions,
3163
+ systemPrompt: this.appendKnowledgeGroundingBlockToSystemPrompt(groundingOptions.systemPrompt, block),
3164
+ };
3165
+ }
3134
3166
  const startedAt = Date.now();
3135
3167
  try {
3136
- return await this.runWithFallbackOrchestration(optionsOrPrompt, "generate", (opts) => {
3168
+ const result = await this.runWithFallbackOrchestration(optionsOrPrompt, "generate", (opts) => {
3137
3169
  // Capture root-ness before startActiveSpan makes generateSpan active.
3138
3170
  // The actual guest-rescue stamp is deferred to executeGenerateRequest,
3139
3171
  // AFTER prepareGenerateRequest merges auth/requestContext-derived
@@ -3142,6 +3174,10 @@ Current user's request: ${currentInput}`;
3142
3174
  const generateIsRoot = !trace.getSpan(context.active());
3143
3175
  return tracers.sdk.startActiveSpan("neurolink.generate", { kind: SpanKind.INTERNAL }, (generateSpan) => this.executeGenerateWithMetricsContext(opts, generateSpan, generateIsRoot));
3144
3176
  });
3177
+ if (knowledgeOutcome) {
3178
+ result.knowledge = knowledgeOutcome.metadata;
3179
+ }
3180
+ return result;
3145
3181
  }
3146
3182
  catch (error) {
3147
3183
  // Lifecycle middleware (wrapGenerate.catch in builtin/lifecycle.ts)
@@ -5190,12 +5226,22 @@ Current user's request: ${currentInput}`;
5190
5226
  if (!generationContext) {
5191
5227
  return null;
5192
5228
  }
5229
+ // Provider construction runs BEFORE the budget check so runtime
5230
+ // model-limit discovery (ensureModelLimits) can register real context
5231
+ // windows first — ensureMCPGenerationBudget otherwise computes against
5232
+ // the static default window (see directProviderGeneration for the
5233
+ // lock-out this ordering prevents).
5234
+ const provider = await AIProviderFactory.createProvider(generationContext.providerName, options.model, !options.disableTools, this, options.region, this.resolveCredentials(options.credentials));
5235
+ provider.setTraceContext(this._metricsTraceContext);
5236
+ // Never rejects — discovery failure degrades to static defaults.
5237
+ await provider.ensureModelLimits?.();
5193
5238
  const conversationMessages = await this.ensureMCPGenerationBudget(options, requestId, generationContext.providerName, generationContext.enhancedSystemPrompt, generationContext.availableTools, generationContext.conversationMessages);
5194
5239
  return this.generateWithMCPProvider({
5195
5240
  options,
5196
5241
  requestId,
5197
5242
  functionTag,
5198
5243
  tryMCPStartTime,
5244
+ provider,
5199
5245
  providerName: generationContext.providerName,
5200
5246
  availableTools: generationContext.availableTools,
5201
5247
  enhancedSystemPrompt: generationContext.enhancedSystemPrompt,
@@ -5479,9 +5525,7 @@ Current user's request: ${currentInput}`;
5479
5525
  return compactedMessages;
5480
5526
  }
5481
5527
  async generateWithMCPProvider(context) {
5482
- const { options, requestId, functionTag, tryMCPStartTime, providerName, availableTools, enhancedSystemPrompt, conversationMessages, } = context;
5483
- const provider = await AIProviderFactory.createProvider(providerName, options.model, !options.disableTools, this, options.region, this.resolveCredentials(options.credentials));
5484
- provider.setTraceContext(this._metricsTraceContext);
5528
+ const { options, requestId, functionTag, tryMCPStartTime, provider, providerName, availableTools, enhancedSystemPrompt, conversationMessages, } = context;
5485
5529
  this.emitter.emit("connected");
5486
5530
  this.emitter.emit("message", `${providerName} provider initialized successfully`);
5487
5531
  provider.setupToolExecutor({
@@ -5760,6 +5804,21 @@ Current user's request: ${currentInput}`;
5760
5804
  ?.length
5761
5805
  ? optionsWithMessages.conversationMessages
5762
5806
  : await getConversationMessages(this.conversationMemory, options);
5807
+ // Provider construction runs BEFORE the budget check so runtime
5808
+ // model-limit discovery (ensureModelLimits) can register real
5809
+ // context windows first. The previous order created a lock-out:
5810
+ // checkContextBudget ran against the static default window, its
5811
+ // pre-dispatch hard cap threw before the provider (whose
5812
+ // constructor owns the discovery) ever existed — so a blocked call
5813
+ // prevented the very discovery that would have unblocked it.
5814
+ const provider = await AIProviderFactory.createProvider(providerName, options.model, !options.disableTools, // Pass disableTools as inverse of enableMCP
5815
+ this, // Pass SDK instance
5816
+ options.region, // Pass region parameter
5817
+ this.resolveCredentials(options.credentials));
5818
+ // Propagate trace context for parent-child span hierarchy
5819
+ provider.setTraceContext(this._metricsTraceContext);
5820
+ // Never rejects — discovery failure degrades to static defaults.
5821
+ await provider.ensureModelLimits?.();
5763
5822
  // Pre-generation budget check
5764
5823
  const budgetCheck = checkContextBudget({
5765
5824
  provider: providerName,
@@ -5915,12 +5974,6 @@ Current user's request: ${currentInput}`;
5915
5974
  }
5916
5975
  }
5917
5976
  }
5918
- const provider = await AIProviderFactory.createProvider(providerName, options.model, !options.disableTools, // Pass disableTools as inverse of enableMCP
5919
- this, // Pass SDK instance
5920
- options.region, // Pass region parameter
5921
- this.resolveCredentials(options.credentials));
5922
- // Propagate trace context for parent-child span hierarchy
5923
- provider.setTraceContext(this._metricsTraceContext);
5924
5977
  // ADD: Emit connection events for successful provider creation (Bedrock-compatible)
5925
5978
  this.emitter.emit("connected");
5926
5979
  this.emitter.emit("message", `${providerName} provider initialized successfully`);
@@ -6187,6 +6240,7 @@ Current user's request: ${currentInput}`;
6187
6240
  * @param options.enableEvaluation - Whether to include response quality evaluation
6188
6241
  * @param options.context - Additional context for the request
6189
6242
  * @param options.evaluationDomain - Domain for specialized evaluation
6243
+ * @param options.useKnowledgeGrounding - Whether to use the instance's configured knowledge for this call
6190
6244
  *
6191
6245
  * @returns Promise resolving to StreamResult with an async iterable stream
6192
6246
  *
@@ -6244,8 +6298,23 @@ Current user's request: ${currentInput}`;
6244
6298
  // top-level keys and left `options.input` shared with the caller.
6245
6299
  options = cloneOptionsForCallIsolation(options);
6246
6300
  const startedAt = Date.now();
6301
+ // Retrieve once before provider/model fallback orchestration. Every fallback
6302
+ // receives the same explicitly enriched options, without the retrieval helper
6303
+ // mutating its input or injecting the same block more than once.
6304
+ const knowledgeOutcome = await this.retrieveKnowledgeGrounding(options);
6305
+ if (knowledgeOutcome?.ephemeralContext) {
6306
+ const block = knowledgeOutcome.ephemeralContext.content;
6307
+ options = {
6308
+ ...options,
6309
+ systemPrompt: this.appendKnowledgeGroundingBlockToSystemPrompt(options.systemPrompt, block),
6310
+ };
6311
+ }
6247
6312
  try {
6248
- return await this.streamWithIterationFallback(options);
6313
+ const result = await this.streamWithIterationFallback(options);
6314
+ if (knowledgeOutcome) {
6315
+ result.knowledge = knowledgeOutcome.metadata;
6316
+ }
6317
+ return result;
6249
6318
  }
6250
6319
  catch (error) {
6251
6320
  // Mirror generate(): fire consumer onError for failures that
@@ -6913,6 +6982,63 @@ Current user's request: ${currentInput}`;
6913
6982
  // to recompute exclusions against the new server/tool set.
6914
6983
  this.toolRoutingCacheInstance = undefined;
6915
6984
  }
6985
+ /** Knowledge-grounding engine health (null when it was not configured). */
6986
+ getKnowledgeStatus() {
6987
+ return this.knowledgeGroundingEngine?.getStatus() ?? null;
6988
+ }
6989
+ appendKnowledgeGroundingBlockToSystemPrompt(systemPrompt, block) {
6990
+ if (typeof systemPrompt === "function") {
6991
+ const originalSystemPrompt = systemPrompt;
6992
+ return async (context) => {
6993
+ const resolved = await resolveDynamicArgument(originalSystemPrompt, context);
6994
+ return resolved.value ? `${resolved.value}\n\n${block}` : block;
6995
+ };
6996
+ }
6997
+ return systemPrompt ? `${systemPrompt}\n\n${block}` : block;
6998
+ }
6999
+ /**
7000
+ * Retrieve knowledge grounding for a public generate/stream call without
7001
+ * mutating its options. The outer call boundary decides how to apply the
7002
+ * returned context. Returns undefined when the call does not opt in,
7003
+ * grounding is disabled/not applicable, or retrieval fails open.
7004
+ */
7005
+ async retrieveKnowledgeGrounding(options) {
7006
+ const engine = this.knowledgeGroundingEngine;
7007
+ if (!engine ||
7008
+ !engine.isEnabled() ||
7009
+ options.useKnowledgeGrounding !== true) {
7010
+ return undefined;
7011
+ }
7012
+ const query = options.input?.text;
7013
+ if (!query) {
7014
+ return undefined;
7015
+ }
7016
+ try {
7017
+ const conversationMessages = options.conversationMessages !== undefined
7018
+ ? options.conversationMessages
7019
+ : await this.fetchRecentRoutingHistory(options);
7020
+ const recentTurns = conversationMessages
7021
+ .filter((message) => message.role === "user" || message.role === "assistant")
7022
+ .slice(-DEFAULT_RECENT_TURNS)
7023
+ .map((message) => ({
7024
+ role: message.role === "assistant"
7025
+ ? "assistant"
7026
+ : "user",
7027
+ text: typeof message.content === "string" ? message.content : "",
7028
+ }));
7029
+ return await engine.ground({
7030
+ query,
7031
+ recentTurns,
7032
+ scope: options.knowledgeContext,
7033
+ });
7034
+ }
7035
+ catch (error) {
7036
+ logger.warn("[KnowledgeGrounding] grounding hook failed open", {
7037
+ error: String(error),
7038
+ });
7039
+ return undefined;
7040
+ }
7041
+ }
6916
7042
  async validateStreamRequestOptions(options, startTime) {
6917
7043
  await this.validateStreamInput(options);
6918
7044
  // Input validation for stream
@@ -7892,6 +8018,10 @@ Current user's request: ${currentInput}`;
7892
8018
  this, // Pass SDK instance
7893
8019
  options.region, // Pass region parameter
7894
8020
  this.resolveCredentials(options.credentials));
8021
+ // Runtime model-limit discovery must land BEFORE the stream budget
8022
+ // check below — otherwise it computes against the static default
8023
+ // window. Never rejects; failure degrades to static defaults.
8024
+ await provider.ensureModelLimits?.();
7895
8025
  // Propagate trace context for parent-child span hierarchy
7896
8026
  provider.setTraceContext(this._metricsTraceContext);
7897
8027
  // Enable tool execution for the provider using BaseProvider method
@@ -1,6 +1,26 @@
1
1
  import type { AIProviderName } from "../constants/enums.js";
2
2
  import type { OpenAICompatBuildBodyArgs, OpenAICompatStreamLifecycleListeners } from "../types/index.js";
3
3
  import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
4
+ /** Test hook: reset the discovery caches (state is module-global). */
5
+ export declare function clearLiteLLMModelLimitsCache(): void;
6
+ /**
7
+ * Discover real per-model limits from the LiteLLM proxy's `GET /model/info`
8
+ * and register them with the runtime resolvers: `max_input_tokens` → context
9
+ * window, `max_output_tokens` → output ceiling. The static table only has a
10
+ * one-size litellm `_default` (128K) while proxied models range from 8K to
11
+ * 2M — budget checks, compaction, and max_tokens clamping need the real
12
+ * numbers.
13
+ *
14
+ * Awaitable and deduped per base URL: generation pipelines await this (via
15
+ * `LiteLLMProvider.ensureModelLimits`) BEFORE any budget math, so even the
16
+ * FIRST call in a fresh process budgets against real windows — the previous
17
+ * constructor-scoped fire-and-forget lost that race every time, and a
18
+ * budget-blocked call then prevented the discovery that would have unblocked
19
+ * it. Never rejects: any failure (endpoint absent, auth, timeout) is logged
20
+ * at debug, leaves the static defaults in force, and allows a retry on the
21
+ * next call.
22
+ */
23
+ export declare function ensureLiteLLMModelLimits(baseURL: string, apiKey: string): Promise<void>;
4
24
  /**
5
25
  * LiteLLM Provider — direct HTTP, no AI SDK. Talks to a LiteLLM proxy
6
26
  * server (or any deployment that speaks OpenAI chat-completions + the
@@ -17,16 +37,17 @@ export declare class LiteLLMProvider extends OpenAIChatCompletionsProvider {
17
37
  private static modelsCache;
18
38
  private static modelsCacheTime;
19
39
  private static readonly MODELS_CACHE_DURATION;
20
- /**
21
- * Dedupes the fire-and-forget `/model/info` discovery per base URL, so a
22
- * process constructing many LiteLLM providers fetches each proxy's limits
23
- * once per cache period.
24
- */
25
- private static modelInfoFetchTime;
26
40
  constructor(modelName?: string, sdk?: unknown, _region?: string, credentials?: {
27
41
  apiKey?: string;
28
42
  baseURL?: string;
29
43
  });
44
+ /**
45
+ * Awaitable model-limit discovery — generation pipelines call this before
46
+ * budget checks so window/output-ceiling math uses the proxy's real
47
+ * numbers (see BaseProvider.ensureModelLimits). Deduped and cached per
48
+ * base URL; never rejects (failure degrades to static defaults).
49
+ */
50
+ ensureModelLimits(): Promise<void>;
30
51
  protected getProviderName(): AIProviderName;
31
52
  protected getDefaultModel(): string;
32
53
  protected getFallbackModelName(): string;
@@ -50,19 +71,6 @@ export declare class LiteLLMProvider extends OpenAIChatCompletionsProvider {
50
71
  * minimal safe default if the API fetch fails.
51
72
  */
52
73
  getAvailableModels(): Promise<string[]>;
53
- /**
54
- * Discover real per-model context windows from the LiteLLM proxy's
55
- * `GET /model/info` (`data[].model_info.max_input_tokens`) and register
56
- * them with the context-window resolver. Fire-and-forget with the same
57
- * cache period as the models list; any failure (endpoint absent, auth,
58
- * timeout) is logged at debug and leaves the static `_default` in force.
59
- */
60
- private discoverModelContextWindows;
61
- /**
62
- * Fetch `GET /model/info` and map model group name → max_input_tokens.
63
- * Same fetch/auth/abort scaffolding as {@link fetchModelsFromAPI}.
64
- */
65
- private fetchModelInfoFromAPI;
66
74
  private fetchModelsFromAPI;
67
75
  /**
68
76
  * Generate an embedding for a single text input via native /v1/embeddings.
@@ -1,5 +1,5 @@
1
1
  import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
2
- import { registerRuntimeContextWindow } from "../constants/contextWindows.js";
2
+ import { registerRuntimeContextWindow, registerRuntimeOutputCeiling, } from "../constants/contextWindows.js";
3
3
  import { createProxyFetch } from "../proxy/proxyFetch.js";
4
4
  import { AuthenticationError, InvalidModelError, ModelAccessDeniedError, NetworkError, ProviderError, RateLimitError, isModelAccessDeniedMessage, parseAllowedModels, } from "../types/index.js";
5
5
  import { isAbortError } from "../utils/errorHandling.js";
@@ -21,6 +21,163 @@ const getLiteLLMConfig = () => ({
21
21
  * LiteLLM uses a 'provider/model' format. Override via LITELLM_MODEL env var.
22
22
  */
23
23
  const getDefaultLiteLLMModel = () => getProviderModel("LITELLM_MODEL", FALLBACK_LITELLM_MODEL);
24
+ /** Cache period for `/model/info` limit discovery (same as the models list). */
25
+ const MODEL_INFO_CACHE_DURATION = 10 * 60 * 1000;
26
+ /** In-flight `/model/info` discovery per base URL — dedupes concurrent callers. */
27
+ const modelInfoInFlight = new Map();
28
+ /** Last successful `/model/info` discovery per base URL. */
29
+ const modelInfoLastSuccess = new Map();
30
+ /**
31
+ * Discovered limits per base URL. The runtime registries key by
32
+ * (provider, model) only — budget call sites carry no deployment identity —
33
+ * so when a process talks to MULTIPLE proxies that share a model-group name,
34
+ * registrations use the MIN across every discovered deployment: budgets stay
35
+ * safe for all of them instead of last-writer-wins silently overstating one.
36
+ * Single-proxy processes (the normal case) are unaffected.
37
+ */
38
+ const modelInfoLimitsByURL = new Map();
39
+ /** Test hook: reset the discovery caches (state is module-global). */
40
+ export function clearLiteLLMModelLimitsCache() {
41
+ modelInfoInFlight.clear();
42
+ modelInfoLastSuccess.clear();
43
+ modelInfoLimitsByURL.clear();
44
+ }
45
+ /**
46
+ * Fetch `GET /model/info` and map model group name → advertised limits
47
+ * (`model_info.max_input_tokens` / `model_info.max_output_tokens`).
48
+ * A model group can appear once per underlying deployment; the SMALLEST
49
+ * advertised value wins per field so budgets are safe for every replica.
50
+ */
51
+ async function fetchLiteLLMModelLimits(baseURL, apiKey) {
52
+ const infoUrl = `${baseURL}/model/info`;
53
+ const proxyFetch = createProxyFetch();
54
+ const controller = new AbortController();
55
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
56
+ try {
57
+ const response = await proxyFetch(infoUrl, {
58
+ method: "GET",
59
+ headers: {
60
+ Authorization: `Bearer ${apiKey}`,
61
+ "Content-Type": "application/json",
62
+ },
63
+ signal: controller.signal,
64
+ });
65
+ if (!response.ok) {
66
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
67
+ }
68
+ const data = (await response.json());
69
+ const isUsable = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
70
+ const limits = new Map();
71
+ for (const entry of data.data ?? []) {
72
+ const model = entry?.model_name;
73
+ if (typeof model !== "string" || model.length === 0) {
74
+ continue;
75
+ }
76
+ const maxInput = entry?.model_info?.max_input_tokens;
77
+ const maxOutput = entry?.model_info?.max_output_tokens;
78
+ if (!isUsable(maxInput) && !isUsable(maxOutput)) {
79
+ continue;
80
+ }
81
+ const existing = limits.get(model) ?? {};
82
+ if (isUsable(maxInput)) {
83
+ existing.maxInputTokens =
84
+ existing.maxInputTokens === undefined
85
+ ? maxInput
86
+ : Math.min(existing.maxInputTokens, maxInput);
87
+ }
88
+ if (isUsable(maxOutput)) {
89
+ existing.maxOutputTokens =
90
+ existing.maxOutputTokens === undefined
91
+ ? maxOutput
92
+ : Math.min(existing.maxOutputTokens, maxOutput);
93
+ }
94
+ limits.set(model, existing);
95
+ }
96
+ return limits;
97
+ }
98
+ catch (error) {
99
+ if (isAbortError(error)) {
100
+ throw new NetworkError("Request timed out after 5 seconds", "litellm");
101
+ }
102
+ throw error;
103
+ }
104
+ finally {
105
+ clearTimeout(timeoutId);
106
+ }
107
+ }
108
+ /**
109
+ * Discover real per-model limits from the LiteLLM proxy's `GET /model/info`
110
+ * and register them with the runtime resolvers: `max_input_tokens` → context
111
+ * window, `max_output_tokens` → output ceiling. The static table only has a
112
+ * one-size litellm `_default` (128K) while proxied models range from 8K to
113
+ * 2M — budget checks, compaction, and max_tokens clamping need the real
114
+ * numbers.
115
+ *
116
+ * Awaitable and deduped per base URL: generation pipelines await this (via
117
+ * `LiteLLMProvider.ensureModelLimits`) BEFORE any budget math, so even the
118
+ * FIRST call in a fresh process budgets against real windows — the previous
119
+ * constructor-scoped fire-and-forget lost that race every time, and a
120
+ * budget-blocked call then prevented the discovery that would have unblocked
121
+ * it. Never rejects: any failure (endpoint absent, auth, timeout) is logged
122
+ * at debug, leaves the static defaults in force, and allows a retry on the
123
+ * next call.
124
+ */
125
+ export function ensureLiteLLMModelLimits(baseURL, apiKey) {
126
+ const key = stripTrailingSlash(baseURL);
127
+ const lastSuccess = modelInfoLastSuccess.get(key) ?? 0;
128
+ if (Date.now() - lastSuccess < MODEL_INFO_CACHE_DURATION) {
129
+ return Promise.resolve();
130
+ }
131
+ const inFlight = modelInfoInFlight.get(key);
132
+ if (inFlight) {
133
+ return inFlight;
134
+ }
135
+ const discovery = fetchLiteLLMModelLimits(key, apiKey)
136
+ .then((limits) => {
137
+ modelInfoLimitsByURL.set(key, limits);
138
+ // Min-merge across every base URL discovered so far (see
139
+ // modelInfoLimitsByURL) before registering.
140
+ const merged = new Map();
141
+ for (const urlLimits of modelInfoLimitsByURL.values()) {
142
+ for (const [model, modelLimits] of urlLimits) {
143
+ const existing = merged.get(model) ?? {};
144
+ if (modelLimits.maxInputTokens !== undefined) {
145
+ existing.maxInputTokens =
146
+ existing.maxInputTokens === undefined
147
+ ? modelLimits.maxInputTokens
148
+ : Math.min(existing.maxInputTokens, modelLimits.maxInputTokens);
149
+ }
150
+ if (modelLimits.maxOutputTokens !== undefined) {
151
+ existing.maxOutputTokens =
152
+ existing.maxOutputTokens === undefined
153
+ ? modelLimits.maxOutputTokens
154
+ : Math.min(existing.maxOutputTokens, modelLimits.maxOutputTokens);
155
+ }
156
+ merged.set(model, existing);
157
+ }
158
+ }
159
+ for (const [model, modelLimits] of merged) {
160
+ if (modelLimits.maxInputTokens !== undefined) {
161
+ registerRuntimeContextWindow("litellm", model, modelLimits.maxInputTokens);
162
+ }
163
+ if (modelLimits.maxOutputTokens !== undefined) {
164
+ registerRuntimeOutputCeiling("litellm", model, modelLimits.maxOutputTokens);
165
+ }
166
+ }
167
+ modelInfoLastSuccess.set(key, Date.now());
168
+ if (limits.size > 0) {
169
+ logger.debug("[LiteLLM] Registered runtime model limits from /model/info", { baseURL: redactUrlCredentials(key), models: limits.size });
170
+ }
171
+ })
172
+ .catch((error) => {
173
+ logger.debug("[LiteLLM] /model/info discovery failed; static context-window defaults remain in force", { error: error instanceof Error ? error.message : String(error) });
174
+ })
175
+ .finally(() => {
176
+ modelInfoInFlight.delete(key);
177
+ });
178
+ modelInfoInFlight.set(key, discovery);
179
+ return discovery;
180
+ }
24
181
  // LiteLLM model ids come in `provider/model` form (e.g. "google/gemini-2.5-flash").
25
182
  // Strip the provider prefix and delegate to the canonical anchored-regex
26
183
  // check in src/lib/utils/modelDetection.ts so the truth lives in one place.
@@ -46,30 +203,31 @@ export class LiteLLMProvider extends OpenAIChatCompletionsProvider {
46
203
  static modelsCache = [];
47
204
  static modelsCacheTime = 0;
48
205
  static MODELS_CACHE_DURATION = 10 * 60 * 1000; // 10 minutes
49
- /**
50
- * Dedupes the fire-and-forget `/model/info` discovery per base URL, so a
51
- * process constructing many LiteLLM providers fetches each proxy's limits
52
- * once per cache period.
53
- */
54
- static modelInfoFetchTime = new Map();
55
206
  constructor(modelName, sdk, _region, credentials) {
56
207
  const envConfig = getLiteLLMConfig();
57
208
  super("litellm", modelName, sdk, {
58
209
  baseURL: credentials?.baseURL ?? envConfig.baseURL,
59
210
  apiKey: credentials?.apiKey ?? envConfig.apiKey,
60
211
  });
61
- // Fire-and-forget: discover real per-model context windows from the
62
- // proxy's /model/info. The static table only has a one-size litellm
63
- // `_default` (128K), while proxied models range from 8K to 2M — budget
64
- // checks and compaction need the real window. Failures degrade cleanly
65
- // to the static default.
66
- this.discoverModelContextWindows();
212
+ // Warm the model-limit discovery early (deduped per base URL). The
213
+ // generation pipelines still AWAIT ensureModelLimits() before budget
214
+ // math this fire-and-forget only shaves latency off that first await.
215
+ void ensureLiteLLMModelLimits(this.config.baseURL, this.config.apiKey);
67
216
  logger.debug("LiteLLM Provider initialized", {
68
217
  modelName: this.modelName,
69
218
  provider: this.providerName,
70
219
  baseURL: redactUrlCredentials(this.config.baseURL),
71
220
  });
72
221
  }
222
+ /**
223
+ * Awaitable model-limit discovery — generation pipelines call this before
224
+ * budget checks so window/output-ceiling math uses the proxy's real
225
+ * numbers (see BaseProvider.ensureModelLimits). Deduped and cached per
226
+ * base URL; never rejects (failure degrades to static defaults).
227
+ */
228
+ async ensureModelLimits() {
229
+ await ensureLiteLLMModelLimits(this.config.baseURL, this.config.apiKey);
230
+ }
73
231
  getProviderName() {
74
232
  return "litellm";
75
233
  }
@@ -221,85 +379,6 @@ export class LiteLLMProvider extends OpenAIChatCompletionsProvider {
221
379
  }
222
380
  return this.getFallbackModels();
223
381
  }
224
- /**
225
- * Discover real per-model context windows from the LiteLLM proxy's
226
- * `GET /model/info` (`data[].model_info.max_input_tokens`) and register
227
- * them with the context-window resolver. Fire-and-forget with the same
228
- * cache period as the models list; any failure (endpoint absent, auth,
229
- * timeout) is logged at debug and leaves the static `_default` in force.
230
- */
231
- discoverModelContextWindows() {
232
- const baseURL = stripTrailingSlash(this.config.baseURL);
233
- const now = Date.now();
234
- const lastFetch = LiteLLMProvider.modelInfoFetchTime.get(baseURL) ?? 0;
235
- if (now - lastFetch < LiteLLMProvider.MODELS_CACHE_DURATION) {
236
- return;
237
- }
238
- LiteLLMProvider.modelInfoFetchTime.set(baseURL, now);
239
- void this.fetchModelInfoFromAPI()
240
- .then((windows) => {
241
- for (const [model, contextWindow] of windows) {
242
- registerRuntimeContextWindow("litellm", model, contextWindow);
243
- }
244
- if (windows.size > 0) {
245
- logger.debug("[LiteLLMProvider] Registered runtime context windows from /model/info", { baseURL: redactUrlCredentials(baseURL), models: windows.size });
246
- }
247
- })
248
- .catch((error) => {
249
- // Allow a retry before the cache period when discovery failed.
250
- LiteLLMProvider.modelInfoFetchTime.delete(baseURL);
251
- logger.debug("[LiteLLMProvider] /model/info discovery failed; static context-window defaults remain in force", { error: error instanceof Error ? error.message : String(error) });
252
- });
253
- }
254
- /**
255
- * Fetch `GET /model/info` and map model group name → max_input_tokens.
256
- * Same fetch/auth/abort scaffolding as {@link fetchModelsFromAPI}.
257
- */
258
- async fetchModelInfoFromAPI() {
259
- const infoUrl = `${stripTrailingSlash(this.config.baseURL)}/model/info`;
260
- const proxyFetch = createProxyFetch();
261
- const controller = new AbortController();
262
- const timeoutId = setTimeout(() => controller.abort(), 5000);
263
- try {
264
- const response = await proxyFetch(infoUrl, {
265
- method: "GET",
266
- headers: {
267
- Authorization: `Bearer ${this.config.apiKey}`,
268
- "Content-Type": "application/json",
269
- },
270
- signal: controller.signal,
271
- });
272
- if (!response.ok) {
273
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
274
- }
275
- const data = (await response.json());
276
- const windows = new Map();
277
- for (const entry of data.data ?? []) {
278
- const model = entry?.model_name;
279
- const maxInput = entry?.model_info?.max_input_tokens;
280
- if (typeof model === "string" &&
281
- model.length > 0 &&
282
- typeof maxInput === "number" &&
283
- Number.isFinite(maxInput) &&
284
- maxInput > 0) {
285
- // A model group can appear once per underlying deployment; keep the
286
- // smallest advertised window so budgets are safe for every replica.
287
- const existing = windows.get(model);
288
- windows.set(model, existing === undefined ? maxInput : Math.min(existing, maxInput));
289
- }
290
- }
291
- return windows;
292
- }
293
- catch (error) {
294
- if (isAbortError(error)) {
295
- throw new NetworkError("Request timed out after 5 seconds", this.providerName);
296
- }
297
- throw error;
298
- }
299
- finally {
300
- clearTimeout(timeoutId);
301
- }
302
- }
303
382
  async fetchModelsFromAPI() {
304
383
  const modelsUrl = `${stripTrailingSlash(this.config.baseURL)}/v1/models`;
305
384
  const proxyFetch = createProxyFetch();