@juspay/neurolink 9.69.0 → 9.69.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.
@@ -1,5 +1,5 @@
1
1
  import type { AIProviderName } from "../constants/enums.js";
2
- import type { NeurolinkCredentials, OpenAICompatBuildBodyArgs } from "../types/index.js";
2
+ import type { NeurolinkCredentials, OpenAICompatBuildBodyArgs, OpenAICompatChatRequest } from "../types/index.js";
3
3
  import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
4
4
  declare const isNimFieldRejection: (body: string, field: string) => boolean;
5
5
  /**
@@ -12,8 +12,11 @@ declare const stripFieldFromJsonBody: (body: string, field: "reasoning_budget" |
12
12
  * NVIDIA NIM Provider — native HTTP+SSE, no AI SDK.
13
13
  *
14
14
  * Wraps NVIDIA's hosted (or self-hosted) inference endpoints.
15
- * Passes NIM-specific extras (top_k, min_p, repetition_penalty,
16
- * chat_template_kwargs.reasoning_budget) via adjustBuildBodyOptions.
15
+ * Passes NIM-specific extras (top_k, min_p, repetition_penalty, min_tokens,
16
+ * chat_template, chat_template_kwargs.reasoning_budget) to the wire via the
17
+ * `extraBody` channel of adjustBuildBodyOptions, and retries once without
18
+ * `chat_template`/`reasoning_budget` when a model server rejects them with a
19
+ * 400 (adjustBodyAfter400 — applies on both generate and stream paths).
17
20
  * reasoning_content is surfaced automatically by the native base client
18
21
  * (streamed as `{ content: "", reasoning }` chunks; `result.reasoning` on
19
22
  * the non-streaming path); all other behavior is preserved.
@@ -34,6 +37,17 @@ export declare class NvidiaNimProvider extends OpenAIChatCompletionsProvider {
34
37
  * value is the full StreamOptions object.
35
38
  */
36
39
  protected adjustBuildBodyOptions(_modelId: string, opts: OpenAICompatBuildBodyArgs["options"]): OpenAICompatBuildBodyArgs["options"] & Record<string, unknown>;
40
+ /**
41
+ * One-shot 400 retry (base hook): some NIM model servers reject
42
+ * `chat_template` or `chat_template_kwargs.reasoning_budget` with a 400
43
+ * naming the field. Strip the rejected field(s) and let the base retry
44
+ * once, restoring the pre-migration fetch-level behavior. Returns
45
+ * undefined for unrelated 400s so they propagate unchanged.
46
+ */
47
+ protected adjustBodyAfter400(body: OpenAICompatChatRequest, error: Error & {
48
+ statusCode?: number;
49
+ responseBody?: string;
50
+ }): OpenAICompatChatRequest | undefined;
37
51
  protected formatProviderError(error: unknown): Error;
38
52
  validateConfiguration(): Promise<boolean>;
39
53
  getConfiguration(): {
@@ -142,8 +142,11 @@ const getDefaultNimModel = () => {
142
142
  * NVIDIA NIM Provider — native HTTP+SSE, no AI SDK.
143
143
  *
144
144
  * Wraps NVIDIA's hosted (or self-hosted) inference endpoints.
145
- * Passes NIM-specific extras (top_k, min_p, repetition_penalty,
146
- * chat_template_kwargs.reasoning_budget) via adjustBuildBodyOptions.
145
+ * Passes NIM-specific extras (top_k, min_p, repetition_penalty, min_tokens,
146
+ * chat_template, chat_template_kwargs.reasoning_budget) to the wire via the
147
+ * `extraBody` channel of adjustBuildBodyOptions, and retries once without
148
+ * `chat_template`/`reasoning_budget` when a model server rejects them with a
149
+ * 400 (adjustBodyAfter400 — applies on both generate and stream paths).
147
150
  * reasoning_content is surfaced automatically by the native base client
148
151
  * (streamed as `{ content: "", reasoning }` chunks; `result.reasoning` on
149
152
  * the non-streaming path); all other behavior is preserved.
@@ -195,7 +198,43 @@ export class NvidiaNimProvider extends OpenAIChatCompletionsProvider {
195
198
  const thinkingEnabled = tl !== undefined && tl !== "minimal";
196
199
  const maxTokens = typeof fullOpts.maxTokens === "number" ? fullOpts.maxTokens : undefined;
197
200
  const extra = buildNvidiaNimExtraBody(thinkingEnabled, maxTokens);
198
- return { ...opts, ...extra };
201
+ // Attach via the explicit extraBody channel — buildBody spreads it onto
202
+ // the wire body. (Loose unknown keys on the returned options object are
203
+ // dropped by buildBody, which is why the extras previously never reached
204
+ // the wire.)
205
+ return Object.keys(extra).length > 0
206
+ ? { ...opts, extraBody: extra }
207
+ : opts;
208
+ }
209
+ /**
210
+ * One-shot 400 retry (base hook): some NIM model servers reject
211
+ * `chat_template` or `chat_template_kwargs.reasoning_budget` with a 400
212
+ * naming the field. Strip the rejected field(s) and let the base retry
213
+ * once, restoring the pre-migration fetch-level behavior. Returns
214
+ * undefined for unrelated 400s so they propagate unchanged.
215
+ */
216
+ adjustBodyAfter400(body, error) {
217
+ const responseBody = error.responseBody ?? "";
218
+ let serialized = JSON.stringify(body);
219
+ const strippedFields = [];
220
+ for (const field of ["chat_template", "reasoning_budget"]) {
221
+ if (isNimFieldRejection(responseBody, field)) {
222
+ const next = stripFieldFromJsonBody(serialized, field);
223
+ if (next !== null) {
224
+ serialized = next;
225
+ strippedFields.push(field);
226
+ }
227
+ }
228
+ }
229
+ if (strippedFields.length === 0) {
230
+ return undefined;
231
+ }
232
+ logger.debug("NVIDIA NIM: 400 rejected request field(s) — retrying once without them", {
233
+ strippedFields,
234
+ model: body.model,
235
+ rejection: responseBody.slice(0, 200),
236
+ });
237
+ return JSON.parse(serialized);
199
238
  }
200
239
  formatProviderError(error) {
201
240
  if (error instanceof TimeoutError) {
@@ -70,6 +70,18 @@ export declare abstract class OpenAIChatCompletionsProvider extends BaseProvider
70
70
  * `max_tokens` and require `max_completion_tokens`.
71
71
  */
72
72
  protected adjustRequestBody(body: OpenAICompatChatRequest, _modelId: string): OpenAICompatChatRequest;
73
+ /**
74
+ * Hook called when a request fails with HTTP 400. Return a modified body to
75
+ * retry ONCE with it; return undefined (default) to propagate the error
76
+ * unchanged. Applies on both the non-streaming doGenerate path and the
77
+ * streaming path. NVIDIA NIM uses this to strip `chat_template` /
78
+ * `chat_template_kwargs.reasoning_budget` when a model server rejects them,
79
+ * restoring the pre-migration fetch-level retry behavior.
80
+ */
81
+ protected adjustBodyAfter400(_body: OpenAICompatChatRequest, _error: Error & {
82
+ statusCode?: number;
83
+ responseBody?: string;
84
+ }): OpenAICompatChatRequest | undefined;
73
85
  /**
74
86
  * Hook called once at the start of every `executeStream` invocation.
75
87
  * Return lifecycle listeners (onUsage / onFinish) to receive deferred
@@ -86,6 +86,17 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
86
86
  adjustRequestBody(body, _modelId) {
87
87
  return body;
88
88
  }
89
+ /**
90
+ * Hook called when a request fails with HTTP 400. Return a modified body to
91
+ * retry ONCE with it; return undefined (default) to propagate the error
92
+ * unchanged. Applies on both the non-streaming doGenerate path and the
93
+ * streaming path. NVIDIA NIM uses this to strip `chat_template` /
94
+ * `chat_template_kwargs.reasoning_budget` when a model server rejects them,
95
+ * restoring the pre-migration fetch-level retry behavior.
96
+ */
97
+ adjustBodyAfter400(_body, _error) {
98
+ return undefined;
99
+ }
89
100
  /**
90
101
  * Hook called once at the start of every `executeStream` invocation.
91
102
  * Return lifecycle listeners (onUsage / onFinish) to receive deferred
@@ -209,6 +220,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
209
220
  const adjustBuildBodyOptions = this.adjustBuildBodyOptions.bind(this);
210
221
  const adjustResponseFormat = this.adjustResponseFormat.bind(this);
211
222
  const adjustRequestBody = this.adjustRequestBody.bind(this);
223
+ const adjustBodyAfter400 = this.adjustBodyAfter400.bind(this);
212
224
  const getTimeoutForOptions = (opts) => this.getTimeout((opts ?? {}));
213
225
  return {
214
226
  specificationVersion: "v3",
@@ -256,13 +268,37 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
256
268
  body: JSON.stringify(body),
257
269
  ...(composedSignal ? { signal: composedSignal } : {}),
258
270
  });
271
+ if (!res.ok) {
272
+ const apiErr = await buildAPIError(url, body, res);
273
+ // One-shot 400 retry: a subclass may strip a rejected field and
274
+ // return a modified body (e.g. NIM's chat_template /
275
+ // reasoning_budget). The retry runs under the SAME timeout
276
+ // controller as the first attempt, so the configured timeout caps
277
+ // the overall call — matching the streaming path, which reuses
278
+ // its composed signal for the retry.
279
+ const retryBody = res.status === 400
280
+ ? adjustBodyAfter400(body, apiErr)
281
+ : undefined;
282
+ if (!retryBody) {
283
+ throw apiErr;
284
+ }
285
+ res = await fetchImpl(url, {
286
+ method: "POST",
287
+ headers: {
288
+ "Content-Type": "application/json",
289
+ ...getAuthHeaders(),
290
+ },
291
+ body: JSON.stringify(retryBody),
292
+ ...(composedSignal ? { signal: composedSignal } : {}),
293
+ });
294
+ if (!res.ok) {
295
+ throw await buildAPIError(url, retryBody, res);
296
+ }
297
+ }
259
298
  }
260
299
  finally {
261
300
  timeoutController?.cleanup();
262
301
  }
263
- if (!res.ok) {
264
- throw await buildAPIError(url, body, res);
265
- }
266
302
  const json = (await res.json());
267
303
  const choice = json.choices?.[0];
268
304
  const text = (typeof choice?.message?.content === "string"
@@ -272,7 +308,9 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
272
308
  // Reasoner-model output (DeepSeek `reasoning_content`, gateway
273
309
  // `reasoning`) becomes a V3 reasoning part ahead of the text part —
274
310
  // GenerationHandler joins reasoning parts into `result.reasoning`.
275
- const reasoningText = choice?.message?.reasoning_content ?? choice?.message?.reasoning;
311
+ // `||` so an empty-string reasoning_content falls through to a
312
+ // non-empty `reasoning` field instead of shadowing it.
313
+ const reasoningText = choice?.message?.reasoning_content || choice?.message?.reasoning;
276
314
  if (typeof reasoningText === "string" && reasoningText.length > 0) {
277
315
  content.push({ type: "reasoning", text: reasoningText });
278
316
  }
@@ -307,11 +345,13 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
307
345
  },
308
346
  outputTokens: {
309
347
  total: json.usage?.completion_tokens,
348
+ // Clamped at 0 — some gateways report inconsistent usage where
349
+ // reasoning_tokens exceeds completion_tokens.
310
350
  text: json.usage?.completion_tokens !== undefined &&
311
351
  json.usage?.completion_tokens_details?.reasoning_tokens !==
312
352
  undefined
313
- ? json.usage.completion_tokens -
314
- json.usage.completion_tokens_details.reasoning_tokens
353
+ ? Math.max(0, json.usage.completion_tokens -
354
+ json.usage.completion_tokens_details.reasoning_tokens)
315
355
  : json.usage?.completion_tokens,
316
356
  reasoning: json.usage?.completion_tokens_details?.reasoning_tokens,
317
357
  },
@@ -579,7 +619,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
579
619
  : {}),
580
620
  streaming: true,
581
621
  }), args.modelId));
582
- const res = await args.fetchImpl(args.url, {
622
+ let res = await args.fetchImpl(args.url, {
583
623
  method: "POST",
584
624
  headers: {
585
625
  "Content-Type": "application/json",
@@ -589,7 +629,27 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
589
629
  ...(args.abortSignal ? { signal: args.abortSignal } : {}),
590
630
  });
591
631
  if (!res.ok) {
592
- throw await buildAPIError(args.url, body, res);
632
+ const apiErr = await buildAPIError(args.url, body, res);
633
+ // One-shot 400 retry — same hook as the doGenerate path (e.g. NIM
634
+ // strips chat_template/reasoning_budget when a model rejects them).
635
+ const retryBody = res.status === 400
636
+ ? this.adjustBodyAfter400(body, apiErr)
637
+ : undefined;
638
+ if (!retryBody) {
639
+ throw apiErr;
640
+ }
641
+ res = await args.fetchImpl(args.url, {
642
+ method: "POST",
643
+ headers: {
644
+ "Content-Type": "application/json",
645
+ ...this.getAuthHeaders(),
646
+ },
647
+ body: JSON.stringify(retryBody),
648
+ ...(args.abortSignal ? { signal: args.abortSignal } : {}),
649
+ });
650
+ if (!res.ok) {
651
+ throw await buildAPIError(args.url, retryBody, res);
652
+ }
593
653
  }
594
654
  if (!res.body) {
595
655
  throw new Error(`${this.providerName}: stream response had no body`);
@@ -397,6 +397,13 @@ export const buildBody = (args) => {
397
397
  if (responseFormat) {
398
398
  body.response_format = responseFormat;
399
399
  }
400
+ // Provider-specific extras attached via options.extraBody (the explicit
401
+ // channel from adjustBuildBodyOptions) land verbatim on the wire body.
402
+ // Spread last — a provider that puts a core field here is intentionally
403
+ // overriding it.
404
+ if (options.extraBody) {
405
+ Object.assign(body, options.extraBody);
406
+ }
400
407
  return body;
401
408
  };
402
409
  export const parseSSEStream = async (body, onTextDelta, onReasoningDelta) => {
@@ -437,7 +444,9 @@ export const parseSSEStream = async (body, onTextDelta, onReasoningDelta) => {
437
444
  // Reasoner-model deltas: DeepSeek/NIM emit `reasoning_content`, some
438
445
  // gateways emit `reasoning`. The AI SDK's openai-compatible wrapper
439
446
  // surfaced these automatically; the native client must do the same.
440
- const reasoningDelta = delta?.reasoning_content ?? delta?.reasoning;
447
+ // `||` (not `??`) so an empty-string reasoning_content falls through to
448
+ // a non-empty `reasoning` field instead of shadowing it.
449
+ const reasoningDelta = delta?.reasoning_content || delta?.reasoning;
441
450
  if (reasoningDelta) {
442
451
  result.reasoning += reasoningDelta;
443
452
  onReasoningDelta?.(reasoningDelta);
@@ -248,6 +248,14 @@ export type OpenAICompatBuildBodyArgs = {
248
248
  frequencyPenalty?: number | null;
249
249
  seed?: number | null;
250
250
  stopSequences?: string[];
251
+ /**
252
+ * Provider-specific extra wire fields, spread verbatim onto the final
253
+ * request body by `buildBody` (after the standard fields). This is the
254
+ * explicit channel for non-OpenAI knobs (e.g. NVIDIA NIM's `top_k` /
255
+ * `chat_template_kwargs`) — loose unknown keys returned from
256
+ * `adjustBuildBodyOptions` are intentionally NOT forwarded.
257
+ */
258
+ extraBody?: Record<string, unknown>;
251
259
  };
252
260
  tools?: OpenAICompatChatTool[];
253
261
  toolChoice?: OpenAICompatToolChoiceWire;
@@ -1,5 +1,5 @@
1
1
  import type { AIProviderName } from "../constants/enums.js";
2
- import type { NeurolinkCredentials, OpenAICompatBuildBodyArgs } from "../types/index.js";
2
+ import type { NeurolinkCredentials, OpenAICompatBuildBodyArgs, OpenAICompatChatRequest } from "../types/index.js";
3
3
  import { OpenAIChatCompletionsProvider } from "./openaiChatCompletionsBase.js";
4
4
  declare const isNimFieldRejection: (body: string, field: string) => boolean;
5
5
  /**
@@ -12,8 +12,11 @@ declare const stripFieldFromJsonBody: (body: string, field: "reasoning_budget" |
12
12
  * NVIDIA NIM Provider — native HTTP+SSE, no AI SDK.
13
13
  *
14
14
  * Wraps NVIDIA's hosted (or self-hosted) inference endpoints.
15
- * Passes NIM-specific extras (top_k, min_p, repetition_penalty,
16
- * chat_template_kwargs.reasoning_budget) via adjustBuildBodyOptions.
15
+ * Passes NIM-specific extras (top_k, min_p, repetition_penalty, min_tokens,
16
+ * chat_template, chat_template_kwargs.reasoning_budget) to the wire via the
17
+ * `extraBody` channel of adjustBuildBodyOptions, and retries once without
18
+ * `chat_template`/`reasoning_budget` when a model server rejects them with a
19
+ * 400 (adjustBodyAfter400 — applies on both generate and stream paths).
17
20
  * reasoning_content is surfaced automatically by the native base client
18
21
  * (streamed as `{ content: "", reasoning }` chunks; `result.reasoning` on
19
22
  * the non-streaming path); all other behavior is preserved.
@@ -34,6 +37,17 @@ export declare class NvidiaNimProvider extends OpenAIChatCompletionsProvider {
34
37
  * value is the full StreamOptions object.
35
38
  */
36
39
  protected adjustBuildBodyOptions(_modelId: string, opts: OpenAICompatBuildBodyArgs["options"]): OpenAICompatBuildBodyArgs["options"] & Record<string, unknown>;
40
+ /**
41
+ * One-shot 400 retry (base hook): some NIM model servers reject
42
+ * `chat_template` or `chat_template_kwargs.reasoning_budget` with a 400
43
+ * naming the field. Strip the rejected field(s) and let the base retry
44
+ * once, restoring the pre-migration fetch-level behavior. Returns
45
+ * undefined for unrelated 400s so they propagate unchanged.
46
+ */
47
+ protected adjustBodyAfter400(body: OpenAICompatChatRequest, error: Error & {
48
+ statusCode?: number;
49
+ responseBody?: string;
50
+ }): OpenAICompatChatRequest | undefined;
37
51
  protected formatProviderError(error: unknown): Error;
38
52
  validateConfiguration(): Promise<boolean>;
39
53
  getConfiguration(): {
@@ -142,8 +142,11 @@ const getDefaultNimModel = () => {
142
142
  * NVIDIA NIM Provider — native HTTP+SSE, no AI SDK.
143
143
  *
144
144
  * Wraps NVIDIA's hosted (or self-hosted) inference endpoints.
145
- * Passes NIM-specific extras (top_k, min_p, repetition_penalty,
146
- * chat_template_kwargs.reasoning_budget) via adjustBuildBodyOptions.
145
+ * Passes NIM-specific extras (top_k, min_p, repetition_penalty, min_tokens,
146
+ * chat_template, chat_template_kwargs.reasoning_budget) to the wire via the
147
+ * `extraBody` channel of adjustBuildBodyOptions, and retries once without
148
+ * `chat_template`/`reasoning_budget` when a model server rejects them with a
149
+ * 400 (adjustBodyAfter400 — applies on both generate and stream paths).
147
150
  * reasoning_content is surfaced automatically by the native base client
148
151
  * (streamed as `{ content: "", reasoning }` chunks; `result.reasoning` on
149
152
  * the non-streaming path); all other behavior is preserved.
@@ -195,7 +198,43 @@ export class NvidiaNimProvider extends OpenAIChatCompletionsProvider {
195
198
  const thinkingEnabled = tl !== undefined && tl !== "minimal";
196
199
  const maxTokens = typeof fullOpts.maxTokens === "number" ? fullOpts.maxTokens : undefined;
197
200
  const extra = buildNvidiaNimExtraBody(thinkingEnabled, maxTokens);
198
- return { ...opts, ...extra };
201
+ // Attach via the explicit extraBody channel — buildBody spreads it onto
202
+ // the wire body. (Loose unknown keys on the returned options object are
203
+ // dropped by buildBody, which is why the extras previously never reached
204
+ // the wire.)
205
+ return Object.keys(extra).length > 0
206
+ ? { ...opts, extraBody: extra }
207
+ : opts;
208
+ }
209
+ /**
210
+ * One-shot 400 retry (base hook): some NIM model servers reject
211
+ * `chat_template` or `chat_template_kwargs.reasoning_budget` with a 400
212
+ * naming the field. Strip the rejected field(s) and let the base retry
213
+ * once, restoring the pre-migration fetch-level behavior. Returns
214
+ * undefined for unrelated 400s so they propagate unchanged.
215
+ */
216
+ adjustBodyAfter400(body, error) {
217
+ const responseBody = error.responseBody ?? "";
218
+ let serialized = JSON.stringify(body);
219
+ const strippedFields = [];
220
+ for (const field of ["chat_template", "reasoning_budget"]) {
221
+ if (isNimFieldRejection(responseBody, field)) {
222
+ const next = stripFieldFromJsonBody(serialized, field);
223
+ if (next !== null) {
224
+ serialized = next;
225
+ strippedFields.push(field);
226
+ }
227
+ }
228
+ }
229
+ if (strippedFields.length === 0) {
230
+ return undefined;
231
+ }
232
+ logger.debug("NVIDIA NIM: 400 rejected request field(s) — retrying once without them", {
233
+ strippedFields,
234
+ model: body.model,
235
+ rejection: responseBody.slice(0, 200),
236
+ });
237
+ return JSON.parse(serialized);
199
238
  }
200
239
  formatProviderError(error) {
201
240
  if (error instanceof TimeoutError) {
@@ -70,6 +70,18 @@ export declare abstract class OpenAIChatCompletionsProvider extends BaseProvider
70
70
  * `max_tokens` and require `max_completion_tokens`.
71
71
  */
72
72
  protected adjustRequestBody(body: OpenAICompatChatRequest, _modelId: string): OpenAICompatChatRequest;
73
+ /**
74
+ * Hook called when a request fails with HTTP 400. Return a modified body to
75
+ * retry ONCE with it; return undefined (default) to propagate the error
76
+ * unchanged. Applies on both the non-streaming doGenerate path and the
77
+ * streaming path. NVIDIA NIM uses this to strip `chat_template` /
78
+ * `chat_template_kwargs.reasoning_budget` when a model server rejects them,
79
+ * restoring the pre-migration fetch-level retry behavior.
80
+ */
81
+ protected adjustBodyAfter400(_body: OpenAICompatChatRequest, _error: Error & {
82
+ statusCode?: number;
83
+ responseBody?: string;
84
+ }): OpenAICompatChatRequest | undefined;
73
85
  /**
74
86
  * Hook called once at the start of every `executeStream` invocation.
75
87
  * Return lifecycle listeners (onUsage / onFinish) to receive deferred
@@ -86,6 +86,17 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
86
86
  adjustRequestBody(body, _modelId) {
87
87
  return body;
88
88
  }
89
+ /**
90
+ * Hook called when a request fails with HTTP 400. Return a modified body to
91
+ * retry ONCE with it; return undefined (default) to propagate the error
92
+ * unchanged. Applies on both the non-streaming doGenerate path and the
93
+ * streaming path. NVIDIA NIM uses this to strip `chat_template` /
94
+ * `chat_template_kwargs.reasoning_budget` when a model server rejects them,
95
+ * restoring the pre-migration fetch-level retry behavior.
96
+ */
97
+ adjustBodyAfter400(_body, _error) {
98
+ return undefined;
99
+ }
89
100
  /**
90
101
  * Hook called once at the start of every `executeStream` invocation.
91
102
  * Return lifecycle listeners (onUsage / onFinish) to receive deferred
@@ -209,6 +220,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
209
220
  const adjustBuildBodyOptions = this.adjustBuildBodyOptions.bind(this);
210
221
  const adjustResponseFormat = this.adjustResponseFormat.bind(this);
211
222
  const adjustRequestBody = this.adjustRequestBody.bind(this);
223
+ const adjustBodyAfter400 = this.adjustBodyAfter400.bind(this);
212
224
  const getTimeoutForOptions = (opts) => this.getTimeout((opts ?? {}));
213
225
  return {
214
226
  specificationVersion: "v3",
@@ -256,13 +268,37 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
256
268
  body: JSON.stringify(body),
257
269
  ...(composedSignal ? { signal: composedSignal } : {}),
258
270
  });
271
+ if (!res.ok) {
272
+ const apiErr = await buildAPIError(url, body, res);
273
+ // One-shot 400 retry: a subclass may strip a rejected field and
274
+ // return a modified body (e.g. NIM's chat_template /
275
+ // reasoning_budget). The retry runs under the SAME timeout
276
+ // controller as the first attempt, so the configured timeout caps
277
+ // the overall call — matching the streaming path, which reuses
278
+ // its composed signal for the retry.
279
+ const retryBody = res.status === 400
280
+ ? adjustBodyAfter400(body, apiErr)
281
+ : undefined;
282
+ if (!retryBody) {
283
+ throw apiErr;
284
+ }
285
+ res = await fetchImpl(url, {
286
+ method: "POST",
287
+ headers: {
288
+ "Content-Type": "application/json",
289
+ ...getAuthHeaders(),
290
+ },
291
+ body: JSON.stringify(retryBody),
292
+ ...(composedSignal ? { signal: composedSignal } : {}),
293
+ });
294
+ if (!res.ok) {
295
+ throw await buildAPIError(url, retryBody, res);
296
+ }
297
+ }
259
298
  }
260
299
  finally {
261
300
  timeoutController?.cleanup();
262
301
  }
263
- if (!res.ok) {
264
- throw await buildAPIError(url, body, res);
265
- }
266
302
  const json = (await res.json());
267
303
  const choice = json.choices?.[0];
268
304
  const text = (typeof choice?.message?.content === "string"
@@ -272,7 +308,9 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
272
308
  // Reasoner-model output (DeepSeek `reasoning_content`, gateway
273
309
  // `reasoning`) becomes a V3 reasoning part ahead of the text part —
274
310
  // GenerationHandler joins reasoning parts into `result.reasoning`.
275
- const reasoningText = choice?.message?.reasoning_content ?? choice?.message?.reasoning;
311
+ // `||` so an empty-string reasoning_content falls through to a
312
+ // non-empty `reasoning` field instead of shadowing it.
313
+ const reasoningText = choice?.message?.reasoning_content || choice?.message?.reasoning;
276
314
  if (typeof reasoningText === "string" && reasoningText.length > 0) {
277
315
  content.push({ type: "reasoning", text: reasoningText });
278
316
  }
@@ -307,11 +345,13 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
307
345
  },
308
346
  outputTokens: {
309
347
  total: json.usage?.completion_tokens,
348
+ // Clamped at 0 — some gateways report inconsistent usage where
349
+ // reasoning_tokens exceeds completion_tokens.
310
350
  text: json.usage?.completion_tokens !== undefined &&
311
351
  json.usage?.completion_tokens_details?.reasoning_tokens !==
312
352
  undefined
313
- ? json.usage.completion_tokens -
314
- json.usage.completion_tokens_details.reasoning_tokens
353
+ ? Math.max(0, json.usage.completion_tokens -
354
+ json.usage.completion_tokens_details.reasoning_tokens)
315
355
  : json.usage?.completion_tokens,
316
356
  reasoning: json.usage?.completion_tokens_details?.reasoning_tokens,
317
357
  },
@@ -579,7 +619,7 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
579
619
  : {}),
580
620
  streaming: true,
581
621
  }), args.modelId));
582
- const res = await args.fetchImpl(args.url, {
622
+ let res = await args.fetchImpl(args.url, {
583
623
  method: "POST",
584
624
  headers: {
585
625
  "Content-Type": "application/json",
@@ -589,7 +629,27 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
589
629
  ...(args.abortSignal ? { signal: args.abortSignal } : {}),
590
630
  });
591
631
  if (!res.ok) {
592
- throw await buildAPIError(args.url, body, res);
632
+ const apiErr = await buildAPIError(args.url, body, res);
633
+ // One-shot 400 retry — same hook as the doGenerate path (e.g. NIM
634
+ // strips chat_template/reasoning_budget when a model rejects them).
635
+ const retryBody = res.status === 400
636
+ ? this.adjustBodyAfter400(body, apiErr)
637
+ : undefined;
638
+ if (!retryBody) {
639
+ throw apiErr;
640
+ }
641
+ res = await args.fetchImpl(args.url, {
642
+ method: "POST",
643
+ headers: {
644
+ "Content-Type": "application/json",
645
+ ...this.getAuthHeaders(),
646
+ },
647
+ body: JSON.stringify(retryBody),
648
+ ...(args.abortSignal ? { signal: args.abortSignal } : {}),
649
+ });
650
+ if (!res.ok) {
651
+ throw await buildAPIError(args.url, retryBody, res);
652
+ }
593
653
  }
594
654
  if (!res.body) {
595
655
  throw new Error(`${this.providerName}: stream response had no body`);
@@ -397,6 +397,13 @@ export const buildBody = (args) => {
397
397
  if (responseFormat) {
398
398
  body.response_format = responseFormat;
399
399
  }
400
+ // Provider-specific extras attached via options.extraBody (the explicit
401
+ // channel from adjustBuildBodyOptions) land verbatim on the wire body.
402
+ // Spread last — a provider that puts a core field here is intentionally
403
+ // overriding it.
404
+ if (options.extraBody) {
405
+ Object.assign(body, options.extraBody);
406
+ }
400
407
  return body;
401
408
  };
402
409
  export const parseSSEStream = async (body, onTextDelta, onReasoningDelta) => {
@@ -437,7 +444,9 @@ export const parseSSEStream = async (body, onTextDelta, onReasoningDelta) => {
437
444
  // Reasoner-model deltas: DeepSeek/NIM emit `reasoning_content`, some
438
445
  // gateways emit `reasoning`. The AI SDK's openai-compatible wrapper
439
446
  // surfaced these automatically; the native client must do the same.
440
- const reasoningDelta = delta?.reasoning_content ?? delta?.reasoning;
447
+ // `||` (not `??`) so an empty-string reasoning_content falls through to
448
+ // a non-empty `reasoning` field instead of shadowing it.
449
+ const reasoningDelta = delta?.reasoning_content || delta?.reasoning;
441
450
  if (reasoningDelta) {
442
451
  result.reasoning += reasoningDelta;
443
452
  onReasoningDelta?.(reasoningDelta);
@@ -248,6 +248,14 @@ export type OpenAICompatBuildBodyArgs = {
248
248
  frequencyPenalty?: number | null;
249
249
  seed?: number | null;
250
250
  stopSequences?: string[];
251
+ /**
252
+ * Provider-specific extra wire fields, spread verbatim onto the final
253
+ * request body by `buildBody` (after the standard fields). This is the
254
+ * explicit channel for non-OpenAI knobs (e.g. NVIDIA NIM's `top_k` /
255
+ * `chat_template_kwargs`) — loose unknown keys returned from
256
+ * `adjustBuildBodyOptions` are intentionally NOT forwarded.
257
+ */
258
+ extraBody?: Record<string, unknown>;
251
259
  };
252
260
  tools?: OpenAICompatChatTool[];
253
261
  toolChoice?: OpenAICompatToolChoiceWire;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "9.69.0",
3
+ "version": "9.69.1",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applications with 21+ providers: OpenAI, Anthropic, Google AI Studio, Google Vertex, AWS Bedrock, Azure OpenAI, Mistral, LiteLLM, SageMaker, Hugging Face, Ollama, OpenAI-compatible, OpenRouter, DeepSeek, NVIDIA NIM, LM Studio, llama.cpp, plus voice (OpenAI TTS, ElevenLabs, Deepgram, Azure Speech).",
6
6
  "author": {