@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
@@ -3,6 +3,23 @@
3
3
  * Supports HTTP/HTTPS, SOCKS4/5, authentication, and NO_PROXY bypass
4
4
  * Lightweight implementation extracted from research of major proxy packages
5
5
  */
6
+ /**
7
+ * Classify a fetch failure as a transient network error worth retrying.
8
+ *
9
+ * undici's `fetch()` wraps the real failure in `TypeError: fetch failed`
10
+ * with the actionable code (`ECONNRESET`, `UND_ERR_SOCKET`, ...) on
11
+ * `error.cause` — sometimes nested another level (e.g. SocketError inside
12
+ * a ConnectTimeoutError). Walk the cause chain so those are recognized;
13
+ * checking only the top-level error silently classified every undici
14
+ * connection death as non-retryable.
15
+ *
16
+ * Deliberately NOT retried: `UND_ERR_HEADERS_TIMEOUT` / `UND_ERR_BODY_TIMEOUT`
17
+ * — those already waited out undici's own long deadline (default 300s), and
18
+ * replaying them can triple a stall under the caller's wall-clock budget.
19
+ *
20
+ * Exported for direct coverage by the no-API test suite.
21
+ */
22
+ export declare function isTransientNetworkError(error: unknown): boolean;
6
23
  /**
7
24
  * Create a proxy-aware fetch function with enhanced capabilities
8
25
  * Supports HTTP/HTTPS, SOCKS4/5, authentication, and NO_PROXY bypass
@@ -79,6 +79,47 @@ function extractHostname(url) {
79
79
  return "[unknown]";
80
80
  }
81
81
  }
82
+ /** Error codes classified as transient (module-scope: the retry path is hot). */
83
+ const TRANSIENT_NETWORK_CODES = new Set([
84
+ "ECONNRESET",
85
+ "ETIMEDOUT",
86
+ "ECONNREFUSED",
87
+ "EPIPE",
88
+ "UND_ERR_SOCKET",
89
+ "UND_ERR_CONNECT_TIMEOUT",
90
+ ]);
91
+ /**
92
+ * Classify a fetch failure as a transient network error worth retrying.
93
+ *
94
+ * undici's `fetch()` wraps the real failure in `TypeError: fetch failed`
95
+ * with the actionable code (`ECONNRESET`, `UND_ERR_SOCKET`, ...) on
96
+ * `error.cause` — sometimes nested another level (e.g. SocketError inside
97
+ * a ConnectTimeoutError). Walk the cause chain so those are recognized;
98
+ * checking only the top-level error silently classified every undici
99
+ * connection death as non-retryable.
100
+ *
101
+ * Deliberately NOT retried: `UND_ERR_HEADERS_TIMEOUT` / `UND_ERR_BODY_TIMEOUT`
102
+ * — those already waited out undici's own long deadline (default 300s), and
103
+ * replaying them can triple a stall under the caller's wall-clock budget.
104
+ *
105
+ * Exported for direct coverage by the no-API test suite.
106
+ */
107
+ export function isTransientNetworkError(error) {
108
+ let current = error;
109
+ for (let depth = 0; depth < 5 && current; depth++) {
110
+ const err = current;
111
+ if (err.code && TRANSIENT_NETWORK_CODES.has(err.code)) {
112
+ return true;
113
+ }
114
+ if (err.message?.includes("socket hang up") ||
115
+ err.message?.includes("network socket disconnected") ||
116
+ err.message?.includes("other side closed")) {
117
+ return true;
118
+ }
119
+ current = err.cause;
120
+ }
121
+ return false;
122
+ }
82
123
  /**
83
124
  * Retry-aware fetch wrapper for transient network errors (ECONNRESET, ETIMEDOUT, socket hang up).
84
125
  * Protects all LLM API calls and token refreshes that go through createProxyFetch().
@@ -104,11 +145,8 @@ async function fetchWithRetry(url, init, maxRetries = 3, baseDelay = 500) {
104
145
  return response;
105
146
  }
106
147
  catch (error) {
148
+ const isRetryable = isTransientNetworkError(error);
107
149
  const err = error;
108
- const isRetryable = err?.code === "ECONNRESET" ||
109
- err?.code === "ETIMEDOUT" ||
110
- err?.message?.includes("socket hang up") ||
111
- err?.message?.includes("network socket disconnected");
112
150
  if (!isRetryable || attempt === maxRetries) {
113
151
  // Final failure — record on span and rethrow
114
152
  span.setAttribute("http.request.total_attempts", totalAttempts);
@@ -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;
@@ -323,8 +323,15 @@ export type GenerateOptions = {
323
323
  * never the step-cap text. Unset = no turn-level deadline (the library
324
324
  * imposes no product policy).
325
325
  *
326
- * Enforced by the native Vertex loops (Gemini + Claude); AI-SDK-driven
327
- * providers currently ignore it.
326
+ * Enforced by the native Vertex loops (Gemini + Claude) AND the AI-SDK
327
+ * loop path (litellm and other OpenAI-compatible providers). On the AI-SDK
328
+ * path, an explicit `timeout` also engages the same wrap-up when
329
+ * `turnTimeoutMs` is unset. Once the wrap-up window begins (see
330
+ * `wrapupTimeLeadMs`), the loop forcibly sets `toolChoice: "none"` for the
331
+ * remaining steps — overriding any caller-supplied `toolChoice` or
332
+ * `prepareStep` tool selection — and appends an honest time message that a
333
+ * caller's `prepareStep` callback does not observe (it runs before the
334
+ * wrap-up nudge is applied). An honest partial beats a discarded turn.
328
335
  */
329
336
  turnTimeoutMs?: number;
330
337
  /**
@@ -340,6 +347,11 @@ export type GenerateOptions = {
340
347
  * next tool-result turn telling the model to consolidate what it has and
341
348
  * produce its final answer. Defaults to 120_000 when `turnTimeoutMs` is
342
349
  * set; ignored when it is not.
350
+ *
351
+ * On the AI-SDK loop path the lead is clamped to a quarter of the turn
352
+ * budget (so short budgets don't wrap up on step one) and wrap-up steps
353
+ * run with a forced `toolChoice: "none"` — see `turnTimeoutMs` for the
354
+ * exact override semantics.
343
355
  */
344
356
  wrapupTimeLeadMs?: number;
345
357
  /**
@@ -350,6 +362,8 @@ export type GenerateOptions = {
350
362
  toolTimeoutMs?: number;
351
363
  /** AbortSignal for external cancellation of the AI call */
352
364
  abortSignal?: AbortSignal;
365
+ /** Disable the schema-driven tool call repair mechanism (BZ-665). Default: false (repair enabled). */
366
+ disableToolCallRepair?: boolean;
353
367
  /**
354
368
  * Disable tool execution (including built-in tools)
355
369
  *
@@ -1015,6 +1029,8 @@ export type TextGenerationOptions = {
1015
1029
  /** AbortSignal for external cancellation of the AI call */
1016
1030
  abortSignal?: AbortSignal;
1017
1031
  disableTools?: boolean;
1032
+ /** Disable the schema-driven tool call repair mechanism (BZ-665). Default: false (repair enabled). */
1033
+ disableToolCallRepair?: boolean;
1018
1034
  maxSteps?: number;
1019
1035
  /** Include only these tools by name (whitelist). If set, only matching tools are available. */
1020
1036
  toolFilter?: string[];
@@ -226,6 +226,8 @@ export type StreamLoopArgs = {
226
226
  openAITools: OpenAICompatChatTool[] | undefined;
227
227
  openAIToolChoice: OpenAICompatToolChoiceWire | undefined;
228
228
  toolsRecord: Record<string, Tool>;
229
+ /** Wire → registered tool-name map when sanitization was needed (see buildWireToolNameMaps). */
230
+ toolNameFromWire?: Map<string, string>;
229
231
  emitter: TypedEventEmitter<NeuroLinkEvents> | undefined;
230
232
  toolsUsed: string[];
231
233
  toolExecutionSummaries: ToolExecutionSummaryInternal[];
@@ -601,6 +601,15 @@ export type AIProvider = {
601
601
  * external AIProvider implementations — callers treat absence as `true`.
602
602
  */
603
603
  supportsTools?(): boolean;
604
+ /**
605
+ * Ensure runtime-discovered model limits (context window, output-token
606
+ * ceiling) are registered before budget math runs. Implemented by
607
+ * BaseProvider (default no-op); providers with a discovery source override
608
+ * it (LiteLLM `/model/info`). Must never reject — discovery failure
609
+ * degrades to static defaults. Optional for compile compatibility with
610
+ * external AIProvider implementations — callers treat absence as no-op.
611
+ */
612
+ ensureModelLimits?(): Promise<void>;
604
613
  };
605
614
  /**
606
615
  * Provider attempt result for iteration tracking (converted from interface)
@@ -1012,12 +1012,18 @@ export class ErrorFactory {
1012
1012
  * Timeout wrapper for async operations
1013
1013
  */
1014
1014
  export async function withTimeout(promise, timeoutMs, timeoutError) {
1015
+ let timer;
1015
1016
  const timeoutPromise = new Promise((_, reject) => {
1016
- setTimeout(() => {
1017
+ timer = setTimeout(() => {
1017
1018
  reject(timeoutError || new Error(`Operation timed out after ${timeoutMs}ms`));
1018
1019
  }, timeoutMs);
1019
1020
  });
1020
- return Promise.race([promise, timeoutPromise]);
1021
+ try {
1022
+ return await Promise.race([promise, timeoutPromise]);
1023
+ }
1024
+ finally {
1025
+ clearTimeout(timer);
1026
+ }
1021
1027
  }
1022
1028
  /**
1023
1029
  * Retry mechanism for retriable operations
@@ -29,6 +29,22 @@ export declare function ensureNestedSchemaTypes(schema: Record<string, unknown>)
29
29
  */
30
30
  export declare function convertZodToJsonSchema(zodSchema: ZodUnknownSchema, target?: "jsonSchema7" | "openApi3"): object;
31
31
  export declare function normalizeJsonSchemaObject(schema: Record<string, unknown> | undefined | null): Record<string, unknown>;
32
+ /**
33
+ * Normalize a JSON Schema for the OpenAI chat-completions `tools` wire
34
+ * block. Generic proxied backends (LiteLLM → vllm/GLM/Qwen, local servers)
35
+ * render tool schemas into chat templates more or less verbatim, so `$ref`
36
+ * indirection, `$defs` containers, `$schema` annotations, and zod's
37
+ * nullable `anyOf` pattern all measurably degrade argument generation —
38
+ * this is the parity gap behind "the model can't find the right arguments"
39
+ * on litellm while the same tools work on Gemini/Claude native paths
40
+ * (which sanitize schemas before the wire).
41
+ *
42
+ * Pipeline: strip annotation keys → inline local `$ref`/`$defs`/
43
+ * `definitions` → collapse nullable variants → guarantee a top-level object
44
+ * shape. Identity for already-clean schemas; `description`, `required`,
45
+ * `enum`, and `default` — what models actually read — are always preserved.
46
+ */
47
+ export declare function normalizeWireToolSchema(schema: unknown): Record<string, unknown>;
32
48
  /**
33
49
  * Check if a value is a Zod schema
34
50
  */
@@ -80,9 +80,11 @@ function safePercentDecode(segment) {
80
80
  * - Circular reference detection to prevent infinite loops
81
81
  */
82
82
  export function inlineJsonSchema(schema, definitions, visited = new Set(), rootSchema) {
83
- // Use definitions from schema if not provided
83
+ // Use definitions from schema if not provided. Modern MCP servers and
84
+ // zod v4's native toJSONSchema emit the 2020-12 `$defs` keyword; older
85
+ // emitters use draft-07 `definitions`. Support both.
84
86
  const defs = definitions ||
85
- schema.definitions;
87
+ (schema.definitions ?? schema.$defs);
86
88
  // Keep track of the root schema for deep ref resolution
87
89
  const root = rootSchema || schema;
88
90
  // Handle $ref at current level
@@ -94,10 +96,14 @@ export function inlineJsonSchema(schema, definitions, visited = new Set(), rootS
94
96
  // Return a simple object placeholder for circular refs
95
97
  return { type: "object" };
96
98
  }
97
- // Try simple definition lookup first (for #/definitions/SomeName)
98
- if (refPath.startsWith("#/definitions/")) {
99
- const defName = refPath.replace("#/definitions/", "");
100
- // Check if it's a simple definition name (no slashes after definitions/)
99
+ // Try simple definition lookup first (#/definitions/SomeName or the
100
+ // 2020-12 #/$defs/SomeName form)
101
+ if (refPath.startsWith("#/definitions/") ||
102
+ refPath.startsWith("#/$defs/")) {
103
+ const defName = refPath
104
+ .replace("#/definitions/", "")
105
+ .replace("#/$defs/", "");
106
+ // Check if it's a simple definition name (no slashes after the prefix)
101
107
  if (!defName.includes("/") && defs && defs[defName]) {
102
108
  visited.add(refPath);
103
109
  const resolved = inlineJsonSchema({ ...defs[defName] }, defs, visited, root);
@@ -124,8 +130,8 @@ export function inlineJsonSchema(schema, definitions, visited = new Set(), rootS
124
130
  // Create result without $ref and definitions
125
131
  const result = {};
126
132
  for (const [key, value] of Object.entries(schema)) {
127
- // Skip $ref and definitions keys
128
- if (key === "$ref" || key === "definitions") {
133
+ // Skip $ref and definition-container keys (both draft-07 and 2020-12)
134
+ if (key === "$ref" || key === "definitions" || key === "$defs") {
129
135
  continue;
130
136
  }
131
137
  // Recursively process nested schemas
@@ -402,6 +408,157 @@ function ensureTypeField(schema) {
402
408
  }
403
409
  return schema;
404
410
  }
411
+ /** JSON Schema keys whose values are themselves schemas (single). */
412
+ const SCHEMA_VALUE_KEYS = new Set([
413
+ "items",
414
+ "additionalProperties",
415
+ "not",
416
+ "if",
417
+ "then",
418
+ "else",
419
+ "contains",
420
+ "propertyNames",
421
+ ]);
422
+ /** JSON Schema keys whose values are arrays of schemas. */
423
+ const SCHEMA_ARRAY_KEYS = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]);
424
+ /** JSON Schema keys whose values are name → schema maps. */
425
+ const SCHEMA_MAP_KEYS = new Set([
426
+ "properties",
427
+ "patternProperties",
428
+ "$defs",
429
+ "definitions",
430
+ ]);
431
+ /**
432
+ * Annotation keys that carry no validation semantics for a model and that
433
+ * generic OpenAI-compatible backends (vllm, GLM/Qwen chat templates, local
434
+ * servers) may render into the prompt verbatim, confusing argument
435
+ * generation. Removed only at SCHEMA positions — never inside data values
436
+ * like `default`, `const`, `enum` or `examples`, where `$id` etc. are
437
+ * legitimate payload.
438
+ */
439
+ const SCHEMA_ANNOTATION_KEYS = new Set(["$schema", "$id", "$comment"]);
440
+ /** Walk schema positions only, dropping annotation-only keys. */
441
+ function stripSchemaAnnotations(schema) {
442
+ const result = {};
443
+ for (const [key, value] of Object.entries(schema)) {
444
+ if (SCHEMA_ANNOTATION_KEYS.has(key)) {
445
+ continue;
446
+ }
447
+ if (SCHEMA_VALUE_KEYS.has(key) &&
448
+ value &&
449
+ typeof value === "object" &&
450
+ !Array.isArray(value)) {
451
+ result[key] = stripSchemaAnnotations(value);
452
+ }
453
+ else if (SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(value)) {
454
+ result[key] = value.map((item) => item && typeof item === "object" && !Array.isArray(item)
455
+ ? stripSchemaAnnotations(item)
456
+ : item);
457
+ }
458
+ else if (SCHEMA_MAP_KEYS.has(key) &&
459
+ value &&
460
+ typeof value === "object" &&
461
+ !Array.isArray(value)) {
462
+ const map = {};
463
+ for (const [name, sub] of Object.entries(value)) {
464
+ map[name] =
465
+ sub && typeof sub === "object" && !Array.isArray(sub)
466
+ ? stripSchemaAnnotations(sub)
467
+ : sub;
468
+ }
469
+ result[key] = map;
470
+ }
471
+ else {
472
+ result[key] = value;
473
+ }
474
+ }
475
+ return result;
476
+ }
477
+ /**
478
+ * Collapse the zod-emitted nullable pattern `anyOf/oneOf: [T, {type:"null"}]`
479
+ * into `{...T, type: [t, "null"]}`. Weaker OSS models handle a plain type
480
+ * array far better than composition keywords when choosing tool arguments.
481
+ * Conservative: only fires for exactly-two variants where the non-null
482
+ * variant has a primitive `type` string — everything else passes through.
483
+ */
484
+ function collapseNullableVariants(schema) {
485
+ const result = {};
486
+ for (const [key, value] of Object.entries(schema)) {
487
+ if (SCHEMA_VALUE_KEYS.has(key) &&
488
+ value &&
489
+ typeof value === "object" &&
490
+ !Array.isArray(value)) {
491
+ result[key] = collapseNullableVariants(value);
492
+ }
493
+ else if (SCHEMA_ARRAY_KEYS.has(key) && Array.isArray(value)) {
494
+ result[key] = value.map((item) => item && typeof item === "object" && !Array.isArray(item)
495
+ ? collapseNullableVariants(item)
496
+ : item);
497
+ }
498
+ else if (SCHEMA_MAP_KEYS.has(key) &&
499
+ value &&
500
+ typeof value === "object" &&
501
+ !Array.isArray(value)) {
502
+ const map = {};
503
+ for (const [name, sub] of Object.entries(value)) {
504
+ map[name] =
505
+ sub && typeof sub === "object" && !Array.isArray(sub)
506
+ ? collapseNullableVariants(sub)
507
+ : sub;
508
+ }
509
+ result[key] = map;
510
+ }
511
+ else {
512
+ result[key] = value;
513
+ }
514
+ }
515
+ for (const compositionKey of ["anyOf", "oneOf"]) {
516
+ const variants = result[compositionKey];
517
+ if (!Array.isArray(variants) || variants.length !== 2) {
518
+ continue;
519
+ }
520
+ const isNullVariant = (v) => !!v &&
521
+ typeof v === "object" &&
522
+ v.type === "null";
523
+ const nullIndex = variants.findIndex(isNullVariant);
524
+ if (nullIndex === -1) {
525
+ continue;
526
+ }
527
+ const other = variants[1 - nullIndex];
528
+ if (!other ||
529
+ typeof other !== "object" ||
530
+ Array.isArray(other) ||
531
+ typeof other.type !== "string" ||
532
+ other.type === "null") {
533
+ continue;
534
+ }
535
+ // Merge: variant content + outer annotations (description etc.) win.
536
+ const { [compositionKey]: _dropped, ...outer } = result;
537
+ return { ...other, ...outer, type: [other.type, "null"] };
538
+ }
539
+ return result;
540
+ }
541
+ /**
542
+ * Normalize a JSON Schema for the OpenAI chat-completions `tools` wire
543
+ * block. Generic proxied backends (LiteLLM → vllm/GLM/Qwen, local servers)
544
+ * render tool schemas into chat templates more or less verbatim, so `$ref`
545
+ * indirection, `$defs` containers, `$schema` annotations, and zod's
546
+ * nullable `anyOf` pattern all measurably degrade argument generation —
547
+ * this is the parity gap behind "the model can't find the right arguments"
548
+ * on litellm while the same tools work on Gemini/Claude native paths
549
+ * (which sanitize schemas before the wire).
550
+ *
551
+ * Pipeline: strip annotation keys → inline local `$ref`/`$defs`/
552
+ * `definitions` → collapse nullable variants → guarantee a top-level object
553
+ * shape. Identity for already-clean schemas; `description`, `required`,
554
+ * `enum`, and `default` — what models actually read — are always preserved.
555
+ */
556
+ export function normalizeWireToolSchema(schema) {
557
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
558
+ return { type: "object", properties: {} };
559
+ }
560
+ return ensureTypeField(collapseNullableVariants(inlineJsonSchema(stripSchemaAnnotations(schema))));
561
+ }
405
562
  /**
406
563
  * Check if a value is a Zod schema
407
564
  */
@@ -128,8 +128,30 @@ export declare function createTimeoutController(timeout: number | string | undef
128
128
  * @returns Combined AbortSignal, or undefined if neither is present
129
129
  */
130
130
  export declare function composeAbortSignals(externalSignal?: AbortSignal, timeoutSignal?: AbortSignal): AbortSignal | undefined;
131
+ /**
132
+ * Scoped variant of {@link composeAbortSignals} for per-step / per-request
133
+ * composition against a LONG-LIVED external signal (e.g. the generate-call
134
+ * signal inside a multi-step agent loop). `AbortSignal.any` keeps its source
135
+ * registration alive until the derived signal is GC'd, so composing per step
136
+ * accumulates listeners on the outer signal for the whole turn
137
+ * (MaxListenersExceededWarning at 10+ steps). This helper registers plain
138
+ * listeners instead and returns a `dispose()` that removes them the moment
139
+ * the step settles.
140
+ */
141
+ export declare function composeAbortSignalsScoped(externalSignal?: AbortSignal, timeoutSignal?: AbortSignal): {
142
+ signal: AbortSignal | undefined;
143
+ dispose: () => void;
144
+ };
131
145
  /**
132
146
  * Merge abort signals (for combining user abort with timeout)
147
+ *
148
+ * Implemented via `AbortSignal.any` with a single once-listener forward, so
149
+ * no per-source listeners are left behind on long-lived input signals once
150
+ * the merged controller becomes unreachable (registrations are released with
151
+ * the derived signal). The previous implementation attached one permanent
152
+ * listener per source per call — repeated stream calls sharing one caller
153
+ * signal accumulated listeners for the life of that signal.
154
+ *
133
155
  * @param signals - Array of abort signals to merge
134
156
  * @returns Combined abort controller
135
157
  */
@@ -342,25 +342,85 @@ export function composeAbortSignals(externalSignal, timeoutSignal) {
342
342
  }
343
343
  return externalSignal ?? timeoutSignal;
344
344
  }
345
+ /**
346
+ * Scoped variant of {@link composeAbortSignals} for per-step / per-request
347
+ * composition against a LONG-LIVED external signal (e.g. the generate-call
348
+ * signal inside a multi-step agent loop). `AbortSignal.any` keeps its source
349
+ * registration alive until the derived signal is GC'd, so composing per step
350
+ * accumulates listeners on the outer signal for the whole turn
351
+ * (MaxListenersExceededWarning at 10+ steps). This helper registers plain
352
+ * listeners instead and returns a `dispose()` that removes them the moment
353
+ * the step settles.
354
+ */
355
+ export function composeAbortSignalsScoped(externalSignal, timeoutSignal) {
356
+ if (!externalSignal || !timeoutSignal) {
357
+ return { signal: externalSignal ?? timeoutSignal, dispose: () => { } };
358
+ }
359
+ const controller = new AbortController();
360
+ const sources = [externalSignal, timeoutSignal];
361
+ const listeners = [];
362
+ const dispose = () => {
363
+ for (const { source, listener } of listeners) {
364
+ source.removeEventListener("abort", listener);
365
+ }
366
+ listeners.length = 0;
367
+ };
368
+ const onAbort = (source) => {
369
+ if (!controller.signal.aborted) {
370
+ controller.abort(source.reason);
371
+ }
372
+ // Self-clean on abort: once the composed signal fired, no source
373
+ // listener has any work left — detach from the OTHER source too (the
374
+ // firing one auto-removed via `once`), so callers that only observe
375
+ // `signal.aborted` and never reach their dispose() leave nothing behind.
376
+ dispose();
377
+ };
378
+ for (const source of sources) {
379
+ const listener = () => onAbort(source);
380
+ source.addEventListener("abort", listener, { once: true });
381
+ listeners.push({ source, listener });
382
+ }
383
+ for (const source of sources) {
384
+ if (source.aborted) {
385
+ onAbort(source);
386
+ break;
387
+ }
388
+ }
389
+ return { signal: controller.signal, dispose };
390
+ }
345
391
  /**
346
392
  * Merge abort signals (for combining user abort with timeout)
393
+ *
394
+ * Implemented via `AbortSignal.any` with a single once-listener forward, so
395
+ * no per-source listeners are left behind on long-lived input signals once
396
+ * the merged controller becomes unreachable (registrations are released with
397
+ * the derived signal). The previous implementation attached one permanent
398
+ * listener per source per call — repeated stream calls sharing one caller
399
+ * signal accumulated listeners for the life of that signal.
400
+ *
347
401
  * @param signals - Array of abort signals to merge
348
402
  * @returns Combined abort controller
349
403
  */
350
404
  export function mergeAbortSignals(signals) {
351
405
  const controller = new AbortController();
352
- for (const signal of signals) {
353
- if (signal && !signal.aborted) {
354
- signal.addEventListener("abort", () => {
355
- if (!controller.signal.aborted) {
356
- controller.abort(signal.reason);
357
- }
358
- });
359
- }
360
- if (signal?.aborted) {
361
- controller.abort(signal.reason);
362
- break;
363
- }
406
+ const active = signals.filter((s) => s !== undefined);
407
+ const aborted = active.find((s) => s.aborted);
408
+ if (aborted) {
409
+ controller.abort(aborted.reason);
410
+ return controller;
364
411
  }
412
+ if (active.length === 0) {
413
+ return controller;
414
+ }
415
+ const merged = AbortSignal.any(active);
416
+ merged.addEventListener("abort", () => {
417
+ if (!controller.signal.aborted) {
418
+ controller.abort(merged.reason);
419
+ }
420
+ }, { once: true });
421
+ // Pin the derived signal to the returned controller: sources hold only
422
+ // weak refs to `any()` dependents, so without this strong ref the derived
423
+ // signal (and the forward listener with it) could be GC'd before firing.
424
+ controller.__nlMergedSignal = merged;
365
425
  return controller;
366
426
  }
@@ -2,6 +2,7 @@
2
2
  * Provider-specific token limit utilities
3
3
  * Provides safe maxTokens values based on provider and model capabilities
4
4
  */
5
+ import { getRuntimeOutputCeiling } from "../constants/contextWindows.js";
5
6
  import { PROVIDER_MAX_TOKENS } from "../core/constants.js";
6
7
  import { logger } from "./logger.js";
7
8
  import { hasRestrictedOutputLimit, RESTRICTED_OUTPUT_TOKEN_LIMIT, } from "./modelDetection.js";
@@ -34,6 +35,27 @@ export function getSafeMaxTokens(provider, model, requestedMaxTokens) {
34
35
  // Otherwise, use the requested value (it's within limits, including 0)
35
36
  return requestedMaxTokens;
36
37
  }
38
+ // Runtime-discovered output ceiling (e.g. LiteLLM /model/info
39
+ // max_output_tokens): the serving infrastructure's own number for the
40
+ // deployed model, authoritative over the static per-provider table. This
41
+ // both clamps over-large requests to the real cap AND replaces the static
42
+ // provider default for callers that pass no maxTokens — the litellm
43
+ // blanket default (128000) is a context-window-sized value that slashed
44
+ // usable input on total-context backends. Checked AFTER the
45
+ // restricted-model branch so hard caps proven at the origin API (Gemini 3
46
+ // / image models) still win over a proxy that over-advertises them.
47
+ const runtimeCeiling = getRuntimeOutputCeiling(provider, model);
48
+ if (runtimeCeiling !== undefined) {
49
+ if (requestedMaxTokens === undefined || requestedMaxTokens === null) {
50
+ return runtimeCeiling;
51
+ }
52
+ if (requestedMaxTokens > runtimeCeiling) {
53
+ logger.warn(`Requested maxTokens ${requestedMaxTokens} exceeds the advertised ` +
54
+ `${provider}/${model} output ceiling of ${runtimeCeiling}. Using ${runtimeCeiling} instead.`);
55
+ return runtimeCeiling;
56
+ }
57
+ return requestedMaxTokens;
58
+ }
37
59
  // Get provider-specific limits
38
60
  const providerLimits = PROVIDER_MAX_TOKENS[provider];
39
61
  if (!providerLimits) {
@@ -4,3 +4,11 @@ import type { ToolCallRepairFunction, ToolSet } from "../types/index.js";
4
4
  * Fully dynamic — reads the tool schema at repair time, no configuration needed.
5
5
  */
6
6
  export declare function createToolCallRepair(): ToolCallRepairFunction<ToolSet>;
7
+ /**
8
+ * Coerce a value to match the expected schema type.
9
+ * Handles: string→number, JSON string→object, JSON string→array, value→[value].
10
+ * Exported for reuse by the MCP-layer parameter validator
11
+ * (toolDiscoveryService), which coerces before rejecting so a recoverable
12
+ * mismatch doesn't cost the agent loop a full model round-trip.
13
+ */
14
+ export declare function coerceType(value: unknown, propSchema: Record<string, unknown>): unknown;
@@ -173,8 +173,11 @@ function findMatchingKey(inputKey, schemaKeys) {
173
173
  /**
174
174
  * Coerce a value to match the expected schema type.
175
175
  * Handles: string→number, JSON string→object, JSON string→array, value→[value].
176
+ * Exported for reuse by the MCP-layer parameter validator
177
+ * (toolDiscoveryService), which coerces before rejecting so a recoverable
178
+ * mismatch doesn't cost the agent loop a full model round-trip.
176
179
  */
177
- function coerceType(value, propSchema) {
180
+ export function coerceType(value, propSchema) {
178
181
  const expectedType = propSchema.type;
179
182
  if (!expectedType || value === null || value === undefined) {
180
183
  return value;