@juspay/neurolink 11.22.5 → 11.23.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.
@@ -52,6 +52,18 @@ export declare class LiteLLMProvider extends OpenAIChatCompletionsProvider {
52
52
  protected getDefaultModel(): string;
53
53
  protected getFallbackModelName(): string;
54
54
  protected getFallbackModels(): string[];
55
+ /**
56
+ * generate() rides the SSE wire for LiteLLM: deployments commonly sit
57
+ * behind proxies/tunnels (e.g. Cloudflare, which 524s an origin that is
58
+ * silent for ~100s), and a non-streaming completion from a slow model
59
+ * sends nothing until it is fully done. Streaming keeps bytes flowing for
60
+ * the whole generation while the base class aggregates into the same
61
+ * complete result — structuredData coercion, tool calls, stopReason,
62
+ * usage and the JSON damage flags are unchanged for callers.
63
+ * Escape hatch: NEUROLINK_LITELLM_SSE_GENERATE=false restores the plain
64
+ * JSON wire.
65
+ */
66
+ protected useStreamingWireForGenerate(): boolean;
55
67
  /**
56
68
  * Gemini 2.5 models on LiteLLM have a known compatibility issue with
57
69
  * `max_tokens` — strip it before the wire body is built. Applies to
@@ -248,6 +248,20 @@ export class LiteLLMProvider extends OpenAIChatCompletionsProvider {
248
248
  "google/gemini-2.5-flash",
249
249
  ]);
250
250
  }
251
+ /**
252
+ * generate() rides the SSE wire for LiteLLM: deployments commonly sit
253
+ * behind proxies/tunnels (e.g. Cloudflare, which 524s an origin that is
254
+ * silent for ~100s), and a non-streaming completion from a slow model
255
+ * sends nothing until it is fully done. Streaming keeps bytes flowing for
256
+ * the whole generation while the base class aggregates into the same
257
+ * complete result — structuredData coercion, tool calls, stopReason,
258
+ * usage and the JSON damage flags are unchanged for callers.
259
+ * Escape hatch: NEUROLINK_LITELLM_SSE_GENERATE=false restores the plain
260
+ * JSON wire.
261
+ */
262
+ useStreamingWireForGenerate() {
263
+ return process.env.NEUROLINK_LITELLM_SSE_GENERATE !== "false";
264
+ }
251
265
  /**
252
266
  * Gemini 2.5 models on LiteLLM have a known compatibility issue with
253
267
  * `max_tokens` — strip it before the wire body is built. Applies to
@@ -77,6 +77,19 @@ export declare abstract class OpenAIChatCompletionsProvider extends BaseProvider
77
77
  * (OpenAI, Azure OpenAI) override this to false.
78
78
  */
79
79
  protected suppressResponseFormatWithTools(): boolean;
80
+ /**
81
+ * When true, `doGenerate` puts `stream: true` on the wire and aggregates
82
+ * the SSE stream into the SAME complete result the JSON wire returns —
83
+ * callers still get one awaited result with structuredData coercion, tool
84
+ * calls, finish reason and usage intact. Bytes then flow continuously, so
85
+ * proxy/tunnel idle limits (e.g. Cloudflare's ~100s 524 on tunneled
86
+ * gateways) cannot kill a slow completion, and the request timeout is
87
+ * re-armed on every chunk (idle semantics) instead of capping total
88
+ * duration. Default false: some OpenAI-compatible backends mishandle
89
+ * `stream_options` or omit usage on streams, so each provider opts in
90
+ * deliberately. LiteLLM overrides this to true.
91
+ */
92
+ protected useStreamingWireForGenerate(): boolean;
80
93
  /**
81
94
  * Hook to adjust the fully-built wire request body before it is sent, on
82
95
  * both the streaming and non-streaming paths. Default identity. Override for
@@ -108,6 +108,21 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
108
108
  suppressResponseFormatWithTools() {
109
109
  return true;
110
110
  }
111
+ /**
112
+ * When true, `doGenerate` puts `stream: true` on the wire and aggregates
113
+ * the SSE stream into the SAME complete result the JSON wire returns —
114
+ * callers still get one awaited result with structuredData coercion, tool
115
+ * calls, finish reason and usage intact. Bytes then flow continuously, so
116
+ * proxy/tunnel idle limits (e.g. Cloudflare's ~100s 524 on tunneled
117
+ * gateways) cannot kill a slow completion, and the request timeout is
118
+ * re-armed on every chunk (idle semantics) instead of capping total
119
+ * duration. Default false: some OpenAI-compatible backends mishandle
120
+ * `stream_options` or omit usage on streams, so each provider opts in
121
+ * deliberately. LiteLLM overrides this to true.
122
+ */
123
+ useStreamingWireForGenerate() {
124
+ return false;
125
+ }
111
126
  /**
112
127
  * Hook to adjust the fully-built wire request body before it is sent, on
113
128
  * both the streaming and non-streaming paths. Default identity. Override for
@@ -387,6 +402,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
387
402
  const correctBodyAfterContextOverflow = this.correctBodyAfterContextOverflow.bind(this);
388
403
  const resolveWireMaxTokens = this.resolveWireMaxTokens.bind(this);
389
404
  const suppressResponseFormatWithTools = this.suppressResponseFormatWithTools.bind(this);
405
+ const useStreamingWireForGenerate = this.useStreamingWireForGenerate.bind(this);
390
406
  const getTimeoutForOptions = (opts) => this.getTimeout((opts ?? {}));
391
407
  return {
392
408
  specificationVersion: "v3",
@@ -414,6 +430,10 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
414
430
  // Fit max_tokens to the runtime-discovered output ceiling and
415
431
  // context window (no-op when nothing was discovered).
416
432
  const wireMaxTokens = resolveWireMaxTokens(modelId, options.maxOutputTokens, baseMessages, wireTools);
433
+ // SSE wire for generate (opt-in per provider): stream on the wire,
434
+ // aggregate below into the same complete response the JSON wire
435
+ // yields. See useStreamingWireForGenerate.
436
+ const sseWire = useStreamingWireForGenerate();
417
437
  const body = ensureJsonWordInBody(adjustRequestBody(buildBody({
418
438
  modelId,
419
439
  messages: baseMessages,
@@ -432,7 +452,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
432
452
  toolChoice: v3ToolChoiceToOpenAI(options.toolChoice, wireNameMaps?.toWire),
433
453
  }
434
454
  : {}),
435
- streaming: false,
455
+ streaming: sseWire,
436
456
  ...(responseFormat ? { responseFormat } : {}),
437
457
  }), modelId));
438
458
  // Per-step timeout: the AI-SDK V3 call options never carry `timeout`,
@@ -451,6 +471,10 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
451
471
  // this step's listeners the moment the request settles.
452
472
  const { signal: composedSignal, dispose: disposeComposedSignal } = composeAbortSignalsScoped(options.abortSignal, timeoutController?.controller.signal);
453
473
  let json;
474
+ // Whether the response we end up consuming is SSE. Starts as the
475
+ // provider's wire preference; the 400 fallback below can flip it
476
+ // when a backend rejects `stream`/`stream_options` outright.
477
+ let wireIsStreaming = sseWire;
454
478
  try {
455
479
  let res = await fetchImpl(url, {
456
480
  method: "POST",
@@ -474,14 +498,26 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
474
498
  // attempt, so the configured timeout caps the overall call —
475
499
  // matching the streaming path, which reuses its composed signal
476
500
  // for the retry.
477
- const retryBody = res.status === 400
501
+ const typedErr = apiErr;
502
+ let retryBody = res.status === 400
478
503
  ? (() => {
479
- const typedErr = apiErr;
480
504
  const overflowCorrected = correctBodyAfterContextOverflow(body, typedErr);
481
505
  return (adjustBodyAfter400(overflowCorrected ?? body, typedErr) ??
482
506
  overflowCorrected);
483
507
  })()
484
508
  : undefined;
509
+ // SSE-wire net: a backend that rejects streaming itself (the 400
510
+ // names `stream`/`stream_options`) gets ONE retry on the plain
511
+ // JSON wire. Gated on the error text so a genuine bad request
512
+ // isn't replayed just to fail identically a second time.
513
+ if (!retryBody &&
514
+ sseWire &&
515
+ res.status === 400 &&
516
+ /stream/i.test(typedErr.responseBody ?? "")) {
517
+ const { stream: _stream, stream_options: _streamOptions, ...jsonWireBody } = body;
518
+ retryBody = jsonWireBody;
519
+ wireIsStreaming = false;
520
+ }
485
521
  if (!retryBody) {
486
522
  throw apiErr;
487
523
  }
@@ -502,7 +538,59 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
502
538
  // after cleanup(), so a response whose headers arrived but whose
503
539
  // body stalled mid-transfer was bounded by nothing but the caller's
504
540
  // outer wall-clock.
505
- json = (await res.json());
541
+ if (wireIsStreaming) {
542
+ if (!res.body) {
543
+ throw new Error(`${providerName}: streaming generate response had no body`);
544
+ }
545
+ // Idle-timeout semantics: every raw chunk re-arms the timeout,
546
+ // so the configured window bounds silence, not total duration —
547
+ // the whole point of the SSE wire is that a slow-but-alive
548
+ // completion keeps the connection (and the timer) fed.
549
+ const monitored = timeoutController
550
+ ? res.body.pipeThrough(new TransformStream({
551
+ transform(chunk, controller) {
552
+ timeoutController.reset();
553
+ controller.enqueue(chunk);
554
+ },
555
+ }))
556
+ : res.body;
557
+ const sse = await parseSSEStream(monitored, () => { });
558
+ // Re-shape the aggregate into the JSON-wire response so every
559
+ // line below this point (content parts, finish-reason mapping,
560
+ // usage clamping, response metadata) is shared verbatim between
561
+ // the two wires and cannot drift.
562
+ json = {
563
+ ...(sse.id ? { id: sse.id } : {}),
564
+ ...(sse.model ? { model: sse.model } : {}),
565
+ choices: [
566
+ {
567
+ index: 0,
568
+ message: {
569
+ role: "assistant",
570
+ content: sse.text.length > 0 ? sse.text : null,
571
+ ...(sse.reasoning ? { reasoning: sse.reasoning } : {}),
572
+ ...(sse.toolCalls.size > 0
573
+ ? {
574
+ tool_calls: [...sse.toolCalls.values()].map((tc) => ({
575
+ id: tc.id,
576
+ type: "function",
577
+ function: {
578
+ name: tc.name,
579
+ arguments: tc.argsBuffered,
580
+ },
581
+ })),
582
+ }
583
+ : {}),
584
+ },
585
+ finish_reason: sse.finishReason ?? "stop",
586
+ },
587
+ ],
588
+ ...(sse.usage ? { usage: sse.usage } : {}),
589
+ };
590
+ }
591
+ else {
592
+ json = (await res.json());
593
+ }
506
594
  }
507
595
  finally {
508
596
  timeoutController?.cleanup();
@@ -531,6 +531,12 @@ export const parseSSEStream = async (body, onTextDelta, onReasoningDelta) => {
531
531
  if (chunk.usage) {
532
532
  result.usage = chunk.usage;
533
533
  }
534
+ if (chunk.id && !result.id) {
535
+ result.id = chunk.id;
536
+ }
537
+ if (chunk.model && !result.model) {
538
+ result.model = chunk.model;
539
+ }
534
540
  const choice = chunk.choices?.[0];
535
541
  if (!choice) {
536
542
  return;
@@ -199,6 +199,10 @@ export type OpenAICompatSSEResult = {
199
199
  }>;
200
200
  finishReason: "stop" | "length" | "tool_calls" | "function_call" | "content_filter" | null;
201
201
  usage?: OpenAICompatUsage;
202
+ /** Response id from the first stream chunk that carried one. */
203
+ id?: string;
204
+ /** Served model from the first stream chunk that carried one. */
205
+ model?: string;
202
206
  };
203
207
  export type OpenAICompatStreamChunk = {
204
208
  content: string;
@@ -116,6 +116,13 @@ export declare function withStreamingTimeout<T>(generator: AsyncGenerator<T>, ti
116
116
  export declare function createTimeoutController(timeout: number | string | undefined, provider: string, operation: "generate" | "stream"): {
117
117
  controller: AbortController;
118
118
  cleanup: () => void;
119
+ /**
120
+ * Re-arm the timeout window from now. Streaming consumers call this on
121
+ * every chunk so the timeout bounds *idle* time rather than total
122
+ * duration — a slow-but-alive upstream is not killed mid-generation.
123
+ * No-op once the controller has already aborted.
124
+ */
125
+ reset: () => void;
119
126
  timeoutMs: number;
120
127
  } | null;
121
128
  /**
@@ -313,7 +313,7 @@ export function createTimeoutController(timeout, provider, operation) {
313
313
  return null;
314
314
  }
315
315
  const controller = new AbortController();
316
- const timer = setTimeout(() => {
316
+ const fire = () => {
317
317
  // NOTE: we cannot stamp the AI SDK's ai.streamText/ai.generateText span
318
318
  // from here — the setTimeout callback runs in the async context captured
319
319
  // at schedule time, which is BEFORE the AI SDK span exists. Instead we
@@ -321,11 +321,19 @@ export function createTimeoutController(timeout, provider, operation) {
321
321
  // wrapper, which sets span.status = ERROR + message. ContextEnricher's
322
322
  // SpanStatusCode.ERROR branch then surfaces level=ERROR + status_message.
323
323
  controller.abort(new TimeoutError(`${provider} ${operation} operation timed out after ${timeout}`, timeoutMs, provider, operation));
324
- }, timeoutMs);
324
+ };
325
+ let timer = setTimeout(fire, timeoutMs);
325
326
  const cleanup = () => {
326
327
  clearTimeout(timer);
327
328
  };
328
- return { controller, cleanup, timeoutMs };
329
+ const reset = () => {
330
+ if (controller.signal.aborted) {
331
+ return;
332
+ }
333
+ clearTimeout(timer);
334
+ timer = setTimeout(fire, timeoutMs);
335
+ };
336
+ return { controller, cleanup, reset, timeoutMs };
329
337
  }
330
338
  /**
331
339
  * Compose an external abort signal with a timeout controller's signal.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "11.22.5",
3
+ "version": "11.23.0",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
6
6
  "author": {