@juspay/neurolink 12.41.3 → 12.41.5

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.
@@ -6,7 +6,9 @@ import { DEFAULT_MAX_STEPS } from "../../core/constants.js";
6
6
  import { runAgenticLoop } from "../../core/loopEngine.js";
7
7
  import { resolveToolExecutionRecords } from "../../core/toolExecutionRecorder.js";
8
8
  import { transformToolExecutions } from "../../utils/transformationUtils.js";
9
+ import { composeAbortSignals } from "../../utils/timeout.js";
9
10
  import { createBedrockLoopAdapter } from "./loopAdapter.js";
11
+ import { bedrockChunksToV3Stream, bedrockConversationFromV3Prompt, bedrockV3StreamToChunks, buildV3PromptFromBedrock, isTextOnlyBedrockConversation, unsentBedrockMiddlewareOptions, unwrapBedrockToolInputSchema, } from "./modelBridge.js";
10
12
  import { AuthenticationError, ProviderError, RateLimitError, } from "../../types/index.js";
11
13
  import { classifyProviderError } from "../../utils/errorClassifier.js";
12
14
  import { isAbortError, withTimeout } from "../../utils/errorHandling.js";
@@ -351,47 +353,173 @@ export class AmazonBedrockProvider extends BaseProvider {
351
353
  const tools = await this.resolveTurnTools(options.tools, options.disableTools);
352
354
  const toolConfig = this.formatToolsForBedrock(tools);
353
355
  const sampling = resolveSamplingParams(this.providerName, this.modelName || this.getDefaultModel(), { temperature: options.temperature ?? 0.7 }, "bedrock.converse");
354
- const adapter = createBedrockLoopAdapter({
355
- client: this.bedrockClient,
356
- streaming: false,
357
- region: this.region,
358
- maxSteps,
359
- buildCommandInput: (conversation) => ({
360
- modelId: this.modelName || this.getDefaultModel(),
361
- messages: this.convertToAWSMessages(conversation),
362
- system: [
363
- {
364
- text: options.systemPrompt ||
365
- "You are a helpful assistant with access to external tools. Use tools when necessary to provide accurate information.",
356
+ const systemPromptText = options.systemPrompt ||
357
+ "You are a helpful assistant with access to external tools. Use tools when necessary to provide accurate information.";
358
+ // Builds a fresh adapter per turn — the generate model's `doGenerate` may
359
+ // run this more than once is never true today (one call per turn), but a
360
+ // fresh adapter keeps `buildCommandInput` closed over the exact
361
+ // (possibly middleware-transformed) system/conversation/sampling for
362
+ // that call, rather than the outer ones.
363
+ const runTurn = (conversation, system, maxTokens, temperature, signal) => {
364
+ const adapter = createBedrockLoopAdapter({
365
+ client: this.bedrockClient,
366
+ streaming: false,
367
+ region: this.region,
368
+ maxSteps,
369
+ buildCommandInput: (conv) => ({
370
+ modelId: this.modelName || this.getDefaultModel(),
371
+ messages: this.convertToAWSMessages(conv),
372
+ // Converse rejects a blank block; transformParams may empty this.
373
+ ...(system ? { system: [{ text: system }] } : {}),
374
+ inferenceConfig: {
375
+ maxTokens,
376
+ ...(temperature !== undefined && { temperature }),
366
377
  },
367
- ],
368
- inferenceConfig: {
369
- maxTokens: options.maxTokens,
370
- ...(sampling.temperature !== undefined && {
371
- temperature: sampling.temperature,
372
- }),
373
- },
374
- ...(toolConfig ? { toolConfig } : {}),
375
- }),
376
- });
377
- try {
378
- const { resultPromise } = runAgenticLoop(adapter, this.conversationHistory, {
378
+ ...(toolConfig ? { toolConfig } : {}),
379
+ }),
380
+ });
381
+ const { resultPromise } = runAgenticLoop(adapter, conversation, {
379
382
  tools: this.toEngineTools(tools),
380
- abortSignal: options.abortSignal,
383
+ abortSignal: signal,
381
384
  // `toEngineTools` adds no deadline of its own, so this is the only
382
385
  // per-tool bound on the turn. The engine defaults when it is absent.
383
386
  ...(options.toolTimeoutMs !== undefined
384
387
  ? { toolTimeoutMs: options.toolTimeoutMs }
385
388
  : {}),
386
389
  });
387
- const result = await resultPromise;
388
- this.conversationHistory = result.conversation;
389
- const input = result.usage.inputTokens;
390
- const output = result.usage.outputTokens;
391
- const cacheRead = result.usage.cacheReadTokens ?? 0;
392
- const cacheWrite = result.usage.cacheWriteTokens ?? 0;
390
+ return resultPromise;
391
+ };
392
+ try {
393
+ const initialConversation = this.conversationHistory;
394
+ let text;
395
+ let finishReason;
396
+ let rawFinishReason;
397
+ let usage;
398
+ let toolExecutions;
399
+ let conversationOut;
400
+ if (isTextOnlyBedrockConversation(initialConversation)) {
401
+ // Model middleware (transformParams/wrapGenerate) applies only to a
402
+ // text-only starting turn — see modelBridge.ts's header comment.
403
+ const v3Tools = toolConfig?.tools
404
+ ?.map((t) => t.toolSpec)
405
+ .filter((spec) => !!spec)
406
+ .map((spec) => ({
407
+ type: "function",
408
+ name: spec.name ?? "",
409
+ description: spec.description,
410
+ inputSchema: unwrapBedrockToolInputSchema(spec.inputSchema),
411
+ }));
412
+ // Set only if `doGenerate` actually ran the loop — a guardrails-style
413
+ // wrapGenerate can answer from its own precall without ever calling
414
+ // it, and the richer loop result (toolExecutions/conversation) is
415
+ // then unavailable, tolerated below via `loopResult?.`.
416
+ let capturedLoopResult;
417
+ const generateModel = {
418
+ specificationVersion: "v3",
419
+ provider: this.providerName,
420
+ modelId: this.modelName || this.getDefaultModel(),
421
+ supportedUrls: {},
422
+ doGenerate: async (params) => {
423
+ if (params.tools !== undefined && params.tools !== v3Tools) {
424
+ logger.warn("[AmazonBedrockProvider] middleware rewrote 'tools' on the generate path; the rewrite was ignored and the caller's original tool configuration was sent");
425
+ }
426
+ this.warnOnUnsentMiddlewareOptions("generate", params);
427
+ // Read AFTER transformParams: by AI SDK contract, `params` here
428
+ // is already the transformed version, so a prompt rewrite
429
+ // reaches the wire.
430
+ const { system, conversation } = bedrockConversationFromV3Prompt(params.prompt);
431
+ const loopResult = runTurn(conversation, system, params.maxOutputTokens ?? options.maxTokens, params.temperature ?? sampling.temperature,
432
+ // Composed, not `??`: a middleware's signal must not displace
433
+ // the caller's.
434
+ composeAbortSignals(options.abortSignal, params.abortSignal));
435
+ capturedLoopResult = loopResult;
436
+ const resolved = await loopResult;
437
+ return {
438
+ content: resolved.text
439
+ ? [{ type: "text", text: resolved.text }]
440
+ : [],
441
+ finishReason: {
442
+ unified: resolved.finishReason,
443
+ raw: resolved.rawStopReason,
444
+ },
445
+ usage: {
446
+ inputTokens: {
447
+ // `inputTokens` is the fresh portion only; AWS reports cache
448
+ // reads/writes beside it, so `total` sums all three.
449
+ total: resolved.usage.inputTokens +
450
+ (resolved.usage.cacheReadTokens ?? 0) +
451
+ (resolved.usage.cacheWriteTokens ?? 0),
452
+ noCache: resolved.usage.inputTokens,
453
+ cacheRead: resolved.usage.cacheReadTokens,
454
+ cacheWrite: resolved.usage.cacheWriteTokens,
455
+ },
456
+ outputTokens: { total: resolved.usage.outputTokens },
457
+ },
458
+ };
459
+ },
460
+ // This model exists only to be driven through doGenerate.
461
+ doStream: () => {
462
+ throw new Error("AmazonBedrockProvider's generate model does not implement doStream");
463
+ },
464
+ };
465
+ const wrappedModel = await this.applyMiddlewareToModel(generateModel, options);
466
+ if (typeof wrappedModel === "string") {
467
+ throw new Error("AmazonBedrockProvider: middleware returned a model id string, not a native model handle");
468
+ }
469
+ const v3Result = await wrappedModel.doGenerate({
470
+ prompt: buildV3PromptFromBedrock(systemPromptText, initialConversation),
471
+ maxOutputTokens: options.maxTokens,
472
+ temperature: sampling.temperature,
473
+ ...(v3Tools ? { tools: v3Tools } : {}),
474
+ // Seed the signal so a middleware can compose with it, not replace it.
475
+ abortSignal: options.abortSignal,
476
+ });
477
+ // Settled, not re-thrown: a wrapGenerate may have caught the loop's
478
+ // rejection and answered itself, and a bare `await` would re-throw it
479
+ // over that recovered result.
480
+ const loopResult = await capturedLoopResult?.catch(() => undefined);
481
+ text = v3Result.content
482
+ .filter((part) => part.type === "text")
483
+ .map((part) => part.text)
484
+ .join("");
485
+ finishReason =
486
+ loopResult?.finishReason ?? v3Result.finishReason.unified;
487
+ rawFinishReason =
488
+ loopResult?.rawStopReason ?? v3Result.finishReason.raw;
489
+ usage = loopResult?.usage ?? {
490
+ // `AgenticLoopUsage.inputTokens` is the fresh count, so read
491
+ // `noCache`; `total` would double-count the cache tokens below.
492
+ inputTokens: v3Result.usage.inputTokens.noCache ??
493
+ v3Result.usage.inputTokens.total ??
494
+ 0,
495
+ outputTokens: v3Result.usage.outputTokens.total ?? 0,
496
+ ...(v3Result.usage.inputTokens.cacheRead !== undefined && {
497
+ cacheReadTokens: v3Result.usage.inputTokens.cacheRead,
498
+ }),
499
+ ...(v3Result.usage.inputTokens.cacheWrite !== undefined && {
500
+ cacheWriteTokens: v3Result.usage.inputTokens.cacheWrite,
501
+ }),
502
+ };
503
+ toolExecutions = loopResult?.toolExecutions ?? [];
504
+ conversationOut = loopResult?.conversation ?? initialConversation;
505
+ }
506
+ else {
507
+ this.logMultimodalMiddlewareSkip(options);
508
+ const loopResult = await runTurn(initialConversation, systemPromptText, options.maxTokens, sampling.temperature, options.abortSignal);
509
+ text = loopResult.text || "";
510
+ finishReason = loopResult.finishReason;
511
+ rawFinishReason = loopResult.rawStopReason;
512
+ usage = loopResult.usage;
513
+ toolExecutions = loopResult.toolExecutions;
514
+ conversationOut = loopResult.conversation;
515
+ }
516
+ this.conversationHistory = conversationOut;
517
+ const input = usage.inputTokens;
518
+ const output = usage.outputTokens;
519
+ const cacheRead = usage.cacheReadTokens ?? 0;
520
+ const cacheWrite = usage.cacheWriteTokens ?? 0;
393
521
  return {
394
- text: result.text || "",
522
+ text: text || "",
395
523
  usage: {
396
524
  input,
397
525
  output,
@@ -406,9 +534,9 @@ export class AmazonBedrockProvider extends BaseProvider {
406
534
  // so a turn stopped at the step cap reported "tool_use" here while
407
535
  // the same turn reported "tool-calls" when streamed. The raw value is
408
536
  // kept alongside rather than dropped.
409
- finishReason: result.finishReason,
410
- rawFinishReason: result.rawStopReason,
411
- toolExecutions: result.toolExecutions,
537
+ finishReason,
538
+ rawFinishReason,
539
+ toolExecutions,
412
540
  };
413
541
  }
414
542
  catch (error) {
@@ -822,12 +950,24 @@ export class AmazonBedrockProvider extends BaseProvider {
822
950
  catch (error) {
823
951
  logger.debug("[TRACE] executeStream CATCH - error caught from streamingConversationLoop");
824
952
  const errorObj = error;
825
- // Check if error is related to streaming permissions
826
- const isPermissionError = errorObj?.name === "AccessDeniedException" ||
827
- errorObj?.name === "UnauthorizedOperation" ||
828
- errorObj?.message?.includes("bedrock:InvokeModelWithResponseStream") ||
829
- errorObj?.message?.includes("streaming") ||
830
- errorObj?.message?.includes("ConverseStream");
953
+ // Check if error is related to streaming permissions. Gated on the
954
+ // real AWS SDK service-exception shape ($metadata) first: a
955
+ // middleware's wrapStream/transformParams can throw an arbitrary
956
+ // Error whose message happens to contain "streaming" or whose name
957
+ // happens to collide with an AWS exception name, and without this
958
+ // guard that error was silently swallowed and retried as if AWS
959
+ // itself had rejected the streaming call (see
960
+ // BedrockRuntimeServiceException in the AWS SDK, which carries
961
+ // $fault/$metadata on every genuine service exception).
962
+ const hasAwsServiceExceptionShape = typeof errorObj === "object" &&
963
+ errorObj !== null &&
964
+ "$metadata" in errorObj;
965
+ const isPermissionError = hasAwsServiceExceptionShape &&
966
+ (errorObj?.name === "AccessDeniedException" ||
967
+ errorObj?.name === "UnauthorizedOperation" ||
968
+ errorObj?.message?.includes("bedrock:InvokeModelWithResponseStream") ||
969
+ errorObj?.message?.includes("streaming") ||
970
+ errorObj?.message?.includes("ConverseStream"));
831
971
  logger.debug("[TRACE] executeStream CATCH - checking if permission error");
832
972
  logger.debug(`[TRACE] executeStream CATCH - isPermissionError=${isPermissionError}`);
833
973
  if (isPermissionError) {
@@ -922,6 +1062,152 @@ export class AmazonBedrockProvider extends BaseProvider {
922
1062
  }
923
1063
  });
924
1064
  }
1065
+ /**
1066
+ * A multimodal turn skips model middleware (see modelBridge.ts's header).
1067
+ * WARN when middleware was configured, per request or on the provider,
1068
+ * since it silently did not run; multimodal turns alone are unremarkable,
1069
+ * so stay at debug otherwise.
1070
+ */
1071
+ logMultimodalMiddlewareSkip(options) {
1072
+ if (options.middleware ?? this.middlewareOptions) {
1073
+ logger.warn("[AmazonBedrockProvider] multimodal turn — configured middleware was NOT applied (text-only turns only)");
1074
+ return;
1075
+ }
1076
+ logger.debug("[AmazonBedrockProvider] multimodal turn — skipping model middleware wrapping (text-only turns only)");
1077
+ }
1078
+ /**
1079
+ * `inferenceConfig` carries `maxTokens` and `temperature` only, so any other
1080
+ * sampling option a middleware sets would be dropped without a trace.
1081
+ * `tools` is reported where it is compared, at each call site.
1082
+ */
1083
+ warnOnUnsentMiddlewareOptions(path, params) {
1084
+ const unsent = unsentBedrockMiddlewareOptions(params);
1085
+ if (unsent.length > 0) {
1086
+ logger.warn(`[AmazonBedrockProvider] middleware set options the native Bedrock path does not send on the ${path} path; ignored: ${unsent.map((name) => `'${name}'`).join(", ")}`);
1087
+ }
1088
+ }
1089
+ /**
1090
+ * Runs the turn behind `streamingConversationLoop`: the model-middleware
1091
+ * path for a text-only starting turn, or the direct fallback for a
1092
+ * multimodal one. Extracted only to keep `streamingConversationLoop`
1093
+ * readable — the branching logic itself is unchanged from what it
1094
+ * replaced.
1095
+ */
1096
+ async buildBedrockStreamTurn(params) {
1097
+ const { initialConversation, systemPromptText, options, sampling, toolConfig, turnAbort, runTurn, raceFirstStep, } = params;
1098
+ if (!isTextOnlyBedrockConversation(initialConversation)) {
1099
+ this.logMultimodalMiddlewareSkip(options);
1100
+ const turn = runTurn(initialConversation, systemPromptText, options.maxTokens, sampling.temperature, turnAbort.signal);
1101
+ await raceFirstStep(turn.resultPromise);
1102
+ return { stream: turn.stream, capturedLoopResult: turn.resultPromise };
1103
+ }
1104
+ // Model middleware (transformParams/wrapStream) applies only to a
1105
+ // text-only starting turn — see modelBridge.ts's header comment.
1106
+ const v3Tools = toolConfig?.tools
1107
+ ?.map((t) => t.toolSpec)
1108
+ .filter((spec) => !!spec)
1109
+ .map((spec) => ({
1110
+ type: "function",
1111
+ name: spec.name ?? "",
1112
+ description: spec.description,
1113
+ inputSchema: unwrapBedrockToolInputSchema(spec.inputSchema),
1114
+ }));
1115
+ // Set only once the loop actually starts — a guardrails-style wrapStream
1116
+ // can answer from its own precall without ever calling `doStream`
1117
+ // (correction 3), and the richer loop result (toolExecutions/
1118
+ // conversation/mapped finishReason) is then unavailable. The caller
1119
+ // tolerates `undefined`.
1120
+ let capturedLoopResult;
1121
+ const streamModel = {
1122
+ specificationVersion: "v3",
1123
+ provider: this.providerName,
1124
+ modelId: this.modelName || this.getDefaultModel(),
1125
+ supportedUrls: {},
1126
+ // This model exists only to be driven through doStream.
1127
+ doGenerate: () => {
1128
+ throw new Error("AmazonBedrockProvider's stream model does not implement doGenerate");
1129
+ },
1130
+ doStream: async (params) => {
1131
+ if (params.tools !== undefined && params.tools !== v3Tools) {
1132
+ logger.warn("[AmazonBedrockProvider] middleware rewrote 'tools' on the stream path; the rewrite was ignored and the caller's original tool configuration was sent");
1133
+ }
1134
+ this.warnOnUnsentMiddlewareOptions("stream", params);
1135
+ // Read AFTER transformParams: by AI SDK contract, `params` here is
1136
+ // already the transformed version, so a prompt rewrite reaches the
1137
+ // wire (correction 1).
1138
+ const { system, conversation } = bedrockConversationFromV3Prompt(params.prompt);
1139
+ const turn = runTurn(conversation, system, params.maxOutputTokens ?? options.maxTokens, params.temperature ?? sampling.temperature,
1140
+ // Composed, not `??`: turnAbort.signal already carries the
1141
+ // caller's abort, BaseProvider's teardown signal, and the stream
1142
+ // consumer's own cancel() (wired below) — a middleware's
1143
+ // transformParams-set abortSignal must add to that, never replace
1144
+ // it outright.
1145
+ composeAbortSignals(turnAbort.signal, params.abortSignal));
1146
+ capturedLoopResult = turn.resultPromise;
1147
+ await raceFirstStep(turn.resultPromise);
1148
+ const finish = turn.resultPromise.then((result) => ({
1149
+ type: "finish",
1150
+ finishReason: {
1151
+ unified: result.finishReason,
1152
+ raw: result.rawStopReason,
1153
+ },
1154
+ usage: {
1155
+ inputTokens: {
1156
+ // Same fresh-vs-total split as the generate path above —
1157
+ // `result.usage.inputTokens` excludes cache reads/writes.
1158
+ total: result.usage.inputTokens +
1159
+ (result.usage.cacheReadTokens ?? 0) +
1160
+ (result.usage.cacheWriteTokens ?? 0),
1161
+ noCache: result.usage.inputTokens,
1162
+ cacheRead: result.usage.cacheReadTokens,
1163
+ cacheWrite: result.usage.cacheWriteTokens,
1164
+ },
1165
+ outputTokens: { total: result.usage.outputTokens },
1166
+ },
1167
+ }));
1168
+ // The turn can reject before anything reads this terminal event.
1169
+ void finish.catch(() => undefined);
1170
+ return {
1171
+ stream: bedrockChunksToV3Stream(turn.stream, finish, () => turnAbort.abort()),
1172
+ };
1173
+ },
1174
+ };
1175
+ const wrappedModel = await this.applyMiddlewareToModel(streamModel, options);
1176
+ if (typeof wrappedModel === "string") {
1177
+ throw new Error("AmazonBedrockProvider: middleware returned a model id string, not a native model handle");
1178
+ }
1179
+ const v3StreamResult = await wrappedModel.doStream({
1180
+ prompt: buildV3PromptFromBedrock(systemPromptText, initialConversation),
1181
+ maxOutputTokens: options.maxTokens,
1182
+ temperature: sampling.temperature,
1183
+ ...(v3Tools ? { tools: v3Tools } : {}),
1184
+ abortSignal: turnAbort.signal,
1185
+ });
1186
+ return {
1187
+ stream: bedrockV3StreamToChunks(v3StreamResult.stream),
1188
+ capturedLoopResult,
1189
+ };
1190
+ }
1191
+ /**
1192
+ * Turns a settled loop outcome's usage into the shape both the analytics
1193
+ * resolution and the stream's own `finally` block report. Shared so the
1194
+ * two call sites in `streamingConversationLoop` can't drift.
1195
+ */
1196
+ summarizeBedrockStreamUsage(usage) {
1197
+ const input = usage?.inputTokens ?? 0;
1198
+ const output = usage?.outputTokens ?? 0;
1199
+ const cacheRead = usage?.cacheReadTokens ?? 0;
1200
+ const cacheWrite = usage?.cacheWriteTokens ?? 0;
1201
+ return {
1202
+ input,
1203
+ output,
1204
+ // Cache reads/writes are billed tokens reported separately from
1205
+ // inputTokens — the total must include them.
1206
+ total: input + cacheRead + cacheWrite + output,
1207
+ ...(cacheRead > 0 && { cacheReadTokens: cacheRead }),
1208
+ ...(cacheWrite > 0 && { cacheCreationTokens: cacheWrite }),
1209
+ };
1210
+ }
925
1211
  async streamingConversationLoop(options, streamSpan) {
926
1212
  const startTime = Date.now();
927
1213
  const maxSteps = options.maxSteps || DEFAULT_MAX_STEPS;
@@ -930,29 +1216,8 @@ export class AmazonBedrockProvider extends BaseProvider {
930
1216
  const tools = await this.resolveTurnTools(options.tools, options.disableTools);
931
1217
  const toolConfig = this.formatToolsForBedrock(tools);
932
1218
  const sampling = resolveSamplingParams(this.providerName, this.modelName || this.getDefaultModel(), { temperature: options.temperature ?? 0.7 }, "bedrock.converseStream");
933
- const baseAdapter = createBedrockLoopAdapter({
934
- client: this.bedrockClient,
935
- streaming: true,
936
- region: this.region,
937
- maxSteps,
938
- buildCommandInput: (conversation) => ({
939
- modelId: this.modelName || this.getDefaultModel(),
940
- messages: this.convertToAWSMessages(conversation),
941
- system: [
942
- {
943
- text: options.systemPrompt ||
944
- "You are a helpful assistant with access to external tools. Use tools when necessary to provide accurate information.",
945
- },
946
- ],
947
- inferenceConfig: {
948
- maxTokens: options.maxTokens,
949
- ...(sampling.temperature !== undefined && {
950
- temperature: sampling.temperature,
951
- }),
952
- },
953
- ...(toolConfig ? { toolConfig } : {}),
954
- }),
955
- });
1219
+ const systemPromptText = options.systemPrompt ||
1220
+ "You are a helpful assistant with access to external tools. Use tools when necessary to provide accurate information.";
956
1221
  // executeStream() falls back to non-streaming generate() when the first
957
1222
  // call fails on permissions, and that only works if the failure reaches it
958
1223
  // synchronously. The engine runs the turn in the background, so the first
@@ -976,42 +1241,97 @@ export class AmazonBedrockProvider extends BaseProvider {
976
1241
  resolve();
977
1242
  };
978
1243
  });
979
- const adapter = {
980
- ...baseAdapter,
981
- executeStep: async (request, channel, signal) => {
982
- const stepResult = await baseAdapter.executeStep(request, channel, signal);
983
- firstStepSent();
984
- return stepResult;
985
- },
1244
+ // Shared by both branches below: whichever turn runs awaits this right
1245
+ // after starting, so executeStream's permission fallback still sees a
1246
+ // synchronous first-call failure.
1247
+ const raceFirstStep = async (resultPromise) => {
1248
+ const settledHere = resultPromise.then((result) => ({ result, error: undefined }), (error) => ({ result: undefined, error }));
1249
+ // A turn that ends without ever completing a step — an abort before
1250
+ // the first request, or a failure the engine gave up retrying — would
1251
+ // leave `firstStep` pending forever, so the settled outcome releases
1252
+ // the wait too.
1253
+ await Promise.race([firstStep, settledHere]);
1254
+ if (!firstStepSucceeded) {
1255
+ const outcome = await settledHere;
1256
+ if (outcome.error) {
1257
+ throw outcome.error;
1258
+ }
1259
+ }
986
1260
  };
987
- streamSpan.addEvent("stream.api_call", {
988
- "bedrock.tool_count": toolConfig?.tools?.length ?? 0,
989
- });
990
- const { stream, resultPromise } = runAgenticLoop(adapter, this.conversationHistory, {
991
- tools: this.toEngineTools(tools),
992
- abortSignal: options.abortSignal,
993
- // `toEngineTools` adds no deadline of its own, so this is the only
994
- // per-tool bound on the turn. The engine defaults when it is absent.
995
- ...(options.toolTimeoutMs !== undefined
996
- ? { toolTimeoutMs: options.toolTimeoutMs }
997
- : {}),
1261
+ const runTurn = (conversation, system, maxTokens, temperature, signal) => {
1262
+ const baseAdapter = createBedrockLoopAdapter({
1263
+ client: this.bedrockClient,
1264
+ streaming: true,
1265
+ region: this.region,
1266
+ maxSteps,
1267
+ buildCommandInput: (conv) => ({
1268
+ modelId: this.modelName || this.getDefaultModel(),
1269
+ messages: this.convertToAWSMessages(conv),
1270
+ // Converse rejects a blank block; transformParams may empty this.
1271
+ ...(system ? { system: [{ text: system }] } : {}),
1272
+ inferenceConfig: {
1273
+ maxTokens,
1274
+ ...(temperature !== undefined && { temperature }),
1275
+ },
1276
+ ...(toolConfig ? { toolConfig } : {}),
1277
+ }),
1278
+ });
1279
+ const adapter = {
1280
+ ...baseAdapter,
1281
+ executeStep: async (request, channel, signal) => {
1282
+ const stepResult = await baseAdapter.executeStep(request, channel, signal);
1283
+ firstStepSent();
1284
+ return stepResult;
1285
+ },
1286
+ };
1287
+ streamSpan.addEvent("stream.api_call", {
1288
+ "bedrock.tool_count": toolConfig?.tools?.length ?? 0,
1289
+ });
1290
+ return runAgenticLoop(adapter, conversation, {
1291
+ tools: this.toEngineTools(tools),
1292
+ abortSignal: signal,
1293
+ // `toEngineTools` adds no deadline of its own, so this is the only
1294
+ // per-tool bound on the turn. The engine defaults when it is absent.
1295
+ ...(options.toolTimeoutMs !== undefined
1296
+ ? { toolTimeoutMs: options.toolTimeoutMs }
1297
+ : {}),
1298
+ });
1299
+ };
1300
+ const initialConversation = this.conversationHistory;
1301
+ // Turn-scoped abort: a V3 stream's `cancel()` (consumer broke out early)
1302
+ // must tear this turn's request down even when the caller passed no
1303
+ // signal. Chained so a caller abort still cascades.
1304
+ const turnAbort = new AbortController();
1305
+ if (options.abortSignal) {
1306
+ if (options.abortSignal.aborted) {
1307
+ turnAbort.abort();
1308
+ }
1309
+ else {
1310
+ options.abortSignal.addEventListener("abort", () => turnAbort.abort(), {
1311
+ once: true,
1312
+ });
1313
+ }
1314
+ }
1315
+ // The request is already sent when this can throw; abort it so it is not
1316
+ // orphaned once the caller (e.g. the permission fallback) moves on.
1317
+ const { stream, capturedLoopResult } = await this.buildBedrockStreamTurn({
1318
+ initialConversation,
1319
+ systemPromptText,
1320
+ options,
1321
+ sampling,
1322
+ toolConfig,
1323
+ turnAbort,
1324
+ runTurn,
1325
+ raceFirstStep,
1326
+ }).catch((error) => {
1327
+ turnAbort.abort();
1328
+ throw error;
998
1329
  });
999
1330
  // The stream surfaces the same failure, so this settled view exists only
1000
1331
  // so the turn's outcome can be read without a second unhandled rejection.
1001
- const settled = resultPromise.then((result) => ({ result, error: undefined }), (error) => ({ result: undefined, error }));
1002
- // A turn that ends without ever completing a step — an abort before the
1003
- // first request, or a failure the engine gave up retrying — would leave
1004
- // `firstStep` pending forever, so the settled outcome releases the wait
1005
- // too.
1006
- await Promise.race([firstStep, settled]);
1007
- if (!firstStepSucceeded) {
1008
- // The turn ended before any send succeeded. Surface its error here so
1009
- // executeStream's permission fallback still sees it synchronously.
1010
- const outcome = await settled;
1011
- if (outcome.error) {
1012
- throw outcome.error;
1013
- }
1014
- }
1332
+ // `capturedLoopResult` is undefined only when middleware blocked the turn
1333
+ // before `doStream` ran (correction 3) — every reader below tolerates it.
1334
+ const settled = (capturedLoopResult ?? Promise.resolve(undefined)).then((result) => ({ result, error: undefined }), (error) => ({ result: undefined, error }));
1015
1335
  const streamEmitter = this.neurolink?.getEventEmitter();
1016
1336
  const self = this;
1017
1337
  const metadata = {
@@ -1022,21 +1342,6 @@ export class AmazonBedrockProvider extends BaseProvider {
1022
1342
  const analyticsPromise = new Promise((resolve) => {
1023
1343
  resolveAnalytics = resolve;
1024
1344
  });
1025
- const usageFromOutcome = (usage) => {
1026
- const input = usage?.inputTokens ?? 0;
1027
- const output = usage?.outputTokens ?? 0;
1028
- const cacheRead = usage?.cacheReadTokens ?? 0;
1029
- const cacheWrite = usage?.cacheWriteTokens ?? 0;
1030
- return {
1031
- input,
1032
- output,
1033
- // Cache reads/writes are billed tokens reported separately from
1034
- // inputTokens — the total must include them.
1035
- total: input + cacheRead + cacheWrite + output,
1036
- ...(cacheRead > 0 && { cacheReadTokens: cacheRead }),
1037
- ...(cacheWrite > 0 && { cacheCreationTokens: cacheWrite }),
1038
- };
1039
- };
1040
1345
  // Bind analytics to the turn ending, not to the consumer draining the
1041
1346
  // stream. A caller that awaits `result.analytics` without iterating
1042
1347
  // `result.stream` would otherwise wait forever, because the generator
@@ -1044,7 +1349,7 @@ export class AmazonBedrockProvider extends BaseProvider {
1044
1349
  // harmless; the first call wins.
1045
1350
  void settled.then((outcome) => {
1046
1351
  resolveAnalytics(createAnalytics(this.providerName, this.modelName || this.getDefaultModel(), {
1047
- usage: usageFromOutcome(outcome.result?.usage),
1352
+ usage: this.summarizeBedrockStreamUsage(outcome.result?.usage),
1048
1353
  // createAnalytics derives toolCallCount from these. Passing usage
1049
1354
  // alone left every streamed Bedrock turn reporting no tool calls,
1050
1355
  // however many it made.
@@ -1078,7 +1383,7 @@ export class AmazonBedrockProvider extends BaseProvider {
1078
1383
  if (outcome.error) {
1079
1384
  streamErrored = true;
1080
1385
  }
1081
- const aggregatedUsage = usageFromOutcome(outcome.result?.usage);
1386
+ const aggregatedUsage = self.summarizeBedrockStreamUsage(outcome.result?.usage);
1082
1387
  if (outcome.result) {
1083
1388
  self.conversationHistory = outcome.result.conversation;
1084
1389
  metadata.finishReason = outcome.result.finishReason;