@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/CHANGELOG.md +2 -2
- package/dist/adapters/videoFormatSupport.d.ts +134 -0
- package/dist/adapters/videoFormatSupport.js +379 -0
- package/dist/browser/neurolink.min.js +379 -379
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -0
- package/dist/providers/googleAiStudio/client.js +2 -2
- package/dist/providers/googleNativeGemini3/utils.d.ts +24 -2
- package/dist/providers/googleNativeGemini3/utils.js +76 -1
- package/dist/providers/googleVertex/client.js +11 -1
- package/dist/proxy/proxyTracer.d.ts +9 -0
- package/dist/proxy/proxyTracer.js +101 -0
- package/dist/types/file.d.ts +107 -0
- package/dist/types/generate.d.ts +12 -1
- package/dist/types/providers.d.ts +8 -1
- package/dist/utils/fileDetector.js +8 -0
- package/dist/utils/messageBuilder.js +57 -3
- package/docs-site/static/search-index.json +10 -0
- package/package.json +3 -2
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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/types/file.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/types/generate.d.ts
CHANGED
|
@@ -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
|
-
//
|
|
1380
|
-
|
|
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) {
|