@juspay/neurolink 10.2.1 → 10.3.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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +371 -371
- package/dist/constants/contextWindows.d.ts +12 -0
- package/dist/constants/contextWindows.js +38 -0
- package/dist/context/stepBudgetGuard.d.ts +47 -0
- package/dist/context/stepBudgetGuard.js +289 -0
- package/dist/core/modules/GenerationHandler.js +53 -5
- package/dist/lib/constants/contextWindows.d.ts +12 -0
- package/dist/lib/constants/contextWindows.js +38 -0
- package/dist/lib/context/stepBudgetGuard.d.ts +47 -0
- package/dist/lib/context/stepBudgetGuard.js +290 -0
- package/dist/lib/core/modules/GenerationHandler.js +53 -5
- package/dist/lib/providers/litellm.d.ts +19 -0
- package/dist/lib/providers/litellm.js +92 -0
- package/dist/lib/types/context.d.ts +22 -0
- package/dist/providers/litellm.d.ts +19 -0
- package/dist/providers/litellm.js +92 -0
- package/dist/types/context.d.ts +22 -0
- package/package.json +5 -3
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-step context budget guard for the AI-SDK agent loop.
|
|
3
|
+
*
|
|
4
|
+
* Pre-call budgeting (`checkContextBudget` + compaction) runs ONCE before
|
|
5
|
+
* dispatch and only sees the input/session conversation. The AI-SDK tool loop
|
|
6
|
+
* then appends assistant turns and tool results on every step — growth the
|
|
7
|
+
* pre-call pipeline never sees, which is how long agentic runs overflow the
|
|
8
|
+
* model's real context window mid-loop (provider 400s after dozens of tool
|
|
9
|
+
* calls). The googleVertex native loops already guard this via
|
|
10
|
+
* `createContextGuard`; this module brings the AI-SDK path (every provider
|
|
11
|
+
* that delegates to `generateText`) to parity — and goes one step further:
|
|
12
|
+
* instead of stopping the loop, it deterministically reclaims budget so the
|
|
13
|
+
* loop can CONTINUE.
|
|
14
|
+
*
|
|
15
|
+
* Wired in `GenerationHandler.callGenerateText` through
|
|
16
|
+
* `experimental_prepareStep`, whose result may replace the step's `messages`.
|
|
17
|
+
* The guard operates on `ModelMessage[]` natively (no lossy ChatMessage
|
|
18
|
+
* round-trip) and never makes LLM calls:
|
|
19
|
+
*
|
|
20
|
+
* Stage 1 — truncate OLD tool outputs to head/tail previews
|
|
21
|
+
* (`generateToolOutputPreview`), oldest first, outside the
|
|
22
|
+
* protected recent tail.
|
|
23
|
+
* Stage 2 — drop the oldest complete tool exchanges (assistant tool-call
|
|
24
|
+
* message + its following tool-result messages, as a unit, so
|
|
25
|
+
* call/result pairing stays intact), replacing them with a single
|
|
26
|
+
* elision note.
|
|
27
|
+
*
|
|
28
|
+
* The system prompt and tool definitions ride OUTSIDE the step messages (the
|
|
29
|
+
* handler hoists system into generateText's `system` option), so their cost is
|
|
30
|
+
* passed in as `fixedOverheadTokens`. The first user message (the task) and
|
|
31
|
+
* the most recent messages are never touched.
|
|
32
|
+
*/
|
|
33
|
+
import { DEFAULT_CONTEXT_GUARD_RATIO } from "../core/constants.js";
|
|
34
|
+
import { getAvailableInputTokens } from "../constants/contextWindows.js";
|
|
35
|
+
import { estimateTokens, TOKENS_PER_MESSAGE, } from "../utils/tokenEstimation.js";
|
|
36
|
+
import { generateToolOutputPreview } from "./toolOutputLimits.js";
|
|
37
|
+
import { logger } from "../utils/logger.js";
|
|
38
|
+
/** Estimated tokens for a tool definition that fails to serialize. */
|
|
39
|
+
const TOKENS_PER_TOOL_DEFINITION = 200;
|
|
40
|
+
/** Messages at the end of the conversation the guard never modifies. */
|
|
41
|
+
const PROTECTED_TAIL_MESSAGES = 4;
|
|
42
|
+
/** Stage-1 preview budget for an old tool output (bytes). */
|
|
43
|
+
const OLD_TOOL_OUTPUT_PREVIEW_BYTES = 2_048;
|
|
44
|
+
/** Stage-1 preview budget for an old tool output (lines). */
|
|
45
|
+
const OLD_TOOL_OUTPUT_PREVIEW_LINES = 60;
|
|
46
|
+
/**
|
|
47
|
+
* Serialize any ModelMessage content to text for estimation. Tool-call args
|
|
48
|
+
* and tool-result outputs are JSON-stringified; unserializable values fall
|
|
49
|
+
* back to a fixed-size placeholder so estimation never throws.
|
|
50
|
+
*/
|
|
51
|
+
function contentToText(content) {
|
|
52
|
+
if (typeof content === "string") {
|
|
53
|
+
return content;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
return JSON.stringify(content) ?? "";
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return "x".repeat(TOKENS_PER_TOOL_DEFINITION * 4);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** Estimate the token cost of a step's message array. */
|
|
63
|
+
export function estimateStepMessagesTokens(messages, provider) {
|
|
64
|
+
let total = 0;
|
|
65
|
+
for (const message of messages) {
|
|
66
|
+
total +=
|
|
67
|
+
estimateTokens(contentToText(message.content), provider) +
|
|
68
|
+
TOKENS_PER_MESSAGE;
|
|
69
|
+
}
|
|
70
|
+
return total;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Estimate the fixed per-request overhead: hoisted system prompt + tool
|
|
74
|
+
* definitions. Mirrors `checkContextBudget`'s categories for the pieces that
|
|
75
|
+
* do not live in the step messages.
|
|
76
|
+
*/
|
|
77
|
+
export function estimateFixedOverheadTokens(system, tools, provider) {
|
|
78
|
+
let total = system
|
|
79
|
+
? estimateTokens(contentToText(system), provider) + TOKENS_PER_MESSAGE
|
|
80
|
+
: 0;
|
|
81
|
+
for (const tool of Object.values(tools ?? {})) {
|
|
82
|
+
try {
|
|
83
|
+
total += estimateTokens(JSON.stringify(tool) ?? "", provider);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
total += TOKENS_PER_TOOL_DEFINITION;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return total;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Serialize a ToolResultOutput to the text the MODEL should see in a preview.
|
|
93
|
+
* Variant-aware: `text`/`error-text` carry their payload in `.value` directly —
|
|
94
|
+
* stringifying the wrapper would put escaped `{"type":"text","value":…}` JSON
|
|
95
|
+
* in front of the model instead of the actual output. `json`/`error-json`/
|
|
96
|
+
* `content` serialize their value; unknown shapes fall back to the wrapper.
|
|
97
|
+
*/
|
|
98
|
+
function toolResultOutputToText(output) {
|
|
99
|
+
const variant = output;
|
|
100
|
+
if (variant && typeof variant === "object" && "type" in variant) {
|
|
101
|
+
if ((variant.type === "text" || variant.type === "error-text") &&
|
|
102
|
+
typeof variant.value === "string") {
|
|
103
|
+
return variant.value;
|
|
104
|
+
}
|
|
105
|
+
if (variant.type === "json" ||
|
|
106
|
+
variant.type === "error-json" ||
|
|
107
|
+
variant.type === "content") {
|
|
108
|
+
return contentToText(variant.value);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return contentToText(output);
|
|
112
|
+
}
|
|
113
|
+
/** True when the message is an assistant message that issues tool calls. */
|
|
114
|
+
function isToolCallAssistantMessage(message) {
|
|
115
|
+
return (message.role === "assistant" &&
|
|
116
|
+
Array.isArray(message.content) &&
|
|
117
|
+
message.content.some((part) => part?.type === "tool-call"));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Stage 1: replace large tool-result outputs outside the protected tail with
|
|
121
|
+
* head/tail previews. Returns the new array plus how many outputs shrank.
|
|
122
|
+
*/
|
|
123
|
+
function truncateOldToolOutputs(messages) {
|
|
124
|
+
const cutoff = Math.max(0, messages.length - PROTECTED_TAIL_MESSAGES);
|
|
125
|
+
let truncated = 0;
|
|
126
|
+
const next = messages.map((message, index) => {
|
|
127
|
+
if (index >= cutoff || message.role !== "tool") {
|
|
128
|
+
return message;
|
|
129
|
+
}
|
|
130
|
+
if (!Array.isArray(message.content)) {
|
|
131
|
+
return message;
|
|
132
|
+
}
|
|
133
|
+
let changed = false;
|
|
134
|
+
const content = message.content.map((part) => {
|
|
135
|
+
const resultPart = part;
|
|
136
|
+
if (resultPart?.type !== "tool-result") {
|
|
137
|
+
return part;
|
|
138
|
+
}
|
|
139
|
+
const serialized = toolResultOutputToText(resultPart.output);
|
|
140
|
+
if (serialized.length <= OLD_TOOL_OUTPUT_PREVIEW_BYTES) {
|
|
141
|
+
return part;
|
|
142
|
+
}
|
|
143
|
+
const { preview } = generateToolOutputPreview(serialized, {
|
|
144
|
+
maxBytes: OLD_TOOL_OUTPUT_PREVIEW_BYTES,
|
|
145
|
+
maxLines: OLD_TOOL_OUTPUT_PREVIEW_LINES,
|
|
146
|
+
});
|
|
147
|
+
changed = true;
|
|
148
|
+
truncated += 1;
|
|
149
|
+
return {
|
|
150
|
+
...resultPart,
|
|
151
|
+
output: { type: "text", value: preview },
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
return changed ? { ...message, content } : message;
|
|
155
|
+
});
|
|
156
|
+
return { messages: next, truncated };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Stage 2: drop the oldest complete tool exchanges — an assistant tool-call
|
|
160
|
+
* message together with ALL directly-following `tool` messages — until the
|
|
161
|
+
* estimate fits or only the protected head/tail remains. The first
|
|
162
|
+
* non-assistant message run (the task) is never dropped. A single elision
|
|
163
|
+
* note replaces everything removed so the model knows history was elided.
|
|
164
|
+
*/
|
|
165
|
+
function dropOldestToolExchanges(messages, budgetTokens, fixedOverheadTokens, provider) {
|
|
166
|
+
const result = [...messages];
|
|
167
|
+
let droppedExchanges = 0;
|
|
168
|
+
// Running-total accounting: estimate each message ONCE, keep the estimates
|
|
169
|
+
// array in lockstep with `result`, and subtract dropped blocks — instead of
|
|
170
|
+
// re-estimating the whole array on every iteration (O(n²) with many drops).
|
|
171
|
+
const estimates = result.map((message) => estimateTokens(contentToText(message.content), provider) +
|
|
172
|
+
TOKENS_PER_MESSAGE);
|
|
173
|
+
let currentTokens = fixedOverheadTokens + estimates.reduce((sum, tokens) => sum + tokens, 0);
|
|
174
|
+
while (currentTokens > budgetTokens) {
|
|
175
|
+
// Find the FIRST (oldest) droppable exchange outside the protected tail.
|
|
176
|
+
const tailStart = Math.max(0, result.length - PROTECTED_TAIL_MESSAGES);
|
|
177
|
+
let exchangeStart = -1;
|
|
178
|
+
for (let i = 0; i < tailStart; i++) {
|
|
179
|
+
if (isToolCallAssistantMessage(result[i])) {
|
|
180
|
+
exchangeStart = i;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (exchangeStart === -1) {
|
|
185
|
+
break; // nothing left the guard is allowed to drop
|
|
186
|
+
}
|
|
187
|
+
let exchangeEnd = exchangeStart + 1;
|
|
188
|
+
while (exchangeEnd < result.length && result[exchangeEnd].role === "tool") {
|
|
189
|
+
exchangeEnd++;
|
|
190
|
+
}
|
|
191
|
+
if (exchangeEnd > tailStart) {
|
|
192
|
+
// The oldest remaining exchange bleeds into the protected tail. Because
|
|
193
|
+
// the scan is oldest-first, every exchange after this one STARTS inside
|
|
194
|
+
// the tail (this one's result chain reaches it), and every exchange
|
|
195
|
+
// before it was already dropped by earlier iterations — so there is
|
|
196
|
+
// nothing else the guard may remove. Stop.
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
const dropped = estimates
|
|
200
|
+
.slice(exchangeStart, exchangeEnd)
|
|
201
|
+
.reduce((sum, tokens) => sum + tokens, 0);
|
|
202
|
+
result.splice(exchangeStart, exchangeEnd - exchangeStart);
|
|
203
|
+
estimates.splice(exchangeStart, exchangeEnd - exchangeStart);
|
|
204
|
+
currentTokens -= dropped;
|
|
205
|
+
droppedExchanges++;
|
|
206
|
+
}
|
|
207
|
+
if (droppedExchanges > 0) {
|
|
208
|
+
// Insert one elision note where history was removed: after the leading
|
|
209
|
+
// non-exchange messages (typically the first user/task message), but
|
|
210
|
+
// never after the protected tail — when every droppable exchange was
|
|
211
|
+
// removed, an uncapped scan would append the note at the END, where the
|
|
212
|
+
// "history was removed" cue lands after the content it refers to.
|
|
213
|
+
let noteIndex = 0;
|
|
214
|
+
while (noteIndex < result.length &&
|
|
215
|
+
!isToolCallAssistantMessage(result[noteIndex])) {
|
|
216
|
+
noteIndex++;
|
|
217
|
+
}
|
|
218
|
+
const tailBoundary = Math.max(0, result.length - PROTECTED_TAIL_MESSAGES);
|
|
219
|
+
result.splice(Math.min(noteIndex, tailBoundary), 0, {
|
|
220
|
+
role: "user",
|
|
221
|
+
content: [
|
|
222
|
+
{
|
|
223
|
+
type: "text",
|
|
224
|
+
text: `[context truncated: ${droppedExchanges} earlier tool exchange(s) were removed to fit the model's context window. Continue from the remaining context.]`,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return { messages: result, droppedExchanges };
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Create a per-step budget guard. Returns a function that, given the step's
|
|
233
|
+
* messages, returns a compacted replacement array when the projected request
|
|
234
|
+
* exceeds the threshold — or `undefined` when no change is needed.
|
|
235
|
+
*/
|
|
236
|
+
export function createStepBudgetGuard(config) {
|
|
237
|
+
const { provider, model, maxTokens, fixedOverheadTokens = 0, getFixedOverheadTokens, thresholdRatio = DEFAULT_CONTEXT_GUARD_RATIO, } = config;
|
|
238
|
+
const availableInput = getAvailableInputTokens(provider, model, maxTokens);
|
|
239
|
+
const thresholdTokens = Math.floor(availableInput * thresholdRatio);
|
|
240
|
+
return function guardStepMessages(messages) {
|
|
241
|
+
// Resolve overhead per invocation: the tool set can GROW mid-loop
|
|
242
|
+
// (search_tools hydration adds discovered tools between steps), so a
|
|
243
|
+
// once-captured value would undercount later steps.
|
|
244
|
+
const overheadTokens = getFixedOverheadTokens?.() ?? fixedOverheadTokens;
|
|
245
|
+
const estimate = overheadTokens + estimateStepMessagesTokens(messages, provider);
|
|
246
|
+
// Logger Guard: per-step diagnostics for debugging why a long run does
|
|
247
|
+
// (or does not) trigger compaction — gated so nothing is serialized when
|
|
248
|
+
// debug logging is off.
|
|
249
|
+
if (logger.shouldLog("debug")) {
|
|
250
|
+
logger.debug("[StepBudgetGuard] step estimate", {
|
|
251
|
+
provider,
|
|
252
|
+
model,
|
|
253
|
+
messageCount: messages.length,
|
|
254
|
+
estimatedTokens: estimate,
|
|
255
|
+
thresholdTokens,
|
|
256
|
+
willCompact: estimate > thresholdTokens,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (estimate <= thresholdTokens) {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
// Stage 1: shrink old tool outputs to previews.
|
|
263
|
+
const stage1 = truncateOldToolOutputs([...messages]);
|
|
264
|
+
let compacted = stage1.messages;
|
|
265
|
+
let newEstimate = overheadTokens + estimateStepMessagesTokens(compacted, provider);
|
|
266
|
+
// Stage 2: drop oldest complete tool exchanges if still over.
|
|
267
|
+
let droppedExchanges = 0;
|
|
268
|
+
if (newEstimate > thresholdTokens) {
|
|
269
|
+
const stage2 = dropOldestToolExchanges(compacted, thresholdTokens, overheadTokens, provider);
|
|
270
|
+
compacted = stage2.messages;
|
|
271
|
+
droppedExchanges = stage2.droppedExchanges;
|
|
272
|
+
newEstimate =
|
|
273
|
+
overheadTokens + estimateStepMessagesTokens(compacted, provider);
|
|
274
|
+
}
|
|
275
|
+
if (stage1.truncated === 0 && droppedExchanges === 0) {
|
|
276
|
+
return undefined; // nothing actionable (already all-protected)
|
|
277
|
+
}
|
|
278
|
+
logger.info("[StepBudgetGuard] Compacted agent-loop step messages", {
|
|
279
|
+
provider,
|
|
280
|
+
model,
|
|
281
|
+
estimatedTokens: estimate,
|
|
282
|
+
thresholdTokens,
|
|
283
|
+
afterTokens: newEstimate,
|
|
284
|
+
toolOutputsTruncated: stage1.truncated,
|
|
285
|
+
exchangesDropped: droppedExchanges,
|
|
286
|
+
});
|
|
287
|
+
return compacted;
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
//# sourceMappingURL=stepBudgetGuard.js.map
|
|
@@ -21,6 +21,7 @@ import { calculateCost } from "../../utils/pricing.js";
|
|
|
21
21
|
import { withProviderRetry } from "../../utils/providerRetry.js";
|
|
22
22
|
import { calculateCacheSavingsPercent, extractCacheCreationTokens, extractCacheReadTokens, extractTokenUsage, } from "../../utils/tokenUtils.js";
|
|
23
23
|
import { DEFAULT_MAX_STEPS } from "../constants.js";
|
|
24
|
+
import { createStepBudgetGuard, estimateFixedOverheadTokens, } from "../../context/stepBudgetGuard.js";
|
|
24
25
|
import { isTemperatureDeprecatedError, isSchemaComplexityError, isToolsSchemaConflictError, isToolsSchemaExclusionInForce, } from "./structuredOutputPolicy.js";
|
|
25
26
|
import { coerceJsonToSchema } from "../../utils/json/coerce.js";
|
|
26
27
|
import { NoObjectGeneratedError } from "../../utils/generationErrors.js";
|
|
@@ -116,6 +117,37 @@ export class GenerationHandler {
|
|
|
116
117
|
// rather than passing them inside `messages` (deprecated by the AI SDK,
|
|
117
118
|
// rejected in v7). See extractSystemMessages for the rationale. (#1024)
|
|
118
119
|
const { system, messages: nonSystemMessages } = extractSystemMessages(messages);
|
|
120
|
+
// Per-step context budget guard: the tool loop appends assistant turns and
|
|
121
|
+
// tool results on every step — growth the pre-call budget check never
|
|
122
|
+
// sees. Estimate each step's projected request and deterministically
|
|
123
|
+
// reclaim budget (truncate old tool outputs, then drop oldest exchanges)
|
|
124
|
+
// so long agentic runs cannot overflow the model's window mid-loop.
|
|
125
|
+
// Parity with the googleVertex native loops' createContextGuard, upgraded
|
|
126
|
+
// from stop-only to compact-and-continue. The caller's prepareStep result
|
|
127
|
+
// wins on conflicts; the guard only contributes `messages`.
|
|
128
|
+
//
|
|
129
|
+
// Overhead is resolved PER STEP because `toolsWithCache` is deliberately
|
|
130
|
+
// mutable (search_tools hydration adds discovered tools mid-loop) — a
|
|
131
|
+
// once-captured estimate would undercount later steps. Tools are only
|
|
132
|
+
// ever added, so memoizing on tool count keeps the common step O(1).
|
|
133
|
+
let cachedOverhead = { toolCount: -1, tokens: 0 };
|
|
134
|
+
const stepBudgetGuard = createStepBudgetGuard({
|
|
135
|
+
provider: this.providerName ?? "unknown",
|
|
136
|
+
model: this.modelName,
|
|
137
|
+
maxTokens: options.maxTokens,
|
|
138
|
+
getFixedOverheadTokens: () => {
|
|
139
|
+
const toolCount = shouldUseTools
|
|
140
|
+
? Object.keys(toolsWithCache).length
|
|
141
|
+
: 0;
|
|
142
|
+
if (toolCount !== cachedOverhead.toolCount) {
|
|
143
|
+
cachedOverhead = {
|
|
144
|
+
toolCount,
|
|
145
|
+
tokens: estimateFixedOverheadTokens(system, shouldUseTools ? toolsWithCache : undefined, this.providerName),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return cachedOverhead.tokens;
|
|
149
|
+
},
|
|
150
|
+
});
|
|
119
151
|
return await generateText({
|
|
120
152
|
model,
|
|
121
153
|
...(system && { system }),
|
|
@@ -125,11 +157,27 @@ export class GenerationHandler {
|
|
|
125
157
|
stopWhen: stepCountIs(options.maxSteps ?? DEFAULT_MAX_STEPS),
|
|
126
158
|
...(shouldUseTools &&
|
|
127
159
|
options.toolChoice && { toolChoice: options.toolChoice }),
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
160
|
+
experimental_prepareStep: (async (stepOptions) => {
|
|
161
|
+
// Public contract preserved: a caller-supplied prepareStep receives
|
|
162
|
+
// the ORIGINAL AI-SDK step options, exactly as before the guard
|
|
163
|
+
// existed — callers that inspect message history see the real thing.
|
|
164
|
+
const callerResult = prepareStep
|
|
165
|
+
? await prepareStep({
|
|
166
|
+
...stepOptions,
|
|
167
|
+
maxSteps: options.maxSteps ?? DEFAULT_MAX_STEPS,
|
|
168
|
+
})
|
|
169
|
+
: undefined;
|
|
170
|
+
// The guard runs LAST, on the messages that will actually be sent:
|
|
171
|
+
// the caller's override when one was returned (out-of-contract for
|
|
172
|
+
// NeuroLink's public prepareStep type, but possible at runtime), else
|
|
173
|
+
// the step's own messages. It never replaces a caller's content
|
|
174
|
+
// choices — it only reclaims budget from whatever was chosen.
|
|
175
|
+
const callerMessages = callerResult?.messages;
|
|
176
|
+
const compacted = stepBudgetGuard(callerMessages ?? stepOptions.messages);
|
|
177
|
+
if (!compacted) {
|
|
178
|
+
return callerResult;
|
|
179
|
+
}
|
|
180
|
+
return { ...(callerResult ?? {}), messages: compacted };
|
|
133
181
|
}),
|
|
134
182
|
temperature: options.temperature,
|
|
135
183
|
maxOutputTokens: options.maxTokens,
|
|
@@ -17,6 +17,12 @@ export declare class LiteLLMProvider extends OpenAIChatCompletionsProvider {
|
|
|
17
17
|
private static modelsCache;
|
|
18
18
|
private static modelsCacheTime;
|
|
19
19
|
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;
|
|
20
26
|
constructor(modelName?: string, sdk?: unknown, _region?: string, credentials?: {
|
|
21
27
|
apiKey?: string;
|
|
22
28
|
baseURL?: string;
|
|
@@ -44,6 +50,19 @@ export declare class LiteLLMProvider extends OpenAIChatCompletionsProvider {
|
|
|
44
50
|
* minimal safe default if the API fetch fails.
|
|
45
51
|
*/
|
|
46
52
|
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;
|
|
47
66
|
private fetchModelsFromAPI;
|
|
48
67
|
/**
|
|
49
68
|
* Generate an embedding for a single text input via native /v1/embeddings.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
|
2
|
+
import { registerRuntimeContextWindow } from "../constants/contextWindows.js";
|
|
2
3
|
import { createProxyFetch } from "../proxy/proxyFetch.js";
|
|
3
4
|
import { AuthenticationError, InvalidModelError, ModelAccessDeniedError, NetworkError, ProviderError, RateLimitError, isModelAccessDeniedMessage, parseAllowedModels, } from "../types/index.js";
|
|
4
5
|
import { isAbortError } from "../utils/errorHandling.js";
|
|
@@ -45,12 +46,24 @@ export class LiteLLMProvider extends OpenAIChatCompletionsProvider {
|
|
|
45
46
|
static modelsCache = [];
|
|
46
47
|
static modelsCacheTime = 0;
|
|
47
48
|
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();
|
|
48
55
|
constructor(modelName, sdk, _region, credentials) {
|
|
49
56
|
const envConfig = getLiteLLMConfig();
|
|
50
57
|
super("litellm", modelName, sdk, {
|
|
51
58
|
baseURL: credentials?.baseURL ?? envConfig.baseURL,
|
|
52
59
|
apiKey: credentials?.apiKey ?? envConfig.apiKey,
|
|
53
60
|
});
|
|
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();
|
|
54
67
|
logger.debug("LiteLLM Provider initialized", {
|
|
55
68
|
modelName: this.modelName,
|
|
56
69
|
provider: this.providerName,
|
|
@@ -208,6 +221,85 @@ export class LiteLLMProvider extends OpenAIChatCompletionsProvider {
|
|
|
208
221
|
}
|
|
209
222
|
return this.getFallbackModels();
|
|
210
223
|
}
|
|
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
|
+
}
|
|
211
303
|
async fetchModelsFromAPI() {
|
|
212
304
|
const modelsUrl = `${stripTrailingSlash(this.config.baseURL)}/v1/models`;
|
|
213
305
|
const proxyFetch = createProxyFetch();
|
|
@@ -219,6 +219,28 @@ export type BudgetCheckResult = {
|
|
|
219
219
|
fileAttachments: number;
|
|
220
220
|
};
|
|
221
221
|
};
|
|
222
|
+
/**
|
|
223
|
+
* Configuration for the per-step context budget guard that compacts the
|
|
224
|
+
* AI-SDK tool loop's messages before they overflow the model window
|
|
225
|
+
* (context/stepBudgetGuard.ts).
|
|
226
|
+
*/
|
|
227
|
+
export type StepBudgetGuardConfig = {
|
|
228
|
+
provider: string;
|
|
229
|
+
model?: string;
|
|
230
|
+
/** The caller's requested output budget (reserved out of the window). */
|
|
231
|
+
maxTokens?: number;
|
|
232
|
+
/** Static token cost of the hoisted system prompt + tool definitions. */
|
|
233
|
+
fixedOverheadTokens?: number;
|
|
234
|
+
/**
|
|
235
|
+
* Dynamic overhead resolver, re-evaluated on EVERY guard invocation. Takes
|
|
236
|
+
* precedence over `fixedOverheadTokens`. Use when the tool set can grow
|
|
237
|
+
* mid-loop (search_tools hydration) so newly added definitions count toward
|
|
238
|
+
* the budget.
|
|
239
|
+
*/
|
|
240
|
+
getFixedOverheadTokens?: () => number;
|
|
241
|
+
/** Override the trigger ratio; defaults to DEFAULT_CONTEXT_GUARD_RATIO. */
|
|
242
|
+
thresholdRatio?: number;
|
|
243
|
+
};
|
|
222
244
|
/** Parameters for budget checking. */
|
|
223
245
|
export type BudgetCheckParams = {
|
|
224
246
|
provider: string;
|
|
@@ -17,6 +17,12 @@ export declare class LiteLLMProvider extends OpenAIChatCompletionsProvider {
|
|
|
17
17
|
private static modelsCache;
|
|
18
18
|
private static modelsCacheTime;
|
|
19
19
|
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;
|
|
20
26
|
constructor(modelName?: string, sdk?: unknown, _region?: string, credentials?: {
|
|
21
27
|
apiKey?: string;
|
|
22
28
|
baseURL?: string;
|
|
@@ -44,6 +50,19 @@ export declare class LiteLLMProvider extends OpenAIChatCompletionsProvider {
|
|
|
44
50
|
* minimal safe default if the API fetch fails.
|
|
45
51
|
*/
|
|
46
52
|
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;
|
|
47
66
|
private fetchModelsFromAPI;
|
|
48
67
|
/**
|
|
49
68
|
* Generate an embedding for a single text input via native /v1/embeddings.
|