@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.
@@ -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
- ...(prepareStep && {
129
- experimental_prepareStep: ((stepOptions) => prepareStep({
130
- ...stepOptions,
131
- maxSteps: options.maxSteps ?? DEFAULT_MAX_STEPS,
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.