@juspay/neurolink 12.14.10 → 12.14.11

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.
@@ -244,6 +244,27 @@ export declare abstract class OpenAIChatCompletionsProvider extends BaseProvider
244
244
  */
245
245
  generate(optionsOrPrompt: TextGenerationOptions | string, analysisSchema?: ValidationSchema): Promise<EnhancedGenerateResult | null>;
246
246
  private executeNativeGenerate;
247
+ /**
248
+ * Recover structured output on a turn where tools forced `response_format`
249
+ * off the wire.
250
+ *
251
+ * Runs only after the tool turn is complete, and only when its answer does
252
+ * not already satisfy the schema. The re-ask drops the tools, which is what
253
+ * makes native `response_format` legal again on a vendor that refuses the two
254
+ * together, so this is a formatting pass over an answer the model has already
255
+ * produced rather than a second attempt at the question.
256
+ *
257
+ * The schema is deliberately NOT spelled into the tool turn's system prompt
258
+ * instead. That was tried and reverted: a model handed a JSON Schema next to a
259
+ * tool list can read it as another tool — Groq's llama-3.3 answers by calling
260
+ * one named "json", which the server rejects — turning a wrong answer into a
261
+ * hard error.
262
+ *
263
+ * Returns the merged result, or the untouched `toolPhase` whenever the re-ask
264
+ * cannot be run or cannot be trusted, so it can improve an outcome but never
265
+ * degrade one.
266
+ */
267
+ private reformatWithoutTools;
247
268
  protected executeStream(options: StreamOptions, _analysisSchema?: ZodUnknownSchema | Schema<unknown>): Promise<StreamResult>;
248
269
  private runStreamLoop;
249
270
  private streamOneStep;
@@ -30,7 +30,7 @@ import { logger } from "../utils/logger.js";
30
30
  import { redactUrlCredentials } from "../utils/logSanitize.js";
31
31
  import { NoOutputGeneratedError } from "../utils/generationErrors.js";
32
32
  import { buildNoOutputSentinel, stampNoOutputSpan, } from "../utils/noOutputSentinel.js";
33
- import { composeAbortSignalsScoped, createTimeoutController, mergeAbortSignals, } from "../utils/timeout.js";
33
+ import { composeAbortSignalsScoped, createTimeoutController, mergeAbortSignals, TimeoutError, } from "../utils/timeout.js";
34
34
  import { emitToolEndFromStepFinish } from "../utils/toolEndEmitter.js";
35
35
  import { resolveRequestKind } from "../core/resolveRequestKind.js";
36
36
  import { appendJsonSchemaInstruction, hasNativeDoGenerate, runNativeGenerateLoop, } from "../core/nativeGenerateLoop.js";
@@ -62,6 +62,13 @@ const WINDOW_FIT_MARGIN_TOKENS = 512;
62
62
  * object. A schema we cannot validate with accepts everything, so an unknown
63
63
  * schema never forces a pointless second request.
64
64
  */
65
+ /**
66
+ * Thrown internally to unwind to the "keep the original answer" path when a
67
+ * structured-output re-ask completed but did not produce the object. Never
68
+ * escapes executeNativeGenerate.
69
+ */
70
+ class StructuredReformatRejected extends Error {
71
+ }
65
72
  const yieldsSchemaValidObject = (text, schema) => {
66
73
  const coerced = coerceJsonToSchema(text, schema);
67
74
  return coerced !== null && schemaAccepts(schema, coerced.structuredData);
@@ -869,20 +876,33 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
869
876
  // Structured output rides response_format, which is what Output.object did
870
877
  // on the ai path. Suppressed where the provider says the combination with
871
878
  // tools is rejected.
872
- const responseFormat = options.schema && !(hasTools && this.suppressResponseFormatWithTools())
879
+ const schemaJson = options.schema
880
+ ? convertZodToJsonSchema(options.schema)
881
+ : undefined;
882
+ // Suppression is the DEFAULT (see suppressResponseFormatWithTools) and
883
+ // tools ride along on virtually every turn, so for a `generate({ schema })`
884
+ // call this is the ORDINARY path, not an edge case.
885
+ const responseFormatSuppressed = schemaJson !== undefined &&
886
+ hasTools &&
887
+ this.suppressResponseFormatWithTools();
888
+ const responseFormat = schemaJson && !responseFormatSuppressed
873
889
  ? {
874
890
  type: "json",
875
- schema: convertZodToJsonSchema(options.schema),
891
+ schema: schemaJson,
876
892
  }
877
893
  : undefined;
878
894
  const conversation = (await this.buildMessagesForStream(options));
879
895
  const toolExecutionSummaries = [];
880
- const runLoop = (conv, format) => runNativeGenerateLoop({
896
+ const runLoop = (conv, format,
897
+ // Omit the tools for a pass that only has to reformat an answer the
898
+ // model has already given. Dropping them is what makes `response_format`
899
+ // legal again on a vendor that refuses the two together.
900
+ withTools = true) => runNativeGenerateLoop({
881
901
  doGenerate,
882
902
  conversation: conv,
883
- ...(v3Tools ? { tools: v3Tools } : {}),
903
+ ...(withTools && v3Tools ? { tools: v3Tools } : {}),
884
904
  toolsRecord,
885
- ...(hasTools && options.toolChoice
905
+ ...(withTools && hasTools && options.toolChoice
886
906
  ? { toolChoice: resolveToolChoice(options, toolsRecord, true) }
887
907
  : {}),
888
908
  // The per-call `timeout` keeps its per-MODEL-CALL meaning once
@@ -950,11 +970,41 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
950
970
  options.schema !== undefined &&
951
971
  !yieldsSchemaValidObject(loop.text, options.schema)) {
952
972
  logger.warn(`[${this.providerName}] response_format did not yield a schema-valid object — retrying with the schema in the system prompt`, { provider: this.providerName, model: modelId });
953
- loop = await runLoop(appendJsonSchemaInstruction(conversation, responseFormat.schema), undefined);
973
+ // Unlike the catch above — where the first call threw and reported no
974
+ // usage — this branch is reached from a call that SUCCEEDED and was
975
+ // billed. Replacing `loop` outright would drop the tool phase's tokens
976
+ // from the caller's usage while the vendor still charges for them.
977
+ const rejected = loop;
978
+ const retried = await runLoop(appendJsonSchemaInstruction(conversation, responseFormat.schema), undefined);
979
+ loop = {
980
+ ...retried,
981
+ inputTokens: rejected.inputTokens + retried.inputTokens,
982
+ outputTokens: rejected.outputTokens + retried.outputTokens,
983
+ cacheReadTokens: (rejected.cacheReadTokens ?? 0) + (retried.cacheReadTokens ?? 0),
984
+ cacheWriteTokens: (rejected.cacheWriteTokens ?? 0) + (retried.cacheWriteTokens ?? 0),
985
+ };
986
+ }
987
+ // Ordinary case: tools rode along, so response_format was dropped and the
988
+ // model was never asked for JSON at all. Recover it with a tool-free
989
+ // re-ask. See reformatWithoutTools for why it is not done in the prompt.
990
+ if (responseFormatSuppressed && schemaJson && options.schema) {
991
+ loop = await this.reformatWithoutTools({
992
+ toolPhase: loop,
993
+ conversation,
994
+ schemaJson,
995
+ schema: options.schema,
996
+ runLoop,
997
+ modelId,
998
+ abortSignal: options.abortSignal,
999
+ });
954
1000
  }
955
1001
  const { text, finishReason, toolsUsed } = loop;
956
1002
  const inputTokens = loop.inputTokens;
957
1003
  const outputTokens = loop.outputTokens;
1004
+ // `loop.rawFinishReason` is where reformatWithoutTools's raw/unified
1005
+ // pairing lands — without forwarding it here, that pairing logic is
1006
+ // computed but never reaches the caller, and `result.rawFinishReason`
1007
+ // is silently undefined regardless of what the tool phase reported.
958
1008
  // stopReason / stepsUsed parity with the other native loops (Vertex
959
1009
  // Gemini / Claude / Bedrock) and with the ai-package path this replaced.
960
1010
  // Without them a consumer cannot tell a completed turn from one the step
@@ -973,6 +1023,9 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
973
1023
  provider: this.providerName,
974
1024
  model: modelId,
975
1025
  finishReason,
1026
+ ...(loop.rawFinishReason
1027
+ ? { rawFinishReason: loop.rawFinishReason }
1028
+ : {}),
976
1029
  stopReason,
977
1030
  stepsUsed,
978
1031
  usage: {
@@ -999,6 +1052,253 @@ export class OpenAIChatCompletionsProvider extends BaseProvider {
999
1052
  };
1000
1053
  return this.finalizeNativeGenerate(enhanced, options, startTime);
1001
1054
  }
1055
+ /**
1056
+ * Recover structured output on a turn where tools forced `response_format`
1057
+ * off the wire.
1058
+ *
1059
+ * Runs only after the tool turn is complete, and only when its answer does
1060
+ * not already satisfy the schema. The re-ask drops the tools, which is what
1061
+ * makes native `response_format` legal again on a vendor that refuses the two
1062
+ * together, so this is a formatting pass over an answer the model has already
1063
+ * produced rather than a second attempt at the question.
1064
+ *
1065
+ * The schema is deliberately NOT spelled into the tool turn's system prompt
1066
+ * instead. That was tried and reverted: a model handed a JSON Schema next to a
1067
+ * tool list can read it as another tool — Groq's llama-3.3 answers by calling
1068
+ * one named "json", which the server rejects — turning a wrong answer into a
1069
+ * hard error.
1070
+ *
1071
+ * Returns the merged result, or the untouched `toolPhase` whenever the re-ask
1072
+ * cannot be run or cannot be trusted, so it can improve an outcome but never
1073
+ * degrade one.
1074
+ */
1075
+ async reformatWithoutTools(args) {
1076
+ const { toolPhase, conversation, schemaJson, schema, runLoop, modelId, abortSignal, } = args;
1077
+ let loop = toolPhase;
1078
+ // A function, not a captured boolean: the signal is read once before the
1079
+ // re-ask and again after it, and it can legitimately flip in between — a
1080
+ // const would let the compiler narrow the second read away.
1081
+ const isCancelled = () => abortSignal?.aborted === true;
1082
+ // The suppressed case, which is the ordinary one: tools rode along, so
1083
+ // `response_format` was dropped — and because BOTH branches above are keyed
1084
+ // on `responseFormat !== undefined`, the very condition that dropped it also
1085
+ // disabled the fallback that would have asked for the object in words. The
1086
+ // model was never asked for JSON in any form, answered in prose, and
1087
+ // `structuredData` came back unset, breaking the documented
1088
+ // `generate({ schema })` guarantee.
1089
+ //
1090
+ // The fix is NOT to spell the schema into the system prompt of the tool
1091
+ // turn. That collides with the tool-call channel: Groq's llama-3.3, handed
1092
+ // a JSON Schema while tools are declared, answers by trying to call a tool
1093
+ // literally named "json", and the server rejects the turn outright — a hard
1094
+ // error where there used to be a merely wrong answer.
1095
+ //
1096
+ // Instead, let the tool turn finish untouched and re-ask ONCE with the
1097
+ // tools removed, which is what makes native `response_format` legal again.
1098
+ // By then the tool phase is over and this is a pure formatting pass over an
1099
+ // answer the model has already produced.
1100
+ //
1101
+ // Wrapped so it can only ever improve the outcome: if the reformat pass
1102
+ // fails for any reason, the original tool-phase result stands and the turn
1103
+ // succeeds exactly as it did before.
1104
+ if (
1105
+ // Nothing to reformat. An empty completion has its own handling
1106
+ // downstream, and asking a model to restate a blank answer as JSON
1107
+ // invites it to invent one.
1108
+ loop.text.trim().length > 0 &&
1109
+ // Already cancelled: starting a second request here could only burn
1110
+ // quota on a turn nobody is waiting for.
1111
+ !isCancelled() &&
1112
+ !yieldsSchemaValidObject(loop.text, schema)) {
1113
+ logger.warn(`[${this.providerName}] tools suppressed response_format and the answer is not schema-valid — re-asking once without tools`, { provider: this.providerName, model: modelId });
1114
+ // Every request this block makes is billed, including the ones whose
1115
+ // ANSWER is discarded — a re-ask the vendor answered off-schema, and a
1116
+ // re-ask rejected below for still not being the object. Accumulate as
1117
+ // each response lands rather than reading the survivor at the end,
1118
+ // because the acceptance throw unwinds past the merge: reporting only
1119
+ // the kept response would bill a four-request turn as a two-request one.
1120
+ const billedReask = {
1121
+ inputTokens: 0,
1122
+ outputTokens: 0,
1123
+ cacheReadTokens: 0,
1124
+ cacheWriteTokens: 0,
1125
+ };
1126
+ const bill = (billed) => {
1127
+ billedReask.inputTokens += billed.inputTokens;
1128
+ billedReask.outputTokens += billed.outputTokens;
1129
+ billedReask.cacheReadTokens += billed.cacheReadTokens ?? 0;
1130
+ billedReask.cacheWriteTokens += billed.cacheWriteTokens ?? 0;
1131
+ };
1132
+ try {
1133
+ // `conversation` is load-bearing here, and its contents are not
1134
+ // obvious: runNativeGenerateLoop PUSHES each assistant tool-call turn
1135
+ // and each tool-result turn onto this very array, but breaks out before
1136
+ // pushing the final text answer. So it already carries the whole tool
1137
+ // exchange and is missing exactly one message — the answer — which is
1138
+ // what the assistant turn below supplies. That is what lets the
1139
+ // reformat restate tool-derived values instead of re-answering blind.
1140
+ // If that loop is ever changed to build a new array instead of pushing
1141
+ // onto its input, this call must be changed to take the loop's
1142
+ // conversation, or the reformat goes blind to the tools.
1143
+ const reaskConversation = [
1144
+ ...conversation,
1145
+ { role: "assistant", content: toolPhase.text },
1146
+ {
1147
+ role: "user",
1148
+ content:
1149
+ // "value", not "object": ValidationSchema also accepts array
1150
+ // and scalar roots (z.array(...), z.string()). Demanding an
1151
+ // object told a compliant model to emit something those roots
1152
+ // can never satisfy, so yieldsSchemaValidObject rejected every
1153
+ // such re-ask and the recovery silently never fired for them.
1154
+ "Return that same answer as a single JSON value conforming to " +
1155
+ "the required schema. No prose before or after it, and no " +
1156
+ "markdown code fence.",
1157
+ },
1158
+ ];
1159
+ // Two strategies, native first. A vendor can reject the SCHEMA itself
1160
+ // rather than the request: Groq answers a non-object root with
1161
+ // "invalid JSON schema for response_format: schema must have type
1162
+ // 'object'", so every array- and scalar-root schema fails here. The
1163
+ // non-suppressed path already degrades to spelling the schema into the
1164
+ // prompt (its catch classifies that error via
1165
+ // isToolsSchemaConflictError); this path has to do the same or the
1166
+ // recovery is dead for exactly the roots the "value" wording was meant
1167
+ // to serve. Still tools-free either way, so response_format stays legal.
1168
+ let reformatted;
1169
+ let schemaSpelledOut = false;
1170
+ try {
1171
+ reformatted = await runLoop(reaskConversation, { type: "json", schema: schemaJson }, false);
1172
+ bill(reformatted);
1173
+ }
1174
+ catch (nativeFormatError) {
1175
+ if (isCancelled()) {
1176
+ throw nativeFormatError;
1177
+ }
1178
+ logger.warn(`[${this.providerName}] the re-ask's response_format was rejected — retrying with the schema in the prompt`, {
1179
+ provider: this.providerName,
1180
+ model: modelId,
1181
+ error: nativeFormatError instanceof Error
1182
+ ? nativeFormatError.message
1183
+ : String(nativeFormatError),
1184
+ });
1185
+ reformatted = await runLoop(appendJsonSchemaInstruction(reaskConversation, schemaJson), undefined, false);
1186
+ bill(reformatted);
1187
+ schemaSpelledOut = true;
1188
+ }
1189
+ // A vendor whose `response_format` cannot carry a schema does not
1190
+ // reject the request — it accepts it and answers with JSON of a shape
1191
+ // it invented, because the schema never reached the model. DeepSeek is
1192
+ // the standing case: it supports only `json_object`, so the native
1193
+ // re-ask above returns well-formed JSON echoing the format envelope
1194
+ // (`{"type":"json_object","value":…}`) instead of the schema, and the
1195
+ // acceptance check below would keep the prose and count the recovery a
1196
+ // loss. The rejection branch already knows the remedy, so reach it by
1197
+ // OUTCOME as well as by exception: one more tools-free pass with the
1198
+ // schema spelled into the prompt. It costs an extra request only on a
1199
+ // turn that was already going to be thrown away.
1200
+ if (!schemaSpelledOut &&
1201
+ !isCancelled() &&
1202
+ !yieldsSchemaValidObject(reformatted.text, schema)) {
1203
+ logger.warn(`[${this.providerName}] the re-ask's response_format carried no schema — retrying with the schema in the prompt`, { provider: this.providerName, model: modelId });
1204
+ reformatted = await runLoop(appendJsonSchemaInstruction(reaskConversation, schemaJson), undefined, false);
1205
+ bill(reformatted);
1206
+ }
1207
+ // Accept the reformat ONLY if it actually produced the object. Sending
1208
+ // response_format is not the same as being obeyed: some vendors ignore
1209
+ // it outright (the branch above this one exists for precisely that),
1210
+ // and the pass can also be cut short by the same maxTokens cap the
1211
+ // prose answer just used. Without this check a substantive prose answer
1212
+ // could be replaced by a worse non-answer, which is the one thing this
1213
+ // fallback must never do.
1214
+ if (!yieldsSchemaValidObject(reformatted.text, schema)) {
1215
+ logger.warn(`[${this.providerName}] the tool-free re-ask did not yield a schema-valid object either; keeping the original answer`, { provider: this.providerName, model: modelId });
1216
+ throw new StructuredReformatRejected();
1217
+ }
1218
+ // Keep the tool phase's accounting. The reformat pass ran without
1219
+ // tools, so on its own it reports no tool use and only its own tokens —
1220
+ // publishing that verbatim would erase the tools the turn really did
1221
+ // call and under-report what the turn really did cost.
1222
+ loop = {
1223
+ ...reformatted,
1224
+ toolsUsed: [
1225
+ ...new Set([...toolPhase.toolsUsed, ...reformatted.toolsUsed]),
1226
+ ],
1227
+ inputTokens: toolPhase.inputTokens + billedReask.inputTokens,
1228
+ outputTokens: toolPhase.outputTokens + billedReask.outputTokens,
1229
+ cacheReadTokens: (toolPhase.cacheReadTokens ?? 0) + billedReask.cacheReadTokens,
1230
+ cacheWriteTokens: (toolPhase.cacheWriteTokens ?? 0) + billedReask.cacheWriteTokens,
1231
+ // NOT summed. `steps` is the agentic-loop budget that `maxSteps`
1232
+ // bounds, and the reformat is not a tool step — it runs after the
1233
+ // loop, with no tools. Adding it lets `stepsUsed` exceed the very
1234
+ // bound it is measured against, and pushes a tool phase that stopped
1235
+ // one step short of the cap over the `stepsUsed >= maxSteps`
1236
+ // threshold below, reporting a "step-cap" that never happened.
1237
+ steps: toolPhase.steps,
1238
+ // Only the TOOL phase can end for an alarming reason — the reformat
1239
+ // runs without tools and stops on its own — so any tool-phase reason
1240
+ // other than a clean "stop" is a signal the caller must still get.
1241
+ // Taking the reformat's cheerful "stop" verbatim would erase it:
1242
+ // "tool-calls" is what the step-cap stopReason just below is derived
1243
+ // from, and "length" is what tells the truncation machinery the
1244
+ // underlying answer was cut off. A turn cut short would report itself
1245
+ // as a clean completion. Deferring to the reformat only on a clean
1246
+ // tool phase also lets a reformat that itself hit the cap surface its
1247
+ // own "length".
1248
+ //
1249
+ // `rawFinishReason` moves WITH it. It arrives via the spread, so
1250
+ // taking the tool phase's unified reason while leaving the reformat's
1251
+ // raw one would hand a consumer a contradictory pair — unified
1252
+ // "length" beside raw "stop".
1253
+ ...(toolPhase.finishReason !== "stop"
1254
+ ? {
1255
+ finishReason: toolPhase.finishReason,
1256
+ // Override unconditionally: even when the tool phase has no
1257
+ // raw reason of its own, the reformat's raw "stop" must not
1258
+ // survive alongside a non-"stop" unified reason — that
1259
+ // contradictory pairing is exactly what this block exists to
1260
+ // prevent.
1261
+ rawFinishReason: toolPhase.rawFinishReason,
1262
+ }
1263
+ : { finishReason: reformatted.finishReason }),
1264
+ };
1265
+ }
1266
+ catch (reformatError) {
1267
+ // A caller who cancelled deserves their cancellation, but OUR OWN turn
1268
+ // deadline must not turn an answer they used to receive into an error:
1269
+ // withTurnTimeout races no timer, it only translates errors, so a
1270
+ // deadline reached DURING the reformat would do exactly that. The two
1271
+ // are distinguishable even though both arrive on one composed signal —
1272
+ // AbortSignal.any propagates the reason of whichever fired, and the
1273
+ // turn timer aborts with a TimeoutError. So: our deadline keeps the
1274
+ // tool-phase answer, a caller's abort propagates.
1275
+ if (isCancelled() && !(abortSignal?.reason instanceof TimeoutError)) {
1276
+ throw reformatError;
1277
+ }
1278
+ if (!(reformatError instanceof StructuredReformatRejected)) {
1279
+ logger.warn(`[${this.providerName}] the tool-free structured-output re-ask failed; keeping the original answer`, {
1280
+ provider: this.providerName,
1281
+ model: modelId,
1282
+ error: reformatError instanceof Error
1283
+ ? reformatError.message
1284
+ : String(reformatError),
1285
+ });
1286
+ }
1287
+ // The answer is the tool phase's, but the cost is not: the re-ask
1288
+ // requests above were charged for whether or not their answers were
1289
+ // used. `loop` is still the untouched tool phase here — the merge
1290
+ // above never ran — so carry the bill onto it.
1291
+ loop = {
1292
+ ...loop,
1293
+ inputTokens: loop.inputTokens + billedReask.inputTokens,
1294
+ outputTokens: loop.outputTokens + billedReask.outputTokens,
1295
+ cacheReadTokens: (loop.cacheReadTokens ?? 0) + billedReask.cacheReadTokens,
1296
+ cacheWriteTokens: (loop.cacheWriteTokens ?? 0) + billedReask.cacheWriteTokens,
1297
+ };
1298
+ }
1299
+ }
1300
+ return loop;
1301
+ }
1002
1302
  async executeStream(options, _analysisSchema) {
1003
1303
  this.validateStreamOptions(options);
1004
1304
  const startTime = Date.now();