@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
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Deterministic text normalization for knowledge grounding.
3
+ *
4
+ * The SAME normalizer runs at index-build time and query time so that a
5
+ * configuration key, a technical identifier, and a user's phrasing all
6
+ * collapse to the same comparable token stream:
7
+ *
8
+ * "Multi-Step Flow" -> "multi step flow"
9
+ * "enableMultiStepFlow" -> "enable multi step flow"
10
+ * "ACCOUNT_STATUS" -> "account status"
11
+ * " Access Policy " -> "access policy"
12
+ *
13
+ * The pipeline is intentionally conservative: it splits identifiers and
14
+ * separators and folds case/diacritics, but it NEVER invents synonyms.
15
+ * Reviewed acronym and spelling expansions belong in an entry's `aliases`,
16
+ * not in this function.
17
+ */
18
+ /**
19
+ * Normalize an arbitrary string into a single space-delimited lowercase
20
+ * phrase. Idempotent: `normalizeText(normalizeText(x)) === normalizeText(x)`.
21
+ */
22
+ export declare const normalizeText: (input: string) => string;
23
+ /**
24
+ * Normalize then split into tokens. Empty input yields an empty array.
25
+ * Numbers and alphanumeric identifiers are preserved as their own tokens.
26
+ */
27
+ export declare const tokenize: (input: string) => string[];
28
+ /**
29
+ * Normalize each candidate phrase and drop blanks/duplicates while preserving
30
+ * first-seen order. Used to build the exact-key and alias key sets.
31
+ */
32
+ export declare const normalizePhrases: (phrases: string[]) => string[];
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Deterministic text normalization for knowledge grounding.
3
+ *
4
+ * The SAME normalizer runs at index-build time and query time so that a
5
+ * configuration key, a technical identifier, and a user's phrasing all
6
+ * collapse to the same comparable token stream:
7
+ *
8
+ * "Multi-Step Flow" -> "multi step flow"
9
+ * "enableMultiStepFlow" -> "enable multi step flow"
10
+ * "ACCOUNT_STATUS" -> "account status"
11
+ * " Access Policy " -> "access policy"
12
+ *
13
+ * The pipeline is intentionally conservative: it splits identifiers and
14
+ * separators and folds case/diacritics, but it NEVER invents synonyms.
15
+ * Reviewed acronym and spelling expansions belong in an entry's `aliases`,
16
+ * not in this function.
17
+ */
18
+ /**
19
+ * Insert spaces at camelCase / PascalCase / acronym boundaries. Must run
20
+ * BEFORE lowercasing because it relies on case information.
21
+ *
22
+ * "enableURLReading" -> "enable URL Reading"
23
+ * "customAccessOptionsV2" -> "custom Access Options V2"
24
+ */
25
+ const splitIdentifierBoundaries = (input) => input
26
+ // lower/number followed by an uppercase: "enableTwo" -> "enable Two"
27
+ .replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, "$1 $2")
28
+ // acronym run followed by a Word: "URLReading" -> "URL Reading"
29
+ .replace(/(\p{Lu}+)(\p{Lu}\p{Ll})/gu, "$1 $2");
30
+ /**
31
+ * Normalize an arbitrary string into a single space-delimited lowercase
32
+ * phrase. Idempotent: `normalizeText(normalizeText(x)) === normalizeText(x)`.
33
+ */
34
+ export const normalizeText = (input) => {
35
+ if (!input) {
36
+ return "";
37
+ }
38
+ const boundaries = splitIdentifierBoundaries(
39
+ // NFKC folds compatibility forms; NFD + mark-strip removes diacritics.
40
+ input
41
+ .normalize("NFKC")
42
+ .normalize("NFD")
43
+ .replace(/\p{M}+/gu, ""));
44
+ return (boundaries
45
+ // any run of non-letter/non-number (snake_case, kebab-case, dots, slashes,
46
+ // punctuation, and existing whitespace) collapses to a single space.
47
+ .replace(/[^\p{L}\p{N}]+/gu, " ")
48
+ .trim()
49
+ .toLowerCase());
50
+ };
51
+ /**
52
+ * Normalize then split into tokens. Empty input yields an empty array.
53
+ * Numbers and alphanumeric identifiers are preserved as their own tokens.
54
+ */
55
+ export const tokenize = (input) => {
56
+ const normalized = normalizeText(input);
57
+ return normalized ? normalized.split(" ") : [];
58
+ };
59
+ /**
60
+ * Normalize each candidate phrase and drop blanks/duplicates while preserving
61
+ * first-seen order. Used to build the exact-key and alias key sets.
62
+ */
63
+ export const normalizePhrases = (phrases) => {
64
+ const seen = new Set();
65
+ const out = [];
66
+ for (const phrase of phrases) {
67
+ const normalized = normalizeText(phrase);
68
+ if (normalized && !seen.has(normalized)) {
69
+ seen.add(normalized);
70
+ out.push(normalized);
71
+ }
72
+ }
73
+ return out;
74
+ };
@@ -0,0 +1,18 @@
1
+ import type { KnowledgeEntryInput, KnowledgeManifest, KnowledgeNormalizeOptions, KnowledgeNormalizeResult, KnowledgeSource, NormalizedKnowledgeEntry } from "../types/index.js";
2
+ /**
3
+ * Resolve one authored entry into a complete `NormalizedKnowledgeEntry`,
4
+ * materializing every omitted optional (optional arrays to `[]`, `body` to "",
5
+ * `kind` to "text", `status` to "active") so downstream code never re-checks
6
+ * it. Authored arrays are cloned so the normalized snapshot is isolated from
7
+ * caller-owned references.
8
+ */
9
+ export declare const resolveEntry: (input: KnowledgeEntryInput, version: string) => NormalizedKnowledgeEntry;
10
+ /** Expand a build manifest into sources — one per catalog. */
11
+ export declare const manifestToSources: (manifest: KnowledgeManifest) => KnowledgeSource[];
12
+ /**
13
+ * Resolve and validate every source into normalized entries. `validation.ok`
14
+ * is false when any error-level issue was found; the caller (index builder)
15
+ * must not swap in an index built from an invalid set. Async so the public
16
+ * contract is stable if a future source kind needs asynchronous loading.
17
+ */
18
+ export declare const normalizeAndValidate: (sources: KnowledgeSource[], options: KnowledgeNormalizeOptions) => Promise<KnowledgeNormalizeResult>;
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Source loading, default resolution, and generic validation.
3
+ *
4
+ * Required fields (id/title/summary/domain/integrations) come straight
5
+ * from the authored entry; omitted optionals fall back to SDK defaults. This
6
+ * covers only provider-neutral invariants (ids, required fields, enum values,
7
+ * relationship integrity). Host-specific rules — configuration-key coverage,
8
+ * taxonomy conflicts — are enforced by the host build BEFORE the manifest
9
+ * reaches NeuroLink. Errors block indexing; warnings are surfaced but do not.
10
+ */
11
+ import { decodeArray, decodeString } from "type-decoder/dist/index.js";
12
+ import { normalizeText } from "./normalize.js";
13
+ const SDK_DEFAULT_KIND = "text";
14
+ const SDK_DEFAULT_STATUS = "active";
15
+ const STATUSES = new Set(["draft", "active", "deprecated"]);
16
+ const KINDS = new Set([
17
+ "concept",
18
+ "text",
19
+ "configuration",
20
+ "tool",
21
+ "procedure",
22
+ "policy",
23
+ "troubleshooting",
24
+ ]);
25
+ /**
26
+ * Resolve one authored entry into a complete `NormalizedKnowledgeEntry`,
27
+ * materializing every omitted optional (optional arrays to `[]`, `body` to "",
28
+ * `kind` to "text", `status` to "active") so downstream code never re-checks
29
+ * it. Authored arrays are cloned so the normalized snapshot is isolated from
30
+ * caller-owned references.
31
+ */
32
+ export const resolveEntry = (input, version) => ({
33
+ id: input.id,
34
+ title: input.title,
35
+ summary: input.summary,
36
+ domain: input.domain,
37
+ integrations: decodeArray(input.integrations, decodeString) ?? [],
38
+ kind: input.kind ?? SDK_DEFAULT_KIND,
39
+ status: input.status ?? SDK_DEFAULT_STATUS,
40
+ body: input.body ?? "",
41
+ aliases: [...(input.aliases ?? [])],
42
+ keywords: [...(input.keywords ?? [])],
43
+ relatedEntryIds: [...(input.relatedEntryIds ?? [])],
44
+ parentEntryId: input.parentEntryId,
45
+ version,
46
+ });
47
+ /** Expand a build manifest into sources — one per catalog. */
48
+ export const manifestToSources = (manifest) => manifest.catalogs.map((catalog) => ({
49
+ id: catalog.id,
50
+ version: manifest.contentVersion,
51
+ entries: catalog.entries,
52
+ }));
53
+ /** Resolve a source's entries and its effective version. */
54
+ const loadSourceEntries = (source, manifestVersion) => ({
55
+ id: source.id,
56
+ version: source.version ?? manifestVersion,
57
+ entries: source.entries,
58
+ });
59
+ const error = (code, message, extra) => ({ level: "error", code, message, ...extra });
60
+ const warn = (code, message, extra) => ({ level: "warning", code, message, ...extra });
61
+ /**
62
+ * Resolve and validate every source into normalized entries. `validation.ok`
63
+ * is false when any error-level issue was found; the caller (index builder)
64
+ * must not swap in an index built from an invalid set. Async so the public
65
+ * contract is stable if a future source kind needs asynchronous loading.
66
+ */
67
+ export const normalizeAndValidate = async (sources, options) => {
68
+ const issues = [];
69
+ const entries = [];
70
+ const seenIds = new Set();
71
+ const aliasOwner = new Map();
72
+ for (const source of sources) {
73
+ const loaded = loadSourceEntries(source, options.manifestVersion);
74
+ if (!loaded.id) {
75
+ issues.push(error("missing-source-id", "Source has no id"));
76
+ }
77
+ for (const input of loaded.entries) {
78
+ if (!input.id) {
79
+ issues.push(error("missing-entry-id", "Entry has no id", { sourceId: loaded.id }));
80
+ continue;
81
+ }
82
+ if (seenIds.has(input.id)) {
83
+ issues.push(error("duplicate-entry-id", `Duplicate entry id "${input.id}"`, {
84
+ entryId: input.id,
85
+ }));
86
+ continue;
87
+ }
88
+ seenIds.add(input.id);
89
+ if (!input.title || !input.title.trim()) {
90
+ issues.push(error("missing-title", "Entry is missing a title", {
91
+ entryId: input.id,
92
+ }));
93
+ }
94
+ if (!input.summary || !input.summary.trim()) {
95
+ issues.push(error("missing-summary", "Entry is missing a summary", {
96
+ entryId: input.id,
97
+ }));
98
+ }
99
+ if (!input.domain || !input.domain.trim()) {
100
+ issues.push(error("missing-domain", "Entry is missing a domain", {
101
+ entryId: input.id,
102
+ }));
103
+ }
104
+ if (input.integrations === undefined) {
105
+ issues.push(error("missing-integrations", "Entry is missing integrations", {
106
+ entryId: input.id,
107
+ }));
108
+ }
109
+ else if (decodeArray(input.integrations, decodeString) === null) {
110
+ issues.push(error("bad-integrations", "Entry integrations must be an array of strings", { entryId: input.id }));
111
+ }
112
+ const normalized = resolveEntry(input, loaded.version);
113
+ if (!KINDS.has(normalized.kind)) {
114
+ issues.push(error("bad-kind", `Unsupported kind "${normalized.kind}"`, {
115
+ entryId: normalized.id,
116
+ }));
117
+ }
118
+ if (!STATUSES.has(normalized.status)) {
119
+ issues.push(error("bad-status", `Unsupported status "${normalized.status}"`, {
120
+ entryId: normalized.id,
121
+ }));
122
+ }
123
+ for (const alias of normalized.aliases) {
124
+ const key = normalizeText(alias);
125
+ if (!key) {
126
+ issues.push(warn("empty-alias", `Alias "${alias}" is empty after normalization`, { entryId: normalized.id }));
127
+ continue;
128
+ }
129
+ const owner = aliasOwner.get(key);
130
+ if (owner && owner !== normalized.id) {
131
+ issues.push(warn("duplicate-alias", `Alias "${alias}" also used by "${owner}"`, { entryId: normalized.id }));
132
+ }
133
+ else {
134
+ aliasOwner.set(key, normalized.id);
135
+ }
136
+ }
137
+ entries.push(normalized);
138
+ }
139
+ }
140
+ // Relationship integrity — resolved after all entries are collected.
141
+ const idSet = new Set(entries.map((entry) => entry.id));
142
+ for (const entry of entries) {
143
+ for (const related of entry.relatedEntryIds) {
144
+ if (!idSet.has(related)) {
145
+ issues.push(warn("broken-relation", `relatedEntryId "${related}" not found`, {
146
+ entryId: entry.id,
147
+ }));
148
+ }
149
+ }
150
+ if (entry.parentEntryId && !idSet.has(entry.parentEntryId)) {
151
+ issues.push(warn("broken-parent", `parentEntryId "${entry.parentEntryId}" not found`, { entryId: entry.id }));
152
+ }
153
+ }
154
+ const ok = !issues.some((issue) => issue.level === "error");
155
+ return { entries, validation: { ok, issues } };
156
+ };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Lexical-first retrieval pipeline.
3
+ *
4
+ * For one turn: authorize/filter BEFORE ranking, resolve exact identifiers and
5
+ * reviewed aliases via query n-gram lookup, add field-aware BM25 over a bounded
6
+ * query+history window, combine the deterministic signals, take the top
7
+ * candidates, expand a bounded set of directly-related entries, and classify
8
+ * confidence. No LLM, no embeddings, no vector store.
9
+ */
10
+ import type { KnowledgeIndexSnapshot, KnowledgeResolvedRetrieval, KnowledgeRetrievalRequest, KnowledgeSelection } from "../types/index.js";
11
+ /**
12
+ * Run retrieval against a ready snapshot. Returns the primary selection, a
13
+ * bounded relationship expansion, the scored candidate list (diagnostics), and
14
+ * a confidence class. Context assembly is a separate, later step.
15
+ */
16
+ export declare const retrieve: (snapshot: KnowledgeIndexSnapshot, request: KnowledgeRetrievalRequest, config: KnowledgeResolvedRetrieval, blockedDomains?: string[]) => KnowledgeSelection;
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Lexical-first retrieval pipeline.
3
+ *
4
+ * For one turn: authorize/filter BEFORE ranking, resolve exact identifiers and
5
+ * reviewed aliases via query n-gram lookup, add field-aware BM25 over a bounded
6
+ * query+history window, combine the deterministic signals, take the top
7
+ * candidates, expand a bounded set of directly-related entries, and classify
8
+ * confidence. No LLM, no embeddings, no vector store.
9
+ */
10
+ import { tokenize } from "./normalize.js";
11
+ /** Per-turn history text cap so a long prior turn cannot dominate the query. */
12
+ const HISTORY_TURN_CHARS = 400;
13
+ /** All contiguous token n-grams (length 1..maxLen), de-duplicated. */
14
+ const generateNGrams = (tokens, maxLen) => {
15
+ const grams = [];
16
+ const seen = new Set();
17
+ const limit = Math.min(maxLen, tokens.length);
18
+ for (let size = 1; size <= limit; size += 1) {
19
+ for (let start = 0; start + size <= tokens.length; start += 1) {
20
+ const gram = tokens.slice(start, start + size).join(" ");
21
+ if (!seen.has(gram)) {
22
+ seen.add(gram);
23
+ grams.push(gram);
24
+ }
25
+ }
26
+ }
27
+ return grams;
28
+ };
29
+ /** Longest indexed exact/alias phrase length, derived from the active snapshot. */
30
+ const getMaxIndexedPhraseLen = (...indexes) => {
31
+ let maxLen = 0;
32
+ for (const index of indexes) {
33
+ for (const phrase of index.keys()) {
34
+ const length = phrase === "" ? 0 : phrase.split(" ").length;
35
+ maxLen = Math.max(maxLen, length);
36
+ }
37
+ }
38
+ return maxLen;
39
+ };
40
+ /** Look each n-gram up in a phrase index; returns entry id -> matched phrases. */
41
+ const lookupPhrases = (grams, index) => {
42
+ const hits = new Map();
43
+ for (const gram of grams) {
44
+ const ids = index.get(gram);
45
+ if (!ids) {
46
+ continue;
47
+ }
48
+ for (const id of ids) {
49
+ const matched = hits.get(id) ?? [];
50
+ matched.push(gram);
51
+ hits.set(id, matched);
52
+ }
53
+ }
54
+ return hits;
55
+ };
56
+ /** Tokens fed to BM25: the current query plus a bounded recent-turn window. */
57
+ const buildLexicalTokens = (request) => {
58
+ const parts = [request.query];
59
+ for (const turn of request.recentTurns) {
60
+ parts.push(turn.text.slice(0, HISTORY_TURN_CHARS));
61
+ }
62
+ return parts.flatMap((part) => tokenize(part));
63
+ };
64
+ /**
65
+ * Authorization + metadata filter. Applied BEFORE ranking so restricted or
66
+ * disabled-integration content never enters candidates, traces, or model context.
67
+ */
68
+ const isAuthorized = (entry, request, blockedDomains) => {
69
+ if (entry.status !== "active") {
70
+ return false;
71
+ }
72
+ if (blockedDomains && blockedDomains.includes(entry.domain)) {
73
+ return false;
74
+ }
75
+ if (entry.integrations.length > 0) {
76
+ const active = new Set();
77
+ for (const integration of request.enabledIntegrations) {
78
+ active.add(integration.toLowerCase());
79
+ }
80
+ if (!entry.integrations.some((integration) => active.has(integration.toLowerCase()))) {
81
+ return false;
82
+ }
83
+ }
84
+ return true;
85
+ };
86
+ const compareCandidates = (left, right) => {
87
+ if (right.score !== left.score) {
88
+ return right.score - left.score;
89
+ }
90
+ if (left.id < right.id) {
91
+ return -1;
92
+ }
93
+ if (left.id > right.id) {
94
+ return 1;
95
+ }
96
+ return 0;
97
+ };
98
+ /**
99
+ * Confidence class. `high` requires an exact/alias hit at the top; otherwise a
100
+ * multi-field or dominant lexical top is `medium`, a single weak signal is
101
+ * `low`, and no candidates is `none`. Thresholds are pre-tuning heuristics.
102
+ */
103
+ const classifyConfidence = (candidates) => {
104
+ if (candidates.length === 0) {
105
+ return "none";
106
+ }
107
+ const [top, second] = candidates;
108
+ if (top.exact || top.alias) {
109
+ return "high";
110
+ }
111
+ const fieldsMatched = Object.keys(top.fieldScores).length;
112
+ const dominant = second ? top.score >= 1.5 * second.score : false;
113
+ if (fieldsMatched >= 2 || dominant) {
114
+ return "medium";
115
+ }
116
+ return "low";
117
+ };
118
+ /**
119
+ * Run retrieval against a ready snapshot. Returns the primary selection, a
120
+ * bounded relationship expansion, the scored candidate list (diagnostics), and
121
+ * a confidence class. Context assembly is a separate, later step.
122
+ */
123
+ export const retrieve = (snapshot, request, config, blockedDomains) => {
124
+ const queryTokens = tokenize(request.query);
125
+ const maxPhraseLen = getMaxIndexedPhraseLen(snapshot.exactIndex, snapshot.aliasIndex);
126
+ const grams = generateNGrams(queryTokens, maxPhraseLen);
127
+ const exactHits = lookupPhrases(grams, snapshot.exactIndex);
128
+ const aliasHits = lookupPhrases(grams, snapshot.aliasIndex);
129
+ const eligibleEntryIds = new Set();
130
+ for (const [id, entry] of snapshot.entriesById) {
131
+ if (isAuthorized(entry, request, blockedDomains)) {
132
+ eligibleEntryIds.add(id);
133
+ }
134
+ }
135
+ const lexicalTokens = buildLexicalTokens(request);
136
+ const lexicalMatches = snapshot.lexical.search(lexicalTokens, config.candidateLimit * 2, eligibleEntryIds);
137
+ const lexicalById = new Map(lexicalMatches.map((match) => [match.id, match]));
138
+ const candidateIds = new Set();
139
+ for (const id of exactHits.keys()) {
140
+ if (eligibleEntryIds.has(id)) {
141
+ candidateIds.add(id);
142
+ }
143
+ }
144
+ for (const id of aliasHits.keys()) {
145
+ if (eligibleEntryIds.has(id)) {
146
+ candidateIds.add(id);
147
+ }
148
+ }
149
+ for (const match of lexicalMatches) {
150
+ candidateIds.add(match.id);
151
+ }
152
+ const candidates = [];
153
+ for (const id of candidateIds) {
154
+ const entry = snapshot.entriesById.get(id);
155
+ if (!entry) {
156
+ continue;
157
+ }
158
+ const exact = exactHits.has(id);
159
+ const alias = aliasHits.has(id);
160
+ const lexicalMatch = lexicalById.get(id);
161
+ const lexical = lexicalMatch?.score ?? 0;
162
+ let score = lexical;
163
+ if (exact) {
164
+ score += config.exactBoost;
165
+ }
166
+ if (alias) {
167
+ score += config.aliasBoost;
168
+ }
169
+ candidates.push({
170
+ id,
171
+ score,
172
+ exact,
173
+ alias,
174
+ lexical,
175
+ fieldScores: lexicalMatch?.fieldScores ?? {},
176
+ matchedPhrases: [
177
+ ...(exactHits.get(id) ?? []),
178
+ ...(aliasHits.get(id) ?? []),
179
+ ],
180
+ });
181
+ }
182
+ candidates.sort(compareCandidates);
183
+ const trimmed = candidates.slice(0, config.candidateLimit);
184
+ const primary = [];
185
+ for (const candidate of trimmed.slice(0, config.resultLimit)) {
186
+ const entry = snapshot.entriesById.get(candidate.id);
187
+ if (entry) {
188
+ primary.push(entry);
189
+ }
190
+ }
191
+ const selectedIds = new Set(primary.map((entry) => entry.id));
192
+ const expanded = [];
193
+ for (const entry of primary) {
194
+ if (expanded.length >= config.relationLimit) {
195
+ break;
196
+ }
197
+ const relations = snapshot.relationIndex.get(entry.id) ?? [];
198
+ for (const relatedId of relations) {
199
+ if (expanded.length >= config.relationLimit) {
200
+ break;
201
+ }
202
+ if (selectedIds.has(relatedId)) {
203
+ continue;
204
+ }
205
+ const relatedEntry = snapshot.entriesById.get(relatedId);
206
+ if (!relatedEntry ||
207
+ !isAuthorized(relatedEntry, request, blockedDomains)) {
208
+ continue;
209
+ }
210
+ selectedIds.add(relatedId);
211
+ expanded.push(relatedEntry);
212
+ }
213
+ }
214
+ return {
215
+ primary,
216
+ expanded,
217
+ candidates: trimmed,
218
+ confidence: classifyConfidence(trimmed),
219
+ candidateCount: candidates.length,
220
+ };
221
+ };
@@ -30,6 +30,30 @@ export declare const MODEL_CONTEXT_WINDOWS: Record<string, Record<string, number
30
30
  export declare function registerRuntimeContextWindow(provider: string, model: string, contextWindow: number): void;
31
31
  /** Test hook: clear runtime-discovered windows (state is module-global). */
32
32
  export declare function clearRuntimeContextWindows(): void;
33
+ /**
34
+ * Runtime-discovered window for an exact provider/model pair, or undefined.
35
+ *
36
+ * Unlike {@link getContextWindowSize} this NEVER falls back to static table
37
+ * values. Callers use it to distinguish "the serving infrastructure told us
38
+ * the real window" (safe to hard-enforce: clamp max_tokens, fail fast) from
39
+ * "static guess" (advisory only — hard-enforcing a guessed window would
40
+ * falsely reject requests that the real deployment accepts).
41
+ */
42
+ export declare function getRuntimeContextWindow(provider: string, model?: string): number | undefined;
43
+ /**
44
+ * Register a runtime-discovered output-token ceiling for a provider/model
45
+ * pair. Later registrations overwrite earlier ones (rediscovery refreshes
46
+ * values). Non-positive/non-finite ceilings are ignored so a malformed
47
+ * discovery source can never shrink an output budget to zero.
48
+ */
49
+ export declare function registerRuntimeOutputCeiling(provider: string, model: string, maxOutputTokens: number): void;
50
+ /**
51
+ * Runtime-discovered output ceiling for an exact provider/model pair, or
52
+ * undefined when the serving infrastructure has not advertised one.
53
+ */
54
+ export declare function getRuntimeOutputCeiling(provider: string, model?: string): number | undefined;
55
+ /** Test hook: clear runtime-discovered output ceilings (state is module-global). */
56
+ export declare function clearRuntimeOutputCeilings(): void;
33
57
  /**
34
58
  * Resolve context window size for a provider/model combination.
35
59
  *
@@ -420,6 +420,56 @@ export function registerRuntimeContextWindow(provider, model, contextWindow) {
420
420
  export function clearRuntimeContextWindows() {
421
421
  RUNTIME_CONTEXT_WINDOWS.clear();
422
422
  }
423
+ /**
424
+ * Runtime-discovered window for an exact provider/model pair, or undefined.
425
+ *
426
+ * Unlike {@link getContextWindowSize} this NEVER falls back to static table
427
+ * values. Callers use it to distinguish "the serving infrastructure told us
428
+ * the real window" (safe to hard-enforce: clamp max_tokens, fail fast) from
429
+ * "static guess" (advisory only — hard-enforcing a guessed window would
430
+ * falsely reject requests that the real deployment accepts).
431
+ */
432
+ export function getRuntimeContextWindow(provider, model) {
433
+ if (!model) {
434
+ return undefined;
435
+ }
436
+ return RUNTIME_CONTEXT_WINDOWS.get(`${provider}:${model}`);
437
+ }
438
+ /**
439
+ * Runtime-discovered output-token ceilings, keyed `${provider}:${model}` —
440
+ * the `max_output_tokens` the serving infrastructure advertises for a model
441
+ * (e.g. LiteLLM `/model/info`). Same async-populate/sync-read contract as
442
+ * {@link registerRuntimeContextWindow}. Consumed by `getSafeMaxTokens` so
443
+ * requested maxTokens is clamped to the deployed model's real cap instead of
444
+ * a static per-provider table value.
445
+ */
446
+ const RUNTIME_OUTPUT_CEILINGS = new Map();
447
+ /**
448
+ * Register a runtime-discovered output-token ceiling for a provider/model
449
+ * pair. Later registrations overwrite earlier ones (rediscovery refreshes
450
+ * values). Non-positive/non-finite ceilings are ignored so a malformed
451
+ * discovery source can never shrink an output budget to zero.
452
+ */
453
+ export function registerRuntimeOutputCeiling(provider, model, maxOutputTokens) {
454
+ if (!Number.isFinite(maxOutputTokens) || maxOutputTokens <= 0) {
455
+ return;
456
+ }
457
+ RUNTIME_OUTPUT_CEILINGS.set(`${provider}:${model}`, maxOutputTokens);
458
+ }
459
+ /**
460
+ * Runtime-discovered output ceiling for an exact provider/model pair, or
461
+ * undefined when the serving infrastructure has not advertised one.
462
+ */
463
+ export function getRuntimeOutputCeiling(provider, model) {
464
+ if (!model) {
465
+ return undefined;
466
+ }
467
+ return RUNTIME_OUTPUT_CEILINGS.get(`${provider}:${model}`);
468
+ }
469
+ /** Test hook: clear runtime-discovered output ceilings (state is module-global). */
470
+ export function clearRuntimeOutputCeilings() {
471
+ RUNTIME_OUTPUT_CEILINGS.clear();
472
+ }
423
473
  /**
424
474
  * Resolve context window size for a provider/model combination.
425
475
  *
@@ -23,6 +23,12 @@ export declare function getContextOverflowProvider(error: unknown): string | nul
23
23
  export declare function parseProviderOverflowDetails(error: unknown): {
24
24
  actualTokens: number;
25
25
  budgetTokens: number;
26
+ /**
27
+ * Output tokens the rejected request asked for, when the message states
28
+ * them separately (vllm/LiteLLM phrasing). Lets recovery re-fit
29
+ * max_tokens instead of shrinking the input.
30
+ */
31
+ requestedOutputTokens?: number;
26
32
  } | null;
27
33
  /**
28
34
  * Extract error message from various error formats.
@@ -113,6 +113,24 @@ export function parseProviderOverflowDetails(error) {
113
113
  budgetTokens: parseInt(openaiMax[1].replace(/,/g, ""), 10),
114
114
  };
115
115
  }
116
+ // vllm / LiteLLM-proxied backends: "This model's maximum context length is
117
+ // N tokens. However, you requested X output tokens and your prompt contains
118
+ // at least Y input tokens..." — input and requested-output are stated
119
+ // separately (no "resulted in"), so recovery can re-fit max_tokens to
120
+ // N − Y instead of shrinking the input.
121
+ const vllmInput = message.match(/prompt\s+contains\s+at\s+least\s+(\d[\d,]{0,19})\s+input\s+tokens/i);
122
+ if (vllmInput && openaiMax) {
123
+ const requestedOutput = message.match(/requested\s+(\d[\d,]{0,19})\s+output\s+tokens/i);
124
+ return {
125
+ actualTokens: parseInt(vllmInput[1].replace(/,/g, ""), 10),
126
+ budgetTokens: parseInt(openaiMax[1].replace(/,/g, ""), 10),
127
+ ...(requestedOutput
128
+ ? {
129
+ requestedOutputTokens: parseInt(requestedOutput[1].replace(/,/g, ""), 10),
130
+ }
131
+ : {}),
132
+ };
133
+ }
116
134
  // Anthropic pattern: "X tokens > Y token limit" or "X tokens, limit Y"
117
135
  // Use single character-class number groups to prevent ReDoS (CodeQL: js/polynomial-redos)
118
136
  const anthropicMatch = message.match(/(\d[\d,]{0,19})\s*tokens?\s*[>:]\s*(\d[\d,]{0,19})/i);
@@ -41,7 +41,21 @@ export declare function estimateStepMessagesTokens(messages: readonly ModelMessa
41
41
  export declare function estimateFixedOverheadTokens(system: unknown, tools: Record<string, unknown> | undefined, provider?: string): number;
42
42
  /**
43
43
  * Create a per-step budget guard. Returns a function that, given the step's
44
- * messages, returns a compacted replacement array when the projected request
45
- * exceeds the threshold or `undefined` when no change is needed.
44
+ * messages (and optionally the REAL input-token count the provider reported
45
+ * for the previous step), returns a compacted replacement array when the
46
+ * projected request exceeds the threshold — or `undefined` when no change is
47
+ * needed.
48
+ *
49
+ * Two dynamic behaviours:
50
+ * - the available-input budget is re-resolved on EVERY invocation, so
51
+ * runtime window discovery (`/model/info`, overflow self-healing) that
52
+ * lands mid-loop takes effect immediately instead of the guard staying
53
+ * frozen on the value captured at loop start;
54
+ * - usage feedback calibrates the estimator: the ratio between the real
55
+ * prompt tokens of the previous step and this guard's own estimate for
56
+ * what that step sent scales later estimates (only UP — underestimates
57
+ * overflow, overestimates merely compact earlier), eliminating the
58
+ * char-based estimator's drift on dense code/diff content without
59
+ * shipping a tokenizer.
46
60
  */
47
- export declare function createStepBudgetGuard(config: StepBudgetGuardConfig): (messages: readonly ModelMessage[]) => ModelMessage[] | undefined;
61
+ export declare function createStepBudgetGuard(config: StepBudgetGuardConfig): (messages: readonly ModelMessage[], observedInputTokensLastStep?: number) => ModelMessage[] | undefined;