@juspay/neurolink 12.26.3 → 12.27.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.
package/dist/index.d.ts CHANGED
@@ -72,6 +72,7 @@ export { AzureTTS, AzureTTSHandler, CartesiaTTS, CartesiaTTSHandler, ElevenLabsT
72
72
  export { BeatovenMusic, BeatovenMusicHandler, ElevenLabsMusic, ElevenLabsMusicHandler, LyriaMusic, LyriaMusicHandler, registerDefaultMusicHandlers, ReplicateMusic, ReplicateMusicHandler, } from "./music/index.js";
73
73
  export { DIDAvatar, DIDAvatarHandler, HeyGenAvatar, HeyGenAvatarHandler, registerDefaultAvatarHandlers, ReplicateAvatar, ReplicateAvatarHandler, } from "./avatar/index.js";
74
74
  export { isVertexVideoConfigured, KlingVideoHandler, registerDefaultVideoHandlers, ReplicateVideoHandler, RunwayVideoHandler, VertexVideoHandler, } from "./adapters/video/index.js";
75
+ export { canDeliverVideoNatively, estimateVideoTokens, getVideoProviderConfig, isNativeVideoMimeType, supportsNativeVideo, VIDEO_PROVIDER_CONFIGS, } from "./adapters/videoFormatSupport.js";
75
76
  export { ImageGenService } from "./image-gen/ImageGenService.js";
76
77
  export { detectImageMimeType, sniffImageMimeType, } from "./utils/imageDetection.js";
77
78
  export { HITLManager } from "./hitl/hitlManager.js";
package/dist/index.js CHANGED
@@ -145,6 +145,13 @@ export { BeatovenMusic, BeatovenMusicHandler, ElevenLabsMusic, ElevenLabsMusicHa
145
145
  export { DIDAvatar, DIDAvatarHandler, HeyGenAvatar, HeyGenAvatarHandler, registerDefaultAvatarHandlers, ReplicateAvatar, ReplicateAvatarHandler, } from "./avatar/index.js";
146
146
  // Video handlers
147
147
  export { isVertexVideoConfigured, KlingVideoHandler, registerDefaultVideoHandlers, ReplicateVideoHandler, RunwayVideoHandler, VertexVideoHandler, } from "./adapters/video/index.js";
148
+ // Video *ingestion* capabilities — what each provider does with an attached
149
+ // clip. Surfaced because the answer changes what a caller should send: a
150
+ // provider on the native path is billed by duration and reads the audio
151
+ // track, one on the frame path is billed per keyframe and hears nothing, and
152
+ // without a way to ask, the only way to find out is to send a video and read
153
+ // the reply.
154
+ export { canDeliverVideoNatively, estimateVideoTokens, getVideoProviderConfig, isNativeVideoMimeType, supportsNativeVideo, VIDEO_PROVIDER_CONFIGS, } from "./adapters/videoFormatSupport.js";
148
155
  // Image generation + HITL — surfaced from their dedicated barrels
149
156
  export { ImageGenService } from "./image-gen/ImageGenService.js";
150
157
  export { detectImageMimeType, sniffImageMimeType, } from "./utils/imageDetection.js";
@@ -665,7 +665,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
665
665
  // reported "no image attached" on multimodal calls.
666
666
  const currentContents = [];
667
667
  prependConversationMessages(currentContents, options.conversationMessages);
668
- const userParts = await buildUserPartsWithMultimodal(options.input, options.input.text, "[GoogleAIStudio:stream]");
668
+ const userParts = await buildUserPartsWithMultimodal(options.input, options.input.text, "[GoogleAIStudio:stream]", modelName);
669
669
  currentContents.push({
670
670
  role: "user",
671
671
  parts: userParts,
@@ -1220,7 +1220,7 @@ export class GoogleAIStudioProvider extends BaseProvider {
1220
1220
  // legitimately reported "no image / PDF attached".
1221
1221
  const currentContents = [];
1222
1222
  prependConversationMessages(currentContents, options.conversationMessages);
1223
- const userParts = await buildUserPartsWithMultimodal(options.input, promptText, "[GoogleAIStudio:generate]");
1223
+ const userParts = await buildUserPartsWithMultimodal(options.input, promptText, "[GoogleAIStudio:generate]", modelName);
1224
1224
  currentContents.push({
1225
1225
  role: "user",
1226
1226
  parts: userParts,
@@ -8,7 +8,7 @@
8
8
  * This module extracts the functions that are duplicated between the two
9
9
  * providers so they can share a single implementation.
10
10
  */
11
- import type { GenerateStopReason, ThinkingConfig, AgenticLoopOptions, ChatMessage, CollectedChunkResult, MinimalChatMessage, NativeFunctionCall, NativeFunctionResponse, NativeToolDeclarationsResult, NativeToolsConfig, StreamChannel, VertexNativePart, GeminiMultimodalInput, MultimodalAudioEntry } from "../../types/index.js";
11
+ import type { GenerateStopReason, ThinkingConfig, AgenticLoopOptions, ChatMessage, CollectedChunkResult, MinimalChatMessage, NativeFunctionCall, NativeFunctionResponse, NativeToolDeclarationsResult, NativeToolsConfig, StreamChannel, VertexNativePart, GeminiMultimodalInput, MultimodalAudioEntry, MultimodalVideoEntry } from "../../types/index.js";
12
12
  import type { Tool, ToolExecutionGuards } from "../../types/index.js";
13
13
  /**
14
14
  * A per-turn tool execute map that deduplicates identical tool calls.
@@ -466,4 +466,26 @@ export declare function prependConversationMessages(contents: Array<{
466
466
  * summary in the text part.
467
467
  */
468
468
  export declare function appendNativeAudioParts(userParts: VertexNativePart[], audioFiles: MultimodalAudioEntry[] | undefined, logPrefix?: string): Promise<void>;
469
- export declare function buildUserPartsWithMultimodal(input: GeminiMultimodalInput | undefined, textOverride?: string, logPrefix?: string): Promise<VertexNativePart[]>;
469
+ /**
470
+ * Append video to a Gemini request as `inlineData` parts.
471
+ *
472
+ * The native request body is assembled by hand rather than taken from the AI
473
+ * SDK's `file` parts, so — exactly as with audio — a `{ type: "file" }` part
474
+ * built upstream never arrives here. Video has to be added explicitly or it
475
+ * is silently dropped on both Gemini front ends while the capability table
476
+ * says it is supported.
477
+ *
478
+ * A clip the provider will not take inline is skipped rather than sent: an
479
+ * oversized or unsupported `inlineData` part fails the whole request, whereas
480
+ * skipping leaves the caller with the metadata summary and the keyframes,
481
+ * which is what it had before native delivery existed.
482
+ *
483
+ * `model` is forwarded to `canDeliverVideoNatively` so a Claude-on-Vertex
484
+ * request — same `providerName` ("vertex") as a Gemini-on-Vertex one, routed
485
+ * to the native Anthropic SDK instead by `GoogleVertexProvider.isAnthropicModel`
486
+ * — is answered as not natively video-capable rather than inheriting Vertex's
487
+ * Gemini row. Optional because AI Studio's caller never has a Claude model to
488
+ * pass.
489
+ */
490
+ export declare function appendNativeVideoParts(userParts: VertexNativePart[], videoFiles: MultimodalVideoEntry[] | undefined, providerName: string, model?: string, logPrefix?: string): Promise<void>;
491
+ export declare function buildUserPartsWithMultimodal(input: GeminiMultimodalInput | undefined, textOverride?: string, logPrefix?: string, model?: string): Promise<VertexNativePart[]>;
@@ -13,6 +13,7 @@ import { existsSync, readFileSync } from "node:fs";
13
13
  import { extname } from "node:path";
14
14
  import { DEFAULT_CONTEXT_GUARD_RATIO, DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, DEFAULT_WRAPUP_TIME_LEAD_MS, } from "../../core/constants.js";
15
15
  import { needsAudioTranscode, toProviderCompatibleAudio, } from "../../adapters/audioFormatSupport.js";
16
+ import { canDeliverVideoNatively } from "../../adapters/videoFormatSupport.js";
16
17
  import { logger } from "../../utils/logger.js";
17
18
  import { guardToolExecutor } from "../../core/toolExecutionGuards.js";
18
19
  import { resolveSamplingParams } from "../../models/modelRegistry.js";
@@ -1376,7 +1377,73 @@ export async function appendNativeAudioParts(userParts, audioFiles, logPrefix =
1376
1377
  logger.debug(`${logPrefix} Added native audio part for ${base} (${compatible.mimeType})`);
1377
1378
  }
1378
1379
  }
1379
- export async function buildUserPartsWithMultimodal(input, textOverride, logPrefix = "[GeminiNative]") {
1380
+ /**
1381
+ * Append video to a Gemini request as `inlineData` parts.
1382
+ *
1383
+ * The native request body is assembled by hand rather than taken from the AI
1384
+ * SDK's `file` parts, so — exactly as with audio — a `{ type: "file" }` part
1385
+ * built upstream never arrives here. Video has to be added explicitly or it
1386
+ * is silently dropped on both Gemini front ends while the capability table
1387
+ * says it is supported.
1388
+ *
1389
+ * A clip the provider will not take inline is skipped rather than sent: an
1390
+ * oversized or unsupported `inlineData` part fails the whole request, whereas
1391
+ * skipping leaves the caller with the metadata summary and the keyframes,
1392
+ * which is what it had before native delivery existed.
1393
+ *
1394
+ * `model` is forwarded to `canDeliverVideoNatively` so a Claude-on-Vertex
1395
+ * request — same `providerName` ("vertex") as a Gemini-on-Vertex one, routed
1396
+ * to the native Anthropic SDK instead by `GoogleVertexProvider.isAnthropicModel`
1397
+ * — is answered as not natively video-capable rather than inheriting Vertex's
1398
+ * Gemini row. Optional because AI Studio's caller never has a Claude model to
1399
+ * pass.
1400
+ */
1401
+ export async function appendNativeVideoParts(userParts, videoFiles, providerName, model, logPrefix = "[GeminiNative]") {
1402
+ if (!videoFiles || videoFiles.length === 0) {
1403
+ return;
1404
+ }
1405
+ // Tracks source bytes already committed to this request's shared inline
1406
+ // budget. This is the sole place video ceiling enforcement happens: the AI
1407
+ // SDK `file`-part assembly in messageBuilder.ts never delivers video for
1408
+ // either Gemini provider class (both own their request assembly end to
1409
+ // end), so a duplicate of this check used to sit there too — permanently
1410
+ // inert and unreachable by any test — and has been removed rather than
1411
+ // left as a second, silently-driftable copy of the same logic.
1412
+ //
1413
+ // Seeded from the PDF, image and audio `inlineData` parts the caller has
1414
+ // already pushed onto `userParts` before this runs, not just from sibling
1415
+ // video clips: the 20MB Gemini request ceiling is shared by every inline
1416
+ // part, so a clip that would fit alone can still overflow the request
1417
+ // once those other parts are counted. `data` is base64, which costs 4
1418
+ // bytes per 3 of source — decode the length back to source bytes so this
1419
+ // total stays comparable to `video.buffer.length` below.
1420
+ let committedVideoBytes = userParts.reduce((total, part) => {
1421
+ if ("inlineData" in part) {
1422
+ return total + Math.floor((part.inlineData.data.length * 3) / 4);
1423
+ }
1424
+ return total;
1425
+ }, 0);
1426
+ for (const video of videoFiles) {
1427
+ // Split on both separators: a Windows-style name reaching a POSIX host
1428
+ // would otherwise keep its whole path in the log line.
1429
+ const base = video.filename.split(/[\\/]/).pop() ?? video.filename;
1430
+ const decision = canDeliverVideoNatively(providerName, video, committedVideoBytes, model);
1431
+ if (!decision.deliver) {
1432
+ logger.warn(`${logPrefix} Sending keyframes instead of the clip for ${base}: ` +
1433
+ `${decision.reason}.`);
1434
+ continue;
1435
+ }
1436
+ committedVideoBytes += video.buffer.length;
1437
+ userParts.push({
1438
+ inlineData: {
1439
+ mimeType: video.mimeType,
1440
+ data: video.buffer.toString("base64"),
1441
+ },
1442
+ });
1443
+ logger.debug(`${logPrefix} Added native video part for ${base} (${video.mimeType})`);
1444
+ }
1445
+ }
1446
+ export async function buildUserPartsWithMultimodal(input, textOverride, logPrefix = "[GeminiNative]", model) {
1380
1447
  const text = typeof textOverride === "string" ? textOverride : (input?.text ?? "");
1381
1448
  const parts = [{ text }];
1382
1449
  if (input?.pdfFiles && input.pdfFiles.length > 0) {
@@ -1483,5 +1550,13 @@ export async function buildUserPartsWithMultimodal(input, textOverride, logPrefi
1483
1550
  // Vertex client left this front end advertising native audio via
1484
1551
  // NATIVE_AUDIO_PROVIDERS and then dropping the bytes on the floor.
1485
1552
  await appendNativeAudioParts(parts, input?.nativeAudioFiles, logPrefix);
1553
+ // `google-ai-studio` rather than the caller's provider string: this helper
1554
+ // is the AI Studio / Gemini 3 assembly path, and the capability table is
1555
+ // keyed by provider name. Passing a name the table does not know would make
1556
+ // every clip fall back to keyframes on the one front end that can watch it.
1557
+ // `model` is threaded through for signature parity with the Vertex call
1558
+ // site; AI Studio never hosts a Claude model, so it makes no delivery
1559
+ // difference here, only in `canDeliverVideoNatively`'s Vertex branch.
1560
+ await appendNativeVideoParts(parts, input?.nativeVideoFiles, "google-ai-studio", model, logPrefix);
1486
1561
  return parts;
1487
1562
  }
@@ -6,7 +6,7 @@ import path from "path";
6
6
  import { ErrorCategory, ErrorSeverity, } from "../../constants/enums.js";
7
7
  import { BaseProvider } from "../../core/baseProvider.js";
8
8
  import { unwrapImagePayload } from "../../adapters/imageFormatSupport.js";
9
- import { appendNativeAudioParts } from "../googleNativeGemini3/utils.js";
9
+ import { appendNativeAudioParts, appendNativeVideoParts, } from "../googleNativeGemini3/utils.js";
10
10
  import { getMimeTypeForExtension } from "../../processors/config/mimeConstants.js";
11
11
  import { DEFAULT_GEMINI_STREAM_TIMEOUT_MS, DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, GLOBAL_LOCATION_MODELS, resolveToolTimeoutMs, TOOL_STORAGE_TIMEOUT_MS, } from "../../core/constants.js";
12
12
  import { resolveRequestKind } from "../../core/resolveRequestKind.js";
@@ -1302,6 +1302,11 @@ export class GoogleVertexProvider extends BaseProvider {
1302
1302
  });
1303
1303
  }
1304
1304
  }
1305
+ // Video last, after images: `appendNativeVideoParts` seeds its
1306
+ // request-wide byte budget from whatever `inlineData` parts already sit
1307
+ // in `userParts`, so the images above must be pushed before it runs or
1308
+ // their bytes are invisible to the ceiling check that follows.
1309
+ await appendNativeVideoParts(userParts, multimodalInput?.nativeVideoFiles, "vertex", modelName, "[GoogleVertex]");
1305
1310
  // Prepend prior conversation turns before the current user message so
1306
1311
  // multi-turn callers (memory, loop REPL, agent flows) actually carry
1307
1312
  // context. Without this, the native Vertex Gemini stream rebuilt the
@@ -2114,6 +2119,11 @@ export class GoogleVertexProvider extends BaseProvider {
2114
2119
  });
2115
2120
  }
2116
2121
  }
2122
+ // Video last, after images: `appendNativeVideoParts` seeds its
2123
+ // request-wide byte budget from whatever `inlineData` parts already sit
2124
+ // in `userParts`, so the images above must be pushed before it runs or
2125
+ // their bytes are invisible to the ceiling check that follows.
2126
+ await appendNativeVideoParts(userParts, multimodalInput?.nativeVideoFiles, "vertex", modelName, "[GoogleVertex]");
2117
2127
  // Prepend prior conversation turns before the current user message so
2118
2128
  // multi-turn callers (memory, loop REPL, agent flows) carry context
2119
2129
  // into native Vertex Gemini generate. Without this, every call started
@@ -127,6 +127,15 @@ export declare function recordFallbackAttempt(attrs: {
127
127
  errorMessage?: string;
128
128
  durationMs: number;
129
129
  }): void;
130
+ /** Reset leg-health state between tests. */
131
+ export declare const __fallbackLegHealthTestHooks: {
132
+ threshold: number;
133
+ maxTrackedLegs: number;
134
+ trackedCount: () => number;
135
+ reset: () => void;
136
+ streakOf: (legKey: string) => number;
137
+ isReported: (legKey: string) => boolean;
138
+ };
130
139
  export { ProxyTracer };
131
140
  /** Standard SERVER span covers every proxy door, including parsing and admission failures. */
132
141
  export declare function startProxyHttpTrace(metadata: RuntimeRequestMetadata, headers: Record<string, string>): {
@@ -757,7 +757,96 @@ class ProxyTracer {
757
757
  return this.bridge.injectContext({}, trace.setSpan(context.active(), this.rootSpan));
758
758
  }
759
759
  }
760
+ /**
761
+ * Consecutive failures a configured fallback leg may accumulate before the
762
+ * proxy says so out loud.
763
+ *
764
+ * A leg that is attempted and never succeeds is invisible today: the counters
765
+ * record every attempt and every failure, but nothing reads them back, so the
766
+ * condition surfaces only if an operator happens to query telemetry. Meanwhile
767
+ * every request falls through to the next leg, which can bill on a different
768
+ * account at a different rate, so the first symptom is a cost change rather
769
+ * than an error. Twenty is low enough to catch a dead leg within seconds of
770
+ * sustained traffic and high enough that ordinary account rotation or a brief
771
+ * cooldown never trips it.
772
+ */
773
+ const DEAD_FALLBACK_LEG_THRESHOLD = 20;
774
+ /**
775
+ * Upper bound on tracked legs.
776
+ *
777
+ * Not every key comes from the configured chain. The auto-provider path records
778
+ * `auto-provider/<requested model>`, and the requested model is whatever the
779
+ * caller asked for, so a client that fails requests under varying model names
780
+ * mints a new key each time. Without a bound these collections grow for the
781
+ * lifetime of the process. The cap is far above any real fallback chain, so a
782
+ * genuine leg is never evicted in practice.
783
+ */
784
+ const MAX_TRACKED_FALLBACK_LEGS = 256;
785
+ /** Consecutive failures per `provider/model`, reset by any success. */
786
+ const fallbackFailureStreaks = new Map();
787
+ /** Legs already reported, so a dead leg is stated once and not once per turn. */
788
+ const reportedDeadFallbackLegs = new Set();
789
+ /**
790
+ * Record a streak, evicting the least recently touched leg past the cap.
791
+ *
792
+ * `Map` and `Set` iterate in insertion order and re-setting an existing key does
793
+ * not move it, so a key is deleted before being re-added. That makes recency the
794
+ * eviction order and keeps a leg that is actively failing from being dropped in
795
+ * favour of a stale one.
796
+ */
797
+ function touchFallbackLegStreak(legKey, streak) {
798
+ fallbackFailureStreaks.delete(legKey);
799
+ fallbackFailureStreaks.set(legKey, streak);
800
+ while (fallbackFailureStreaks.size > MAX_TRACKED_FALLBACK_LEGS) {
801
+ const oldest = fallbackFailureStreaks.keys().next();
802
+ if (oldest.done) {
803
+ break;
804
+ }
805
+ fallbackFailureStreaks.delete(oldest.value);
806
+ reportedDeadFallbackLegs.delete(oldest.value);
807
+ }
808
+ }
809
+ /**
810
+ * Report a fallback leg that has stopped serving, and its recovery.
811
+ *
812
+ * Deliberately keyed per leg rather than globally: a second leg going dark must
813
+ * still be reported while the first is known dead.
814
+ */
815
+ function trackFallbackLegHealth(legKey, status, errorMessage) {
816
+ if (status === "success") {
817
+ fallbackFailureStreaks.delete(legKey);
818
+ if (reportedDeadFallbackLegs.delete(legKey)) {
819
+ logger.always(`[proxy] fallback ${legKey} is serving again after being reported dead`);
820
+ }
821
+ return;
822
+ }
823
+ const streak = (fallbackFailureStreaks.get(legKey) ?? 0) + 1;
824
+ touchFallbackLegStreak(legKey, streak);
825
+ if (streak < DEAD_FALLBACK_LEG_THRESHOLD) {
826
+ return;
827
+ }
828
+ if (reportedDeadFallbackLegs.has(legKey)) {
829
+ return;
830
+ }
831
+ // Marked reported only once the report is out. Marking first meant a throwing
832
+ // logger — swallowed by the caller's catch — silenced the leg permanently,
833
+ // which is the one outcome this whole mechanism exists to prevent.
834
+ logger.always(`[proxy] fallback ${legKey} has failed ${streak} consecutive attempts with ` +
835
+ `no success — this leg is not serving. Traffic is falling through to the ` +
836
+ `next leg in the chain, which may bill differently. Check the account's ` +
837
+ `quota and credentials` +
838
+ (errorMessage ? `; last error: ${errorMessage.slice(0, 200)}` : ""));
839
+ reportedDeadFallbackLegs.add(legKey);
840
+ }
760
841
  export function recordFallbackAttempt(attrs) {
842
+ // Leg health is tracked outside the metrics try/catch: a telemetry failure
843
+ // must not be the reason an operator never hears that a leg is dead.
844
+ try {
845
+ trackFallbackLegHealth(`${attrs.provider}/${attrs.model}`, attrs.status, attrs.errorMessage);
846
+ }
847
+ catch {
848
+ // Never let health reporting break a request.
849
+ }
761
850
  try {
762
851
  const m = getProxyMetrics();
763
852
  const labels = { provider: attrs.provider, model: attrs.model };
@@ -776,6 +865,18 @@ export function recordFallbackAttempt(attrs) {
776
865
  // metrics are best-effort
777
866
  }
778
867
  }
868
+ /** Reset leg-health state between tests. */
869
+ export const __fallbackLegHealthTestHooks = {
870
+ threshold: DEAD_FALLBACK_LEG_THRESHOLD,
871
+ maxTrackedLegs: MAX_TRACKED_FALLBACK_LEGS,
872
+ trackedCount: () => fallbackFailureStreaks.size,
873
+ reset: () => {
874
+ fallbackFailureStreaks.clear();
875
+ reportedDeadFallbackLegs.clear();
876
+ },
877
+ streakOf: (legKey) => fallbackFailureStreaks.get(legKey) ?? 0,
878
+ isReported: (legKey) => reportedDeadFallbackLegs.has(legKey),
879
+ };
779
880
  export { ProxyTracer };
780
881
  /** Standard SERVER span covers every proxy door, including parsing and admission failures. */
781
882
  export function startProxyHttpTrace(metadata, headers) {
@@ -62,6 +62,105 @@ export type MultimodalAudioEntry = {
62
62
  /** Detected MIME type of `buffer`. */
63
63
  mimeType: string;
64
64
  };
65
+ /**
66
+ * One video file destined for native delivery to a provider.
67
+ *
68
+ * Mirrors {@link MultimodalAudioEntry}: the bytes travel rather than the path,
69
+ * because whether a video is sent at all is decided per provider, after
70
+ * detection has already read the file.
71
+ *
72
+ * `durationSec` rides along because the native-delivery gate is expressed in
73
+ * seconds as well as bytes, and re-probing the container at dispatch time
74
+ * would mean a second ffprobe run for something the processor already
75
+ * measured. It is optional: probing can fail (no ffmpeg, an exotic container),
76
+ * and an unknown duration must not by itself disqualify a clip that is
77
+ * comfortably under the size ceiling.
78
+ */
79
+ export type MultimodalVideoEntry = {
80
+ /** Raw video bytes, as detected. */
81
+ buffer: Buffer;
82
+ /** Display name; may be a full path, so log only its basename. */
83
+ filename: string;
84
+ /** Detected MIME type of `buffer`. */
85
+ mimeType: string;
86
+ /** Clip length in seconds, when the processor was able to measure it. */
87
+ durationSec?: number;
88
+ };
89
+ /**
90
+ * How one provider handles an attached video.
91
+ *
92
+ * The table lives in `adapters/videoFormatSupport.ts`; this is its row shape.
93
+ * `apiType` names the mechanism that is actually implemented, not the one a
94
+ * provider theoretically offers — Gemini also exposes a resumable Files API
95
+ * for clips beyond the inline ceiling, and until that is wired up calling this
96
+ * row "files-api" would misdescribe what happens to a 200 MB upload.
97
+ */
98
+ export type VideoProviderConfig = {
99
+ /** Whether raw video bytes can be handed to this provider at all. */
100
+ readonly supportsNativeVideo: boolean;
101
+ /** How the video reaches the model. */
102
+ readonly apiType: "inline" | "frame-extraction";
103
+ /**
104
+ * Inline source-byte budget for the whole request, in MB. A clip is
105
+ * delivered natively only while its bytes plus every inline part already in
106
+ * the request stay within it; otherwise it falls back to keyframes.
107
+ * Meaningless when `supportsNativeVideo` is false, and set to 0 there rather
108
+ * than to a number that reads like a real limit.
109
+ */
110
+ readonly maxSizeMB: number;
111
+ /** Longest clip accepted natively, in seconds. 0 when not applicable. */
112
+ readonly maxDurationSec: number;
113
+ /** Whether the provider hears the video's audio track as well as seeing it. */
114
+ readonly supportsAudio: boolean;
115
+ /**
116
+ * Keyframe budget to aim for when this provider gets frames instead of the
117
+ * video. Advisory: an explicit `videoOptions.frames` always wins.
118
+ */
119
+ readonly recommendedFrameCount: number;
120
+ };
121
+ /**
122
+ * Media collected during file detection that a provider may be able to
123
+ * consume directly, rather than as a text summary.
124
+ *
125
+ * Grouped rather than passed as two more positional parameters: the message
126
+ * converters already take text, images, PDFs, provider and model, and each
127
+ * new modality added one more argument to a call nobody could read. A bag
128
+ * also means the next modality is a field, not another signature change at
129
+ * every call site.
130
+ */
131
+ export type NativeMediaAttachments = {
132
+ readonly audio?: MultimodalAudioEntry[];
133
+ readonly video?: MultimodalVideoEntry[];
134
+ /**
135
+ * Transcode target for an incompatible image (see
136
+ * `GenerateOptions.imageOptions`). Not itself a native attachment, but
137
+ * bundled here for the same reason audio and video are: it is one more
138
+ * per-request option the message converters need at the same call sites.
139
+ */
140
+ readonly outputFormat?: VisionImageOutputFormat;
141
+ };
142
+ /**
143
+ * Outcome of asking whether one video may be handed to one provider as bytes.
144
+ *
145
+ * A plain boolean collapsed "this provider never watches video" with "this
146
+ * provider would have, but the clip is 400 MB" — and the two want different
147
+ * log lines and different advice. `reason` is populated exactly when
148
+ * `deliver` is false, and is phrased for a user to read.
149
+ *
150
+ * The accepting arm declares `reason?: undefined` rather than omitting the
151
+ * field. Not decoration: `build:react-hooks` type-checks this graph without
152
+ * `--strict`, and there a negated boolean-literal discriminant does not
153
+ * narrow, so `decision.reason` inside `if (!decision.deliver)` fails to
154
+ * compile unless the property exists on both arms. Declaring it keeps the
155
+ * union exact under strict and compilable under both.
156
+ */
157
+ export type VideoDeliveryDecision = {
158
+ readonly deliver: true;
159
+ readonly reason?: undefined;
160
+ } | {
161
+ readonly deliver: false;
162
+ readonly reason: string;
163
+ };
65
164
  /**
66
165
  * Broad category a file format belongs to, as a human would name it.
67
166
  *
@@ -190,6 +289,14 @@ export type FileProcessingResult = {
190
289
  hasImages?: boolean;
191
290
  frameCount?: number;
192
291
  hasKeyframes?: boolean;
292
+ /**
293
+ * Clip length in seconds, when the processor could measure it.
294
+ *
295
+ * Surfaced here so the native-delivery gate does not have to re-probe a
296
+ * container the processor has already opened. Absent when probing failed
297
+ * — an unknown duration, not a zero-length clip.
298
+ */
299
+ durationSec?: number;
193
300
  };
194
301
  };
195
302
  /**
@@ -17,7 +17,7 @@ import type { AvatarOptions, AvatarResult } from "./avatar.js";
17
17
  import type { MusicOptions, MusicResult } from "./music.js";
18
18
  import type { StandardRecord, ValidationSchema, ZodUnknownSchema } from "./aliases.js";
19
19
  import type { NeurolinkCredentials } from "./providers.js";
20
- import type { CSVProcessorOptions, FileWithMetadata, MultimodalAudioEntry, VisionImageOutputFormat } from "./file.js";
20
+ import type { CSVProcessorOptions, FileWithMetadata, MultimodalAudioEntry, MultimodalVideoEntry, VisionImageOutputFormat } from "./file.js";
21
21
  import type { WorkflowConfig } from "./workflow.js";
22
22
  import type { Schema, Tool, ToolChoice } from "./tools.js";
23
23
  import type { StepResult, LanguageModel } from "./providers.js";
@@ -68,6 +68,17 @@ export type GenerateOptions = {
68
68
  */
69
69
  nativeAudioFiles?: MultimodalAudioEntry[];
70
70
  videoFiles?: Array<Buffer | string>;
71
+ /**
72
+ * Video collected during file detection, carried through to a provider
73
+ * that can watch it. Populated by detection rather than by callers.
74
+ *
75
+ * Separate from `videoFiles` above, which is the caller-facing input that
76
+ * yields a metadata summary plus extracted keyframes. This one carries the
77
+ * decoded bytes forward so a provider that accepts video receives the clip
78
+ * itself; providers that cannot fall back to the frames and this is
79
+ * ignored.
80
+ */
81
+ nativeVideoFiles?: MultimodalVideoEntry[];
71
82
  files?: Array<Buffer | string | FileWithMetadata>;
72
83
  content?: Content[];
73
84
  /**
@@ -7,7 +7,7 @@ import { AIProviderName, AnthropicModels, BedrockModels, DeepSeekModels, GoogleA
7
7
  import type { ValidationSchema } from "./aliases.js";
8
8
  import type { DecisionLimits, DecisionRequest, DecisionResult } from "./decision.js";
9
9
  import type { EnhancedGenerateResult, TextGenerationOptions } from "./generate.js";
10
- import type { MultimodalAudioEntry } from "./file.js";
10
+ import type { MultimodalAudioEntry, MultimodalVideoEntry } from "./file.js";
11
11
  import type { StreamOptions, StreamResult } from "./stream.js";
12
12
  import type { ProviderError, ProviderErrorRule } from "./errors.js";
13
13
  import type { ExternalMCPToolInfo } from "./externalMcp.js";
@@ -2206,6 +2206,13 @@ export type GeminiMultimodalInput = {
2206
2206
  * are already-materialised bytes with a resolved mime type.
2207
2207
  */
2208
2208
  nativeAudioFiles?: MultimodalAudioEntry[];
2209
+ /**
2210
+ * Video collected during file detection, carried through to the native
2211
+ * request as `inlineData`. Distinct from the user-facing `videoFiles`: these
2212
+ * are already-materialised bytes with a resolved mime type and, where it
2213
+ * could be measured, the clip's duration.
2214
+ */
2215
+ nativeVideoFiles?: MultimodalVideoEntry[];
2209
2216
  };
2210
2217
  /**
2211
2218
  * Internal helpers used by the conversation-history builder in
@@ -1132,6 +1132,14 @@ export class FileDetector {
1132
1132
  ...detection.metadata,
1133
1133
  frameCount: videoResult.data.frameCount,
1134
1134
  hasKeyframes: videoResult.data.hasKeyframes,
1135
+ // Carried so native delivery can apply its duration ceiling
1136
+ // without opening the container a second time. The processor
1137
+ // reports 0 when nothing could probe it; that is "unknown", not a
1138
+ // zero-length clip, so it is left off rather than passed through
1139
+ // as a number a ceiling check would happily accept.
1140
+ ...(videoResult.data.metadata.duration > 0
1141
+ ? { durationSec: videoResult.data.metadata.duration }
1142
+ : {}),
1135
1143
  },
1136
1144
  };
1137
1145
  }
@@ -16,6 +16,7 @@ import { ErrorFactory, NeuroLinkError, withTimeout } from "./errorHandling.js";
16
16
  import { FileDetector } from "./fileDetector.js";
17
17
  import { detectIsoBmffImageMimeType } from "./isoBmff.js";
18
18
  import { needsAudioTranscode, supportsNativeAudio, toProviderCompatibleAudio, } from "../adapters/audioFormatSupport.js";
19
+ import { supportsNativeVideo } from "../adapters/videoFormatSupport.js";
19
20
  import { getImageCache } from "./imageCache.js";
20
21
  import { ImageProcessor, imageUtils } from "./imageProcessor.js";
21
22
  import { logger } from "./logger.js";
@@ -697,6 +698,26 @@ async function appendDetectedFileResult(result, file, options) {
697
698
  ];
698
699
  logger.info(`[FileDetector] Added ${result.images.length} video keyframes as images`);
699
700
  }
701
+ // Carry the bytes forward alongside the summary and the frames, the same
702
+ // way audio does. Whether the clip is sent is decided later, per provider:
703
+ // one that can watch receives it, one that cannot still gets the keyframes
704
+ // and is no worse off than before. This matters most where the frames are
705
+ // absent — ffmpeg is optional, and without it the summary was everything
706
+ // the model saw.
707
+ const videoBytes = await readFileInputBytes(file);
708
+ if (videoBytes) {
709
+ options.input.nativeVideoFiles = [
710
+ ...(options.input.nativeVideoFiles || []),
711
+ {
712
+ buffer: videoBytes,
713
+ filename,
714
+ mimeType: result.mimeType,
715
+ ...(typeof result.metadata?.durationSec === "number"
716
+ ? { durationSec: result.metadata.durationSec }
717
+ : {}),
718
+ },
719
+ ];
720
+ }
700
721
  logger.info(`[FileDetector] ✅ Video: ${filename}`);
701
722
  }
702
723
  else if (result.type === "audio") {
@@ -1376,8 +1397,14 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
1376
1397
  // summary survives.
1377
1398
  const hasNativeAudio = (inp.nativeAudioFiles?.length ?? 0) > 0 &&
1378
1399
  supportsNativeAudio(provider, model);
1379
- // If no images, PDFs or audio, use standard message building and convert to MultimodalChatMessage[]
1380
- if (!hasImages && !hasPDFs && !hasNativeAudio) {
1400
+ // Video that is to be delivered natively is multimodal for exactly the
1401
+ // reason audio is: it becomes a non-text part. Gated on the provider
1402
+ // accepting video so one that only ever sees keyframes keeps the cheaper
1403
+ // shape it already had.
1404
+ const hasNativeVideo = (inp.nativeVideoFiles?.length ?? 0) > 0 &&
1405
+ supportsNativeVideo(provider, model);
1406
+ // If no images, PDFs, audio or video, use standard message building and convert to MultimodalChatMessage[]
1407
+ if (!hasImages && !hasPDFs && !hasNativeAudio && !hasNativeVideo) {
1381
1408
  // #289: CSV content[] items don't need vision, so they never reach the
1382
1409
  // multimodal converter below — process them into the prompt text here
1383
1410
  // (otherwise a `content: [{type:"csv"}]`-only request silently drops it).
@@ -1495,7 +1522,11 @@ export async function buildMultimodalMessagesArray(options, provider, model) {
1495
1522
  // audio-only turn fell through to the plain-text branch below, so the
1496
1523
  // recording was dropped and only its metadata summary — already folded
1497
1524
  // into `text` — ever reached the model.
1498
- (inp.nativeAudioFiles?.length ?? 0) > 0) {
1525
+ (inp.nativeAudioFiles?.length ?? 0) > 0 ||
1526
+ // Same for a video-only turn. On a machine without ffmpeg there are no
1527
+ // keyframes to put in `images`, so this clause is the only thing keeping
1528
+ // the clip out of the plain-text branch.
1529
+ (inp.nativeVideoFiles?.length ?? 0) > 0) {
1499
1530
  userContent = await convertMultimodalToProviderFormat(inp.text ?? "", inp.images || [], pdfFiles, provider, model, {
1500
1531
  audioFiles: inp.nativeAudioFiles ?? [],
1501
1532
  outputFormat: options.imageOptions?.outputFormat,
@@ -1597,6 +1628,13 @@ async function convertContentToProviderFormat(content, provider, model, pdfOptio
1597
1628
  // change exists to stop, reached through a different branch. Gated on the
1598
1629
  // provider accepting audio so one that cannot keeps the cheaper plain-text
1599
1630
  // shape rather than an array carrying a part it will ignore.
1631
+ //
1632
+ // Video does not get the same treatment here: native delivery for the two
1633
+ // Gemini provider classes never reaches this function with any consequence
1634
+ // (both fully own their own request assembly — see the module comment on
1635
+ // `appendNativeVideoParts` in googleNativeGemini3/utils.ts, the actual and
1636
+ // only place a video clip is attached), so a `deliversVideo` flag here would
1637
+ // gate nothing but a value nobody reads.
1600
1638
  const deliversAudio = audioFiles.length > 0 && supportsNativeAudio(provider, model);
1601
1639
  const hasMultimodal = imageContent.length > 0 || pdfContent.length > 0 || deliversAudio;
1602
1640
  // Validate that we have at least some content
@@ -2252,6 +2290,22 @@ mediaOptions = {}) {
2252
2290
  `${compatible.converted ? ` (converted to ${compatible.mimeType})` : ""}`);
2253
2291
  }
2254
2292
  }
2293
+ // Video is deliberately not attached here. This function's output — an AI
2294
+ // SDK `content` array — only ever reaches a real request for providers that
2295
+ // call `BaseProvider`'s shared message building unmodified, and neither
2296
+ // Gemini provider class does: `GoogleAIStudioProvider.generate()` and
2297
+ // `GoogleVertexProvider.generate()` are full overrides that never call
2298
+ // `super.generate()`, and `BaseProvider.stream()`'s early multimodal check
2299
+ // builds a `messages` value only to test `hasVideoFrames()` on it before
2300
+ // discarding it — so a video part built here never travels anywhere. The
2301
+ // one delivery mechanism both Gemini front ends actually use is the
2302
+ // hand-built `inlineData` request in `appendNativeVideoParts`
2303
+ // (googleNativeGemini3/utils.ts), which carries its own aggregate-ceiling
2304
+ // enforcement. An earlier version of this function duplicated that
2305
+ // ceiling check here too, permanently inert and with no test able to
2306
+ // reach it — that duplicate has been removed rather than left as a second
2307
+ // place the same bug could be introduced without either delivery or a
2308
+ // failing test ever showing it.
2255
2309
  // Check if provider supports native PDF processing
2256
2310
  const supportsNativePDF = PDFProcessor.supportsNativePDF(provider);
2257
2311
  if (supportsNativePDF) {