@juspay/neurolink 10.2.2 → 10.3.1

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 (70) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +425 -422
  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 +61 -0
  9. package/dist/context/stepBudgetGuard.js +328 -0
  10. package/dist/core/baseProvider.d.ts +12 -0
  11. package/dist/core/baseProvider.js +18 -0
  12. package/dist/core/modules/GenerationHandler.js +257 -43
  13. package/dist/core/modules/ToolsManager.d.ts +17 -0
  14. package/dist/core/modules/ToolsManager.js +99 -1
  15. package/dist/lib/constants/contextWindows.d.ts +24 -0
  16. package/dist/lib/constants/contextWindows.js +50 -0
  17. package/dist/lib/context/errorDetection.d.ts +6 -0
  18. package/dist/lib/context/errorDetection.js +18 -0
  19. package/dist/lib/context/stepBudgetGuard.d.ts +61 -0
  20. package/dist/lib/context/stepBudgetGuard.js +329 -0
  21. package/dist/lib/core/baseProvider.d.ts +12 -0
  22. package/dist/lib/core/baseProvider.js +18 -0
  23. package/dist/lib/core/modules/GenerationHandler.js +257 -43
  24. package/dist/lib/core/modules/ToolsManager.d.ts +17 -0
  25. package/dist/lib/core/modules/ToolsManager.js +99 -1
  26. package/dist/lib/mcp/toolDiscoveryService.js +59 -20
  27. package/dist/lib/neurolink.js +30 -9
  28. package/dist/lib/providers/litellm.d.ts +27 -19
  29. package/dist/lib/providers/litellm.js +171 -92
  30. package/dist/lib/providers/openaiChatCompletionsBase.d.ts +37 -1
  31. package/dist/lib/providers/openaiChatCompletionsBase.js +201 -33
  32. package/dist/lib/providers/openaiChatCompletionsClient.d.ts +23 -5
  33. package/dist/lib/providers/openaiChatCompletionsClient.js +94 -14
  34. package/dist/lib/proxy/proxyFetch.d.ts +17 -0
  35. package/dist/lib/proxy/proxyFetch.js +42 -4
  36. package/dist/lib/types/context.d.ts +22 -0
  37. package/dist/lib/types/generate.d.ts +18 -2
  38. package/dist/lib/types/openaiCompatible.d.ts +2 -0
  39. package/dist/lib/types/providers.d.ts +9 -0
  40. package/dist/lib/utils/errorHandling.js +8 -2
  41. package/dist/lib/utils/schemaConversion.d.ts +16 -0
  42. package/dist/lib/utils/schemaConversion.js +165 -8
  43. package/dist/lib/utils/timeout.d.ts +22 -0
  44. package/dist/lib/utils/timeout.js +72 -12
  45. package/dist/lib/utils/tokenLimits.js +22 -0
  46. package/dist/lib/utils/toolCallRepair.d.ts +8 -0
  47. package/dist/lib/utils/toolCallRepair.js +4 -1
  48. package/dist/mcp/toolDiscoveryService.js +59 -20
  49. package/dist/neurolink.js +30 -9
  50. package/dist/providers/litellm.d.ts +27 -19
  51. package/dist/providers/litellm.js +171 -92
  52. package/dist/providers/openaiChatCompletionsBase.d.ts +37 -1
  53. package/dist/providers/openaiChatCompletionsBase.js +201 -33
  54. package/dist/providers/openaiChatCompletionsClient.d.ts +23 -5
  55. package/dist/providers/openaiChatCompletionsClient.js +94 -14
  56. package/dist/proxy/proxyFetch.d.ts +17 -0
  57. package/dist/proxy/proxyFetch.js +42 -4
  58. package/dist/types/context.d.ts +22 -0
  59. package/dist/types/generate.d.ts +18 -2
  60. package/dist/types/openaiCompatible.d.ts +2 -0
  61. package/dist/types/providers.d.ts +9 -0
  62. package/dist/utils/errorHandling.js +8 -2
  63. package/dist/utils/schemaConversion.d.ts +16 -0
  64. package/dist/utils/schemaConversion.js +165 -8
  65. package/dist/utils/timeout.d.ts +22 -0
  66. package/dist/utils/timeout.js +72 -12
  67. package/dist/utils/tokenLimits.js +22 -0
  68. package/dist/utils/toolCallRepair.d.ts +8 -0
  69. package/dist/utils/toolCallRepair.js +4 -1
  70. package/package.json +6 -3
@@ -0,0 +1,61 @@
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 type { ModelMessage, StepBudgetGuardConfig } from "../types/index.js";
34
+ /** Estimate the token cost of a step's message array. */
35
+ export declare function estimateStepMessagesTokens(messages: readonly ModelMessage[], provider?: string): number;
36
+ /**
37
+ * Estimate the fixed per-request overhead: hoisted system prompt + tool
38
+ * definitions. Mirrors `checkContextBudget`'s categories for the pieces that
39
+ * do not live in the step messages.
40
+ */
41
+ export declare function estimateFixedOverheadTokens(system: unknown, tools: Record<string, unknown> | undefined, provider?: string): number;
42
+ /**
43
+ * Create a per-step budget guard. Returns a function that, given the step's
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.
60
+ */
61
+ export declare function createStepBudgetGuard(config: StepBudgetGuardConfig): (messages: readonly ModelMessage[], observedInputTokensLastStep?: number) => ModelMessage[] | undefined;
@@ -0,0 +1,329 @@
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
+ /**
41
+ * Upper bound on the usage-feedback calibration ratio. Real tokenizers count
42
+ * dense code/diff content at up to ~1.3× the char-based estimate; anything
43
+ * far beyond that indicates inconsistent provider usage reporting, and an
44
+ * unbounded ratio would compact the loop into uselessness.
45
+ */
46
+ const MAX_CALIBRATION_RATIO = 3;
47
+ /** Messages at the end of the conversation the guard never modifies. */
48
+ const PROTECTED_TAIL_MESSAGES = 4;
49
+ /** Stage-1 preview budget for an old tool output (bytes). */
50
+ const OLD_TOOL_OUTPUT_PREVIEW_BYTES = 2_048;
51
+ /** Stage-1 preview budget for an old tool output (lines). */
52
+ const OLD_TOOL_OUTPUT_PREVIEW_LINES = 60;
53
+ /**
54
+ * Serialize any ModelMessage content to text for estimation. Tool-call args
55
+ * and tool-result outputs are JSON-stringified; unserializable values fall
56
+ * back to a fixed-size placeholder so estimation never throws.
57
+ */
58
+ function contentToText(content) {
59
+ if (typeof content === "string") {
60
+ return content;
61
+ }
62
+ try {
63
+ return JSON.stringify(content) ?? "";
64
+ }
65
+ catch {
66
+ return "x".repeat(TOKENS_PER_TOOL_DEFINITION * 4);
67
+ }
68
+ }
69
+ /** Estimate the token cost of a step's message array. */
70
+ export function estimateStepMessagesTokens(messages, provider) {
71
+ let total = 0;
72
+ for (const message of messages) {
73
+ total +=
74
+ estimateTokens(contentToText(message.content), provider) +
75
+ TOKENS_PER_MESSAGE;
76
+ }
77
+ return total;
78
+ }
79
+ /**
80
+ * Estimate the fixed per-request overhead: hoisted system prompt + tool
81
+ * definitions. Mirrors `checkContextBudget`'s categories for the pieces that
82
+ * do not live in the step messages.
83
+ */
84
+ export function estimateFixedOverheadTokens(system, tools, provider) {
85
+ let total = system
86
+ ? estimateTokens(contentToText(system), provider) + TOKENS_PER_MESSAGE
87
+ : 0;
88
+ for (const tool of Object.values(tools ?? {})) {
89
+ try {
90
+ total += estimateTokens(JSON.stringify(tool) ?? "", provider);
91
+ }
92
+ catch {
93
+ total += TOKENS_PER_TOOL_DEFINITION;
94
+ }
95
+ }
96
+ return total;
97
+ }
98
+ /**
99
+ * Serialize a ToolResultOutput to the text the MODEL should see in a preview.
100
+ * Variant-aware: `text`/`error-text` carry their payload in `.value` directly —
101
+ * stringifying the wrapper would put escaped `{"type":"text","value":…}` JSON
102
+ * in front of the model instead of the actual output. `json`/`error-json`/
103
+ * `content` serialize their value; unknown shapes fall back to the wrapper.
104
+ */
105
+ function toolResultOutputToText(output) {
106
+ const variant = output;
107
+ if (variant && typeof variant === "object" && "type" in variant) {
108
+ if ((variant.type === "text" || variant.type === "error-text") &&
109
+ typeof variant.value === "string") {
110
+ return variant.value;
111
+ }
112
+ if (variant.type === "json" ||
113
+ variant.type === "error-json" ||
114
+ variant.type === "content") {
115
+ return contentToText(variant.value);
116
+ }
117
+ }
118
+ return contentToText(output);
119
+ }
120
+ /** True when the message is an assistant message that issues tool calls. */
121
+ function isToolCallAssistantMessage(message) {
122
+ return (message.role === "assistant" &&
123
+ Array.isArray(message.content) &&
124
+ message.content.some((part) => part?.type === "tool-call"));
125
+ }
126
+ /**
127
+ * Stage 1: replace large tool-result outputs outside the protected tail with
128
+ * head/tail previews. Returns the new array plus how many outputs shrank.
129
+ */
130
+ function truncateOldToolOutputs(messages) {
131
+ const cutoff = Math.max(0, messages.length - PROTECTED_TAIL_MESSAGES);
132
+ let truncated = 0;
133
+ const next = messages.map((message, index) => {
134
+ if (index >= cutoff || message.role !== "tool") {
135
+ return message;
136
+ }
137
+ if (!Array.isArray(message.content)) {
138
+ return message;
139
+ }
140
+ let changed = false;
141
+ const content = message.content.map((part) => {
142
+ const resultPart = part;
143
+ if (resultPart?.type !== "tool-result") {
144
+ return part;
145
+ }
146
+ const serialized = toolResultOutputToText(resultPart.output);
147
+ if (serialized.length <= OLD_TOOL_OUTPUT_PREVIEW_BYTES) {
148
+ return part;
149
+ }
150
+ const { preview } = generateToolOutputPreview(serialized, {
151
+ maxBytes: OLD_TOOL_OUTPUT_PREVIEW_BYTES,
152
+ maxLines: OLD_TOOL_OUTPUT_PREVIEW_LINES,
153
+ });
154
+ changed = true;
155
+ truncated += 1;
156
+ return {
157
+ ...resultPart,
158
+ output: { type: "text", value: preview },
159
+ };
160
+ });
161
+ return changed ? { ...message, content } : message;
162
+ });
163
+ return { messages: next, truncated };
164
+ }
165
+ /**
166
+ * Stage 2: drop the oldest complete tool exchanges — an assistant tool-call
167
+ * message together with ALL directly-following `tool` messages — until the
168
+ * estimate fits or only the protected head/tail remains. The first
169
+ * non-assistant message run (the task) is never dropped. A single elision
170
+ * note replaces everything removed so the model knows history was elided.
171
+ */
172
+ function dropOldestToolExchanges(messages, budgetTokens, fixedOverheadTokens, provider) {
173
+ const result = [...messages];
174
+ let droppedExchanges = 0;
175
+ // Running-total accounting: estimate each message ONCE, keep the estimates
176
+ // array in lockstep with `result`, and subtract dropped blocks — instead of
177
+ // re-estimating the whole array on every iteration (O(n²) with many drops).
178
+ const estimates = result.map((message) => estimateTokens(contentToText(message.content), provider) +
179
+ TOKENS_PER_MESSAGE);
180
+ let currentTokens = fixedOverheadTokens + estimates.reduce((sum, tokens) => sum + tokens, 0);
181
+ while (currentTokens > budgetTokens) {
182
+ // Find the FIRST (oldest) droppable exchange outside the protected tail.
183
+ const tailStart = Math.max(0, result.length - PROTECTED_TAIL_MESSAGES);
184
+ let exchangeStart = -1;
185
+ for (let i = 0; i < tailStart; i++) {
186
+ if (isToolCallAssistantMessage(result[i])) {
187
+ exchangeStart = i;
188
+ break;
189
+ }
190
+ }
191
+ if (exchangeStart === -1) {
192
+ break; // nothing left the guard is allowed to drop
193
+ }
194
+ let exchangeEnd = exchangeStart + 1;
195
+ while (exchangeEnd < result.length && result[exchangeEnd].role === "tool") {
196
+ exchangeEnd++;
197
+ }
198
+ if (exchangeEnd > tailStart) {
199
+ // The oldest remaining exchange bleeds into the protected tail. Because
200
+ // the scan is oldest-first, every exchange after this one STARTS inside
201
+ // the tail (this one's result chain reaches it), and every exchange
202
+ // before it was already dropped by earlier iterations — so there is
203
+ // nothing else the guard may remove. Stop.
204
+ break;
205
+ }
206
+ const dropped = estimates
207
+ .slice(exchangeStart, exchangeEnd)
208
+ .reduce((sum, tokens) => sum + tokens, 0);
209
+ result.splice(exchangeStart, exchangeEnd - exchangeStart);
210
+ estimates.splice(exchangeStart, exchangeEnd - exchangeStart);
211
+ currentTokens -= dropped;
212
+ droppedExchanges++;
213
+ }
214
+ if (droppedExchanges > 0) {
215
+ // Insert one elision note where history was removed: after the leading
216
+ // non-exchange messages (typically the first user/task message), but
217
+ // never after the protected tail — when every droppable exchange was
218
+ // removed, an uncapped scan would append the note at the END, where the
219
+ // "history was removed" cue lands after the content it refers to.
220
+ let noteIndex = 0;
221
+ while (noteIndex < result.length &&
222
+ !isToolCallAssistantMessage(result[noteIndex])) {
223
+ noteIndex++;
224
+ }
225
+ const tailBoundary = Math.max(0, result.length - PROTECTED_TAIL_MESSAGES);
226
+ result.splice(Math.min(noteIndex, tailBoundary), 0, {
227
+ role: "user",
228
+ content: [
229
+ {
230
+ type: "text",
231
+ text: `[context truncated: ${droppedExchanges} earlier tool exchange(s) were removed to fit the model's context window. Continue from the remaining context.]`,
232
+ },
233
+ ],
234
+ });
235
+ }
236
+ return { messages: result, droppedExchanges };
237
+ }
238
+ /**
239
+ * Create a per-step budget guard. Returns a function that, given the step's
240
+ * messages (and optionally the REAL input-token count the provider reported
241
+ * for the previous step), returns a compacted replacement array when the
242
+ * projected request exceeds the threshold — or `undefined` when no change is
243
+ * needed.
244
+ *
245
+ * Two dynamic behaviours:
246
+ * - the available-input budget is re-resolved on EVERY invocation, so
247
+ * runtime window discovery (`/model/info`, overflow self-healing) that
248
+ * lands mid-loop takes effect immediately instead of the guard staying
249
+ * frozen on the value captured at loop start;
250
+ * - usage feedback calibrates the estimator: the ratio between the real
251
+ * prompt tokens of the previous step and this guard's own estimate for
252
+ * what that step sent scales later estimates (only UP — underestimates
253
+ * overflow, overestimates merely compact earlier), eliminating the
254
+ * char-based estimator's drift on dense code/diff content without
255
+ * shipping a tokenizer.
256
+ */
257
+ export function createStepBudgetGuard(config) {
258
+ const { provider, model, maxTokens, fixedOverheadTokens = 0, getFixedOverheadTokens, thresholdRatio = DEFAULT_CONTEXT_GUARD_RATIO, } = config;
259
+ // Calibration state: raw estimate for the messages the PREVIOUS guard
260
+ // invocation let through (what was actually sent), and the current ratio.
261
+ let lastRawEstimate = 0;
262
+ let calibration = 1;
263
+ return function guardStepMessages(messages, observedInputTokensLastStep) {
264
+ const availableInput = getAvailableInputTokens(provider, model, maxTokens);
265
+ const thresholdTokens = Math.floor(availableInput * thresholdRatio);
266
+ // Resolve overhead per invocation: the tool set can GROW mid-loop
267
+ // (search_tools hydration adds discovered tools between steps), so a
268
+ // once-captured value would undercount later steps.
269
+ const overheadTokens = getFixedOverheadTokens?.() ?? fixedOverheadTokens;
270
+ const rawEstimate = overheadTokens + estimateStepMessagesTokens(messages, provider);
271
+ if (observedInputTokensLastStep !== undefined &&
272
+ observedInputTokensLastStep > 0 &&
273
+ lastRawEstimate > 0) {
274
+ calibration = Math.min(MAX_CALIBRATION_RATIO, Math.max(1, observedInputTokensLastStep / lastRawEstimate));
275
+ }
276
+ // Apply calibration to the THRESHOLD instead of every estimate so the
277
+ // compaction stages keep operating on raw numbers.
278
+ const effectiveThreshold = Math.floor(thresholdTokens / calibration);
279
+ // Logger Guard: per-step diagnostics for debugging why a long run does
280
+ // (or does not) trigger compaction — gated so nothing is serialized when
281
+ // debug logging is off.
282
+ if (logger.shouldLog("debug")) {
283
+ logger.debug("[StepBudgetGuard] step estimate", {
284
+ provider,
285
+ model,
286
+ messageCount: messages.length,
287
+ estimatedTokens: rawEstimate,
288
+ thresholdTokens: effectiveThreshold,
289
+ calibration,
290
+ observedInputTokensLastStep,
291
+ willCompact: rawEstimate > effectiveThreshold,
292
+ });
293
+ }
294
+ if (rawEstimate <= effectiveThreshold) {
295
+ lastRawEstimate = rawEstimate;
296
+ return undefined;
297
+ }
298
+ // Stage 1: shrink old tool outputs to previews.
299
+ const stage1 = truncateOldToolOutputs([...messages]);
300
+ let compacted = stage1.messages;
301
+ let newEstimate = overheadTokens + estimateStepMessagesTokens(compacted, provider);
302
+ // Stage 2: drop oldest complete tool exchanges if still over.
303
+ let droppedExchanges = 0;
304
+ if (newEstimate > effectiveThreshold) {
305
+ const stage2 = dropOldestToolExchanges(compacted, effectiveThreshold, overheadTokens, provider);
306
+ compacted = stage2.messages;
307
+ droppedExchanges = stage2.droppedExchanges;
308
+ newEstimate =
309
+ overheadTokens + estimateStepMessagesTokens(compacted, provider);
310
+ }
311
+ if (stage1.truncated === 0 && droppedExchanges === 0) {
312
+ lastRawEstimate = rawEstimate;
313
+ return undefined; // nothing actionable (already all-protected)
314
+ }
315
+ logger.info("[StepBudgetGuard] Compacted agent-loop step messages", {
316
+ provider,
317
+ model,
318
+ estimatedTokens: rawEstimate,
319
+ thresholdTokens: effectiveThreshold,
320
+ calibration,
321
+ afterTokens: newEstimate,
322
+ toolOutputsTruncated: stage1.truncated,
323
+ exchangesDropped: droppedExchanges,
324
+ });
325
+ lastRawEstimate = newEstimate;
326
+ return compacted;
327
+ };
328
+ }
329
+ //# sourceMappingURL=stepBudgetGuard.js.map
@@ -185,6 +185,18 @@ export declare abstract class BaseProvider implements AIProvider {
185
185
  * IMPLEMENTATION NOTE: Uses streamText() under the hood and accumulates results
186
186
  * for consistency and better performance
187
187
  */
188
+ /**
189
+ * Ensure runtime-discovered model limits (context window, output-token
190
+ * ceiling) are registered before any budget math runs. Generation
191
+ * pipelines await this BEFORE `checkContextBudget`, and generate()/stream()
192
+ * await it before options normalization so `getSafeMaxTokens` sees the
193
+ * discovered output ceiling.
194
+ *
195
+ * Default no-op. Providers with a runtime discovery source override it
196
+ * (e.g. LiteLLM's `/model/info`). Implementations must NEVER reject —
197
+ * discovery failure degrades to the static defaults.
198
+ */
199
+ ensureModelLimits(): Promise<void>;
188
200
  generate(optionsOrPrompt: TextGenerationOptions | string, _analysisSchema?: ValidationSchema): Promise<EnhancedGenerateResult | null>;
189
201
  /**
190
202
  * Alias for generate method - implements AIProvider interface
@@ -118,6 +118,9 @@ export class BaseProvider {
118
118
  * When tools are involved, falls back to generate() with synthetic streaming
119
119
  */
120
120
  async stream(optionsOrPrompt, analysisSchema) {
121
+ // Runtime model limits must land before normalizeStreamOptions resolves
122
+ // maxTokens (getSafeMaxTokens consults the discovered output ceiling).
123
+ await this.ensureModelLimits();
121
124
  let options = this.normalizeStreamOptions(optionsOrPrompt);
122
125
  logger.info(`Starting stream`, {
123
126
  provider: this.providerName,
@@ -855,7 +858,22 @@ export class BaseProvider {
855
858
  * IMPLEMENTATION NOTE: Uses streamText() under the hood and accumulates results
856
859
  * for consistency and better performance
857
860
  */
861
+ /**
862
+ * Ensure runtime-discovered model limits (context window, output-token
863
+ * ceiling) are registered before any budget math runs. Generation
864
+ * pipelines await this BEFORE `checkContextBudget`, and generate()/stream()
865
+ * await it before options normalization so `getSafeMaxTokens` sees the
866
+ * discovered output ceiling.
867
+ *
868
+ * Default no-op. Providers with a runtime discovery source override it
869
+ * (e.g. LiteLLM's `/model/info`). Implementations must NEVER reject —
870
+ * discovery failure degrades to the static defaults.
871
+ */
872
+ async ensureModelLimits() { }
858
873
  async generate(optionsOrPrompt, _analysisSchema) {
874
+ // Runtime model limits must land before normalizeTextOptions resolves
875
+ // maxTokens (getSafeMaxTokens consults the discovered output ceiling).
876
+ await this.ensureModelLimits();
859
877
  const options = this.normalizeTextOptions(optionsOrPrompt);
860
878
  this.validateOptions(options);
861
879
  const startTime = Date.now();