@juspay/neurolink 10.8.6 → 10.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +372 -372
  3. package/dist/core/baseProvider.js +5 -1
  4. package/dist/lib/core/baseProvider.js +5 -1
  5. package/dist/lib/processors/media/AudioProcessor.d.ts +5 -6
  6. package/dist/lib/processors/media/AudioProcessor.js +8 -18
  7. package/dist/lib/processors/media/VideoProcessor.d.ts +5 -0
  8. package/dist/lib/processors/media/VideoProcessor.js +8 -18
  9. package/dist/lib/providers/googleVertex/client.d.ts +20 -0
  10. package/dist/lib/providers/googleVertex/client.js +39 -15
  11. package/dist/lib/proxy/rollingWorkerSupervisor.d.ts +1 -0
  12. package/dist/lib/proxy/rollingWorkerSupervisor.js +41 -15
  13. package/dist/lib/types/proxy.d.ts +2 -0
  14. package/dist/lib/utils/mediaDuration.d.ts +23 -0
  15. package/dist/lib/utils/mediaDuration.js +45 -0
  16. package/dist/lib/utils/messageBuilder.d.ts +25 -0
  17. package/dist/lib/utils/messageBuilder.js +33 -12
  18. package/dist/lib/utils/multimodalOptionsBuilder.d.ts +5 -1
  19. package/dist/lib/utils/multimodalOptionsBuilder.js +8 -1
  20. package/dist/processors/media/AudioProcessor.d.ts +5 -6
  21. package/dist/processors/media/AudioProcessor.js +8 -18
  22. package/dist/processors/media/VideoProcessor.d.ts +5 -0
  23. package/dist/processors/media/VideoProcessor.js +8 -18
  24. package/dist/providers/googleVertex/client.d.ts +20 -0
  25. package/dist/providers/googleVertex/client.js +39 -15
  26. package/dist/proxy/rollingWorkerSupervisor.d.ts +1 -0
  27. package/dist/proxy/rollingWorkerSupervisor.js +41 -15
  28. package/dist/types/proxy.d.ts +2 -0
  29. package/dist/utils/mediaDuration.d.ts +23 -0
  30. package/dist/utils/mediaDuration.js +44 -0
  31. package/dist/utils/messageBuilder.d.ts +25 -0
  32. package/dist/utils/messageBuilder.js +33 -12
  33. package/dist/utils/multimodalOptionsBuilder.d.ts +5 -1
  34. package/dist/utils/multimodalOptionsBuilder.js +8 -1
  35. package/package.json +1 -1
@@ -166,7 +166,11 @@ export class BaseProvider {
166
166
  timestamp: Date.now(),
167
167
  });
168
168
  // ===== EARLY MULTIMODAL DETECTION =====
169
- const hasFileInput = !!options.input?.files?.length || !!options.input?.videoFiles?.length;
169
+ // #1259: audioFiles was missing here while videoFiles was present, so an
170
+ // audio-only stream skipped this branch entirely.
171
+ const hasFileInput = !!options.input?.files?.length ||
172
+ !!options.input?.videoFiles?.length ||
173
+ !!options.input?.audioFiles?.length;
170
174
  if (hasFileInput) {
171
175
  // ===== VIDEO ANALYSIS DETECTION =====
172
176
  // Check if video frames are present and handle with fake streaming
@@ -166,7 +166,11 @@ export class BaseProvider {
166
166
  timestamp: Date.now(),
167
167
  });
168
168
  // ===== EARLY MULTIMODAL DETECTION =====
169
- const hasFileInput = !!options.input?.files?.length || !!options.input?.videoFiles?.length;
169
+ // #1259: audioFiles was missing here while videoFiles was present, so an
170
+ // audio-only stream skipped this branch entirely.
171
+ const hasFileInput = !!options.input?.files?.length ||
172
+ !!options.input?.videoFiles?.length ||
173
+ !!options.input?.audioFiles?.length;
170
174
  if (hasFileInput) {
171
175
  // ===== VIDEO ANALYSIS DETECTION =====
172
176
  // Check if video frames are present and handle with fake streaming
@@ -179,13 +179,12 @@ export declare class AudioProcessor extends BaseFileProcessor<ProcessedAudio> {
179
179
  /**
180
180
  * Format a duration in seconds to a human-readable string.
181
181
  *
182
- * @param seconds - Duration in seconds
183
- * @returns Formatted string: "M:SS" for < 1 hour, "H:MM:SS" for >= 1 hour
182
+ * Delegates to the shared formatter so audio and video describe the same
183
+ * file the same way this used to render "0:02" where VideoProcessor
184
+ * rendered "2s".
184
185
  *
185
- * @example
186
- * formatDuration(225) // "3:45"
187
- * formatDuration(3750) // "1:02:30"
188
- * formatDuration(0) // "0:00"
186
+ * @param seconds - Duration in seconds
187
+ * @returns Formatted string: "45s", "3m 45s", "1h 2m 30s"
189
188
  */
190
189
  private formatDuration;
191
190
  /**
@@ -40,6 +40,7 @@ import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
40
40
  import { SIZE_LIMITS_MB } from "../config/index.js";
41
41
  import { FileErrorCode } from "../errors/index.js";
42
42
  import { withTimeout } from "../../utils/timeout.js";
43
+ import { formatMediaDuration } from "../../utils/mediaDuration.js";
43
44
  let _musicMetadata = null;
44
45
  async function loadMusicMetadata() {
45
46
  if (_musicMetadata) {
@@ -397,7 +398,7 @@ export class AudioProcessor extends BaseFileProcessor {
397
398
  textContent: "",
398
399
  metadata: {
399
400
  duration: 0,
400
- durationFormatted: "0:00",
401
+ durationFormatted: formatMediaDuration(0),
401
402
  codec: "unknown",
402
403
  lossless: false,
403
404
  fileSize: buffer.length,
@@ -608,26 +609,15 @@ export class AudioProcessor extends BaseFileProcessor {
608
609
  /**
609
610
  * Format a duration in seconds to a human-readable string.
610
611
  *
611
- * @param seconds - Duration in seconds
612
- * @returns Formatted string: "M:SS" for < 1 hour, "H:MM:SS" for >= 1 hour
612
+ * Delegates to the shared formatter so audio and video describe the same
613
+ * file the same way this used to render "0:02" where VideoProcessor
614
+ * rendered "2s".
613
615
  *
614
- * @example
615
- * formatDuration(225) // "3:45"
616
- * formatDuration(3750) // "1:02:30"
617
- * formatDuration(0) // "0:00"
616
+ * @param seconds - Duration in seconds
617
+ * @returns Formatted string: "45s", "3m 45s", "1h 2m 30s"
618
618
  */
619
619
  formatDuration(seconds) {
620
- if (!seconds || seconds <= 0) {
621
- return "0:00";
622
- }
623
- const totalSeconds = Math.round(seconds);
624
- const hours = Math.floor(totalSeconds / 3600);
625
- const minutes = Math.floor((totalSeconds % 3600) / 60);
626
- const secs = totalSeconds % 60;
627
- if (hours > 0) {
628
- return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
629
- }
630
- return `${minutes}:${String(secs).padStart(2, "0")}`;
620
+ return formatMediaDuration(seconds);
631
621
  }
632
622
  /**
633
623
  * Format bitrate to a human-readable string.
@@ -205,6 +205,11 @@ export declare class VideoProcessor extends BaseFileProcessor<ProcessedVideo> {
205
205
  /**
206
206
  * Format a duration in seconds to a human-readable string.
207
207
  *
208
+ * Delegates to the shared formatter so audio and video agree — see
209
+ * `formatMediaDuration`. Rounding replaces the previous truncation, so a
210
+ * 2.6s clip now reads "3s" rather than "2s" and matches what the audio
211
+ * side reports for the same stream.
212
+ *
208
213
  * @param seconds - Duration in seconds
209
214
  * @returns Formatted string (e.g., "1h 23m 45s")
210
215
  */
@@ -50,6 +50,7 @@ import { join } from "path";
50
50
  import { Readable } from "stream";
51
51
  import { pipeline } from "stream/promises";
52
52
  import { BaseFileProcessor } from "../base/BaseFileProcessor.js";
53
+ import { formatMediaDuration } from "../../utils/mediaDuration.js";
53
54
  import { SIZE_LIMITS_MB } from "../config/index.js";
54
55
  import { FileErrorCode } from "../errors/index.js";
55
56
  import { tracers, ATTR, withSpan } from "../../telemetry/index.js";
@@ -270,7 +271,7 @@ export class VideoProcessor extends BaseFileProcessor {
270
271
  keyframes: [],
271
272
  metadata: {
272
273
  duration: 0,
273
- durationFormatted: "0s",
274
+ durationFormatted: formatMediaDuration(0),
274
275
  width: 0,
275
276
  height: 0,
276
277
  codec: "unknown",
@@ -908,27 +909,16 @@ export class VideoProcessor extends BaseFileProcessor {
908
909
  /**
909
910
  * Format a duration in seconds to a human-readable string.
910
911
  *
912
+ * Delegates to the shared formatter so audio and video agree — see
913
+ * `formatMediaDuration`. Rounding replaces the previous truncation, so a
914
+ * 2.6s clip now reads "3s" rather than "2s" and matches what the audio
915
+ * side reports for the same stream.
916
+ *
911
917
  * @param seconds - Duration in seconds
912
918
  * @returns Formatted string (e.g., "1h 23m 45s")
913
919
  */
914
920
  formatDuration(seconds) {
915
- if (seconds <= 0) {
916
- return "0s";
917
- }
918
- const hours = Math.floor(seconds / 3600);
919
- const minutes = Math.floor((seconds % 3600) / 60);
920
- const secs = Math.floor(seconds % 60);
921
- const parts = [];
922
- if (hours > 0) {
923
- parts.push(`${hours}h`);
924
- }
925
- if (minutes > 0) {
926
- parts.push(`${minutes}m`);
927
- }
928
- if (secs > 0 || parts.length === 0) {
929
- parts.push(`${secs}s`);
930
- }
931
- return parts.join(" ");
921
+ return formatMediaDuration(seconds);
932
922
  }
933
923
  /**
934
924
  * Get a file extension from FileInfo, falling back to ".mp4".
@@ -141,6 +141,26 @@ export declare class GoogleVertexProvider extends BaseProvider {
141
141
  * Validate stream options
142
142
  */
143
143
  private validateStreamOptionsOnly;
144
+ /**
145
+ * Preprocess file input before routing to the native SDKs.
146
+ *
147
+ * BaseProvider runs this via `buildMultimodalMessagesArray`, but Vertex
148
+ * overrides both `generate()` and `executeStream()` to reach the native
149
+ * @google/genai / @anthropic-ai/vertex-sdk clients directly, so neither
150
+ * inherits it. Without this the file content never reaches the model and
151
+ * the reply is an entirely plausible "no document is attached" — a silent
152
+ * wrong answer rather than an error.
153
+ *
154
+ * #1258: only `generate()` used to call this, so the same document that
155
+ * `generate()` read back correctly came back as "no documents attached"
156
+ * through `stream()`. Sharing one method is what keeps the two paths from
157
+ * drifting apart again.
158
+ *
159
+ * #1259: the alias fold has to happen *before* the `files` check, or
160
+ * requests carrying only `audioFiles`/`videoFiles` look empty here and skip
161
+ * preprocessing entirely.
162
+ */
163
+ private preprocessNativeFileInput;
144
164
  protected executeStream(options: StreamOptions, _analysisSchema?: ZodType<unknown> | Schema<unknown>): Promise<StreamResult>;
145
165
  /**
146
166
  * Emit `stream:end` so the Pipeline B observability listener creates a
@@ -14,7 +14,7 @@ import { AuthenticationError, InvalidModelError, NetworkError, ProviderError, Ra
14
14
  import { ERROR_CODES, NeuroLinkError } from "../../utils/errorHandling.js";
15
15
  import { applyVertexAnthropicCacheBreakpoints } from "../../utils/anthropicCacheBreakpoints.js";
16
16
  import { FileDetector } from "../../utils/fileDetector.js";
17
- import { processUnifiedFilesArray } from "../../utils/messageBuilder.js";
17
+ import { mergeMediaFileAliases, processUnifiedFilesArray, } from "../../utils/messageBuilder.js";
18
18
  import { logger } from "../../utils/logger.js";
19
19
  import { hasRestrictedOutputLimit, RESTRICTED_OUTPUT_TOKEN_LIMIT, toVertexAnthropicModelId, } from "../../utils/modelDetection.js";
20
20
  import { detectImageMimeType } from "../../utils/imageDetection.js";
@@ -868,6 +868,40 @@ export class GoogleVertexProvider extends BaseProvider {
868
868
  validateStreamOptionsOnly(options) {
869
869
  this.validateStreamOptions(options);
870
870
  }
871
+ /**
872
+ * Preprocess file input before routing to the native SDKs.
873
+ *
874
+ * BaseProvider runs this via `buildMultimodalMessagesArray`, but Vertex
875
+ * overrides both `generate()` and `executeStream()` to reach the native
876
+ * @google/genai / @anthropic-ai/vertex-sdk clients directly, so neither
877
+ * inherits it. Without this the file content never reaches the model and
878
+ * the reply is an entirely plausible "no document is attached" — a silent
879
+ * wrong answer rather than an error.
880
+ *
881
+ * #1258: only `generate()` used to call this, so the same document that
882
+ * `generate()` read back correctly came back as "no documents attached"
883
+ * through `stream()`. Sharing one method is what keeps the two paths from
884
+ * drifting apart again.
885
+ *
886
+ * #1259: the alias fold has to happen *before* the `files` check, or
887
+ * requests carrying only `audioFiles`/`videoFiles` look empty here and skip
888
+ * preprocessing entirely.
889
+ */
890
+ async preprocessNativeFileInput(options) {
891
+ if (options.input) {
892
+ mergeMediaFileAliases(options.input);
893
+ }
894
+ if (!options.input?.files?.length) {
895
+ return;
896
+ }
897
+ try {
898
+ // Mutates options.input.text / .images / .pdfFiles in place.
899
+ await processUnifiedFilesArray(options, 100 * 1024 * 1024, this.providerName);
900
+ }
901
+ catch (fileError) {
902
+ logger.warn(`[GoogleVertex] processUnifiedFilesArray threw, continuing without file content: ${fileError instanceof Error ? fileError.message : String(fileError)}`);
903
+ }
904
+ }
871
905
  async executeStream(options, _analysisSchema) {
872
906
  // ALL models now use native SDKs - no more @ai-sdk/google-vertex dependency
873
907
  const modelName = options.model || this.modelName || getDefaultVertexModel();
@@ -889,6 +923,9 @@ export class GoogleVertexProvider extends BaseProvider {
889
923
  // Tool filter (a0269210): trust options.tools — caller (BaseProvider.stream)
890
924
  // already merged MCP/built-in tools and applied any enabledToolNames filter.
891
925
  const optionTools = options.tools || {};
926
+ // #1258: stream() must run the same file preprocessing generate()
927
+ // does, or attached files are dropped on this path alone.
928
+ await this.preprocessNativeFileInput(options);
892
929
  // Emit a `neurolink.message.build` span for the native stream path
893
930
  // so observability tooling sees the same hierarchy it sees on
894
931
  // Pipeline A. Without this, test:tracing's "Message Build Span"
@@ -5742,20 +5779,7 @@ export class GoogleVertexProvider extends BaseProvider {
5742
5779
  const baseTools = !options.disableTools
5743
5780
  ? await this.getToolsForStream(options)
5744
5781
  : {};
5745
- // Process the unified `input.files` array before routing to the
5746
- // native SDK. BaseProvider.generate() runs this preprocessing via
5747
- // buildMultimodalMessagesArray, but Vertex's override skips it,
5748
- // which would otherwise drop text-file content (and the
5749
- // mimetype-hint contract) on the floor. Mutates options.input.text /
5750
- // options.input.images / options.input.pdfFiles in place.
5751
- if (options.input?.files && options.input.files.length > 0) {
5752
- try {
5753
- await processUnifiedFilesArray(options, 100 * 1024 * 1024, this.providerName);
5754
- }
5755
- catch (fileError) {
5756
- logger.warn(`[GoogleVertex] processUnifiedFilesArray threw, continuing without file content: ${fileError instanceof Error ? fileError.message : String(fileError)}`);
5757
- }
5758
- }
5782
+ await this.preprocessNativeFileInput(options);
5759
5783
  // Emit a `neurolink.message.build` span so observability tooling
5760
5784
  // sees the message-construction phase even on the native (Pipeline B)
5761
5785
  // Vertex path. Pipeline A normally produces this via MessageBuilder;
@@ -32,6 +32,7 @@ export declare class RollingWorkerSupervisor {
32
32
  private spawnCandidate;
33
33
  private flushQueuedSockets;
34
34
  private transferSocket;
35
+ private maybeDrainWorker;
35
36
  private handleTransferFailure;
36
37
  private rejectSocket;
37
38
  private describeTransferError;
@@ -287,21 +287,19 @@ export class RollingWorkerSupervisor {
287
287
  generation,
288
288
  version: expectedVersion,
289
289
  dispose,
290
+ pendingTransfers: 0,
291
+ drainRequested: false,
290
292
  };
291
293
  this.active = activated;
292
294
  this.candidate = null;
293
295
  this.flushQueuedSockets();
294
296
  if (previous) {
295
297
  this.draining.set(previous.generation, previous);
296
- try {
297
- previous.handle.sendControl({
298
- type: "proxy-worker:drain",
299
- generation: previous.generation,
300
- });
301
- }
302
- catch {
303
- previous.handle.terminate("SIGTERM");
304
- }
298
+ // A worker can still own socket transfers accepted before this
299
+ // activation. Draining it before their IPC commits makes those
300
+ // clients fail even though the replacement is healthy.
301
+ previous.drainRequested = true;
302
+ this.maybeDrainWorker(previous);
305
303
  }
306
304
  this.options.log?.(`[proxy-supervisor] activated generation=${generation} pid=${handle.pid} version=${expectedVersion}`);
307
305
  this.publishState();
@@ -340,6 +338,8 @@ export class RollingWorkerSupervisor {
340
338
  handle,
341
339
  generation,
342
340
  version: expectedVersion,
341
+ pendingTransfers: 0,
342
+ drainRequested: false,
343
343
  expectedVersion,
344
344
  activationRequested: false,
345
345
  dispose,
@@ -362,15 +362,41 @@ export class RollingWorkerSupervisor {
362
362
  }
363
363
  }
364
364
  transferSocket(worker, socket) {
365
+ worker.pendingTransfers += 1;
366
+ let settled = false;
367
+ const complete = (error) => {
368
+ if (settled) {
369
+ return;
370
+ }
371
+ settled = true;
372
+ worker.pendingTransfers = Math.max(0, worker.pendingTransfers - 1);
373
+ if (error) {
374
+ this.handleTransferFailure(worker, socket, error);
375
+ }
376
+ this.maybeDrainWorker(worker);
377
+ };
365
378
  try {
366
- worker.handle.sendSocket(worker.generation, socket, (error) => {
367
- if (error) {
368
- this.handleTransferFailure(worker, socket, error);
369
- }
370
- });
379
+ worker.handle.sendSocket(worker.generation, socket, complete);
371
380
  }
372
381
  catch (error) {
373
- this.handleTransferFailure(worker, socket, error);
382
+ complete(error instanceof Error ? error : new Error(String(error)));
383
+ }
384
+ }
385
+ maybeDrainWorker(worker) {
386
+ if (!worker.drainRequested ||
387
+ worker.pendingTransfers > 0 ||
388
+ !this.draining.has(worker.generation)) {
389
+ return;
390
+ }
391
+ worker.drainRequested = false;
392
+ try {
393
+ worker.handle.sendControl({
394
+ type: "proxy-worker:drain",
395
+ generation: worker.generation,
396
+ });
397
+ }
398
+ catch {
399
+ worker.handle.terminate("SIGTERM");
374
400
  }
375
401
  }
376
402
  handleTransferFailure(worker, socket, error) {
@@ -1949,6 +1949,8 @@ export type RollingManagedWorker = {
1949
1949
  generation: number;
1950
1950
  version: string;
1951
1951
  dispose: () => void;
1952
+ pendingTransfers: number;
1953
+ drainRequested: boolean;
1952
1954
  };
1953
1955
  export type RollingCandidateWorker = RollingManagedWorker & {
1954
1956
  expectedVersion: string;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared duration formatting for the media processors.
3
+ *
4
+ * AudioProcessor and VideoProcessor each carried their own private
5
+ * `formatDuration`, and they disagreed: the same two-second file rendered as
6
+ * "0:02" from audio and "2s" from video, and a zero duration as "0:00" versus
7
+ * "0s". Both strings land in the `textContent` handed to the model, often in
8
+ * the same request when a video's muxed audio is described alongside it, so
9
+ * the mismatch reads as two different facts about one file.
10
+ *
11
+ * The explicit-unit form wins over the "m:ss" clock form because these strings
12
+ * are consumed by a language model, not rendered in a player scrubber: "1m 30s"
13
+ * has exactly one reading, while "1:30" is ambiguous between 1m30s and 1h30m
14
+ * and has to be disambiguated from context that may not be present.
15
+ */
16
+ /**
17
+ * Format a duration in seconds as explicit units — "45s", "1m 30s", "1h 2m 3s".
18
+ *
19
+ * Non-finite, negative and zero durations all render as "0s": callers reach
20
+ * this with a probe failure or a stream that reports no duration, and a
21
+ * fabricated number would be worse than an obvious zero.
22
+ */
23
+ export declare function formatMediaDuration(seconds: number): string;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Shared duration formatting for the media processors.
3
+ *
4
+ * AudioProcessor and VideoProcessor each carried their own private
5
+ * `formatDuration`, and they disagreed: the same two-second file rendered as
6
+ * "0:02" from audio and "2s" from video, and a zero duration as "0:00" versus
7
+ * "0s". Both strings land in the `textContent` handed to the model, often in
8
+ * the same request when a video's muxed audio is described alongside it, so
9
+ * the mismatch reads as two different facts about one file.
10
+ *
11
+ * The explicit-unit form wins over the "m:ss" clock form because these strings
12
+ * are consumed by a language model, not rendered in a player scrubber: "1m 30s"
13
+ * has exactly one reading, while "1:30" is ambiguous between 1m30s and 1h30m
14
+ * and has to be disambiguated from context that may not be present.
15
+ */
16
+ /**
17
+ * Format a duration in seconds as explicit units — "45s", "1m 30s", "1h 2m 3s".
18
+ *
19
+ * Non-finite, negative and zero durations all render as "0s": callers reach
20
+ * this with a probe failure or a stream that reports no duration, and a
21
+ * fabricated number would be worse than an obvious zero.
22
+ */
23
+ export function formatMediaDuration(seconds) {
24
+ if (!Number.isFinite(seconds) || seconds <= 0) {
25
+ return "0s";
26
+ }
27
+ const total = Math.round(seconds);
28
+ const hours = Math.floor(total / 3600);
29
+ const minutes = Math.floor((total % 3600) / 60);
30
+ const secs = total % 60;
31
+ const parts = [];
32
+ if (hours > 0) {
33
+ parts.push(`${hours}h`);
34
+ }
35
+ if (minutes > 0) {
36
+ parts.push(`${minutes}m`);
37
+ }
38
+ // Keep the seconds term when it is the only one, so sub-minute durations
39
+ // never render as an empty string.
40
+ if (secs > 0 || parts.length === 0) {
41
+ parts.push(`${secs}s`);
42
+ }
43
+ return parts.join(" ");
44
+ }
45
+ //# sourceMappingURL=mediaDuration.js.map
@@ -12,6 +12,31 @@ export declare function convertToModelMessages(messages: MultimodalChatMessage[]
12
12
  * Enhanced with CSV file processing support
13
13
  */
14
14
  export declare function buildMessagesArray(options: TextGenerationOptions | StreamOptions): Promise<ModelMessage[]>;
15
+ /**
16
+ * Fold the `audioFiles` / `videoFiles` aliases into the unified `files` array.
17
+ *
18
+ * #284 gave audio and video their own input fields, but neither has a
19
+ * dedicated processor — both are meant to travel through the same
20
+ * auto-detecting `files` pipeline that already understands "audio"/"video"
21
+ * FileDetector results (see `appendDetectedFileResult`). The fold used to live
22
+ * inline in `buildMultimodalMessagesArray`, which meant any path that bypassed
23
+ * that builder never performed it and dropped the files silently: the model
24
+ * received the prompt alone and answered as though nothing were attached
25
+ * (#1259). GoogleVertex's native SDK path and `buildMultimodalOptions`
26
+ * (Bedrock) are both such paths, so the fold has to be callable from them.
27
+ *
28
+ * The aliases are cleared once merged, which makes the call idempotent: a
29
+ * provider override and the shared builder can both call this on the same
30
+ * options object without attaching every file twice.
31
+ *
32
+ * Mutates in place, matching `processUnifiedFilesArray` below — downstream
33
+ * stages all read `options.input.files`.
34
+ */
35
+ export declare function mergeMediaFileAliases<TFile>(input: {
36
+ files?: Array<TFile | Buffer | string>;
37
+ audioFiles?: Array<Buffer | string>;
38
+ videoFiles?: Array<Buffer | string>;
39
+ }): void;
15
40
  /**
16
41
  * Process the unified files array with auto-detection.
17
42
  * Handles lazy file registration, full processing, and preview injection.
@@ -777,6 +777,38 @@ function appendDetectedFileResult(result, file, options) {
777
777
  logger.info(`[FileDetector] ⚠️ Unknown format (metadata extracted): ${filename}`);
778
778
  }
779
779
  }
780
+ /**
781
+ * Fold the `audioFiles` / `videoFiles` aliases into the unified `files` array.
782
+ *
783
+ * #284 gave audio and video their own input fields, but neither has a
784
+ * dedicated processor — both are meant to travel through the same
785
+ * auto-detecting `files` pipeline that already understands "audio"/"video"
786
+ * FileDetector results (see `appendDetectedFileResult`). The fold used to live
787
+ * inline in `buildMultimodalMessagesArray`, which meant any path that bypassed
788
+ * that builder never performed it and dropped the files silently: the model
789
+ * received the prompt alone and answered as though nothing were attached
790
+ * (#1259). GoogleVertex's native SDK path and `buildMultimodalOptions`
791
+ * (Bedrock) are both such paths, so the fold has to be callable from them.
792
+ *
793
+ * The aliases are cleared once merged, which makes the call idempotent: a
794
+ * provider override and the shared builder can both call this on the same
795
+ * options object without attaching every file twice.
796
+ *
797
+ * Mutates in place, matching `processUnifiedFilesArray` below — downstream
798
+ * stages all read `options.input.files`.
799
+ */
800
+ export function mergeMediaFileAliases(input) {
801
+ if (!input.audioFiles?.length && !input.videoFiles?.length) {
802
+ return;
803
+ }
804
+ input.files = [
805
+ ...(input.files ?? []),
806
+ ...(input.audioFiles ?? []),
807
+ ...(input.videoFiles ?? []),
808
+ ];
809
+ input.audioFiles = undefined;
810
+ input.videoFiles = undefined;
811
+ }
780
812
  /**
781
813
  * Process the unified files array with auto-detection.
782
814
  * Handles lazy file registration, full processing, and preview injection.
@@ -1115,18 +1147,7 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
1115
1147
  // local const so TypeScript sees the definite (non-optional) type in the
1116
1148
  // rest of this function, avoiding 60+ "possibly undefined" errors.
1117
1149
  const inp = options.input;
1118
- // #284: audioFiles/videoFiles have no dedicated processor — route them
1119
- // through the same auto-detecting `files` pipeline that already
1120
- // understands "audio"/"video" FileDetector results (see
1121
- // appendDetectedFileResult), instead of silently dropping them once
1122
- // detectMultimodal() routes an audio/video-only request here.
1123
- if (inp.audioFiles?.length || inp.videoFiles?.length) {
1124
- inp.files = [
1125
- ...(inp.files || []),
1126
- ...(inp.audioFiles || []),
1127
- ...(inp.videoFiles || []),
1128
- ];
1129
- }
1150
+ mergeMediaFileAliases(inp);
1130
1151
  // Compute provider-specific max PDF size once for consistent validation
1131
1152
  const pdfConfig = PDFProcessor.getProviderConfig(provider);
1132
1153
  const maxSize = pdfConfig
@@ -12,6 +12,8 @@ import type { StreamOptions } from "../types/index.js";
12
12
  * - input.files: Auto-detected file types
13
13
  * - input.csvFiles: CSV files for tabular data
14
14
  * - input.pdfFiles: PDF documents (Buffer | string paths)
15
+ * - input.audioFiles: Audio files (Buffer | string paths)
16
+ * - input.videoFiles: Video files (Buffer | string paths)
15
17
  * - csvOptions: CSV parsing options
16
18
  * - systemPrompt: System-level instructions
17
19
  * - conversationMessages: Chat history
@@ -23,7 +25,7 @@ import type { StreamOptions } from "../types/index.js";
23
25
  * @param {string} providerName - Provider identifier (e.g., "vertex", "openai", "anthropic")
24
26
  * @param {string} modelName - Model identifier (e.g., "gemini-2.5-flash", "gpt-4o")
25
27
  * @returns {object} Normalized options object with:
26
- * - input: { text, images, content, files, csvFiles, pdfFiles }
28
+ * - input: { text, images, content, files, csvFiles, pdfFiles, audioFiles, videoFiles }
27
29
  * - csvOptions: CSV processing options
28
30
  * - systemPrompt: System prompt string
29
31
  * - conversationHistory: Message history array
@@ -49,6 +51,8 @@ export declare function buildMultimodalOptions(options: StreamOptions, providerN
49
51
  files: (string | Buffer<ArrayBufferLike> | import("../types/file.js").FileWithMetadata)[] | undefined;
50
52
  csvFiles: (string | Buffer<ArrayBufferLike>)[] | undefined;
51
53
  pdfFiles: (string | Buffer<ArrayBufferLike>)[] | undefined;
54
+ audioFiles: (string | Buffer<ArrayBufferLike>)[] | undefined;
55
+ videoFiles: (string | Buffer<ArrayBufferLike>)[] | undefined;
52
56
  };
53
57
  csvOptions: import("../types/file.js").CSVProcessorOptions | undefined;
54
58
  pdfOptions: {
@@ -11,6 +11,8 @@
11
11
  * - input.files: Auto-detected file types
12
12
  * - input.csvFiles: CSV files for tabular data
13
13
  * - input.pdfFiles: PDF documents (Buffer | string paths)
14
+ * - input.audioFiles: Audio files (Buffer | string paths)
15
+ * - input.videoFiles: Video files (Buffer | string paths)
14
16
  * - csvOptions: CSV parsing options
15
17
  * - systemPrompt: System-level instructions
16
18
  * - conversationMessages: Chat history
@@ -22,7 +24,7 @@
22
24
  * @param {string} providerName - Provider identifier (e.g., "vertex", "openai", "anthropic")
23
25
  * @param {string} modelName - Model identifier (e.g., "gemini-2.5-flash", "gpt-4o")
24
26
  * @returns {object} Normalized options object with:
25
- * - input: { text, images, content, files, csvFiles, pdfFiles }
27
+ * - input: { text, images, content, files, csvFiles, pdfFiles, audioFiles, videoFiles }
26
28
  * - csvOptions: CSV processing options
27
29
  * - systemPrompt: System prompt string
28
30
  * - conversationHistory: Message history array
@@ -49,6 +51,11 @@ export function buildMultimodalOptions(options, providerName, modelName) {
49
51
  files: options.input?.files,
50
52
  csvFiles: options.input?.csvFiles,
51
53
  pdfFiles: options.input?.pdfFiles,
54
+ // #1259: this is a whitelist — a field omitted here is dropped
55
+ // silently, and the model answers as though nothing were attached.
56
+ // audioFiles/videoFiles were missing, so Bedrock received neither.
57
+ audioFiles: options.input?.audioFiles,
58
+ videoFiles: options.input?.videoFiles,
52
59
  },
53
60
  csvOptions: options.csvOptions,
54
61
  pdfOptions: options.pdfOptions,
@@ -179,13 +179,12 @@ export declare class AudioProcessor extends BaseFileProcessor<ProcessedAudio> {
179
179
  /**
180
180
  * Format a duration in seconds to a human-readable string.
181
181
  *
182
- * @param seconds - Duration in seconds
183
- * @returns Formatted string: "M:SS" for < 1 hour, "H:MM:SS" for >= 1 hour
182
+ * Delegates to the shared formatter so audio and video describe the same
183
+ * file the same way this used to render "0:02" where VideoProcessor
184
+ * rendered "2s".
184
185
  *
185
- * @example
186
- * formatDuration(225) // "3:45"
187
- * formatDuration(3750) // "1:02:30"
188
- * formatDuration(0) // "0:00"
186
+ * @param seconds - Duration in seconds
187
+ * @returns Formatted string: "45s", "3m 45s", "1h 2m 30s"
189
188
  */
190
189
  private formatDuration;
191
190
  /**