@juspay/neurolink 10.10.6 → 10.10.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.
- package/CHANGELOG.md +15 -0
- package/README.md +37 -8
- package/dist/browser/neurolink.min.js +399 -399
- package/dist/cli/factories/commandFactory.js +8 -4
- package/dist/constants/contextWindows.js +10 -1
- package/dist/context/anthropicLoopGuard.d.ts +1 -0
- package/dist/context/anthropicLoopGuard.js +30 -12
- package/dist/context/contextCompactor.js +19 -0
- package/dist/context/geminiLoopGuard.d.ts +54 -0
- package/dist/context/geminiLoopGuard.js +140 -0
- package/dist/core/redisConversationMemoryManager.d.ts +27 -0
- package/dist/core/redisConversationMemoryManager.js +146 -25
- package/dist/lib/constants/contextWindows.js +10 -1
- package/dist/lib/context/anthropicLoopGuard.d.ts +1 -0
- package/dist/lib/context/anthropicLoopGuard.js +30 -12
- package/dist/lib/context/contextCompactor.js +19 -0
- package/dist/lib/context/geminiLoopGuard.d.ts +54 -0
- package/dist/lib/context/geminiLoopGuard.js +141 -0
- package/dist/lib/core/redisConversationMemoryManager.d.ts +27 -0
- package/dist/lib/core/redisConversationMemoryManager.js +146 -25
- package/dist/lib/processors/media/VideoProcessor.d.ts +13 -3
- package/dist/lib/processors/media/VideoProcessor.js +53 -12
- package/dist/lib/providers/googleAiStudio/client.d.ts +0 -31
- package/dist/lib/providers/googleAiStudio/client.js +118 -1
- package/dist/lib/providers/googleNativeGemini3/utils.d.ts +9 -0
- package/dist/lib/providers/googleNativeGemini3/utils.js +12 -0
- package/dist/lib/providers/googleVertex/client.d.ts +0 -45
- package/dist/lib/providers/googleVertex/client.js +201 -21
- package/dist/lib/types/context.d.ts +9 -0
- package/dist/lib/types/file.d.ts +37 -0
- package/dist/lib/types/generate.d.ts +4 -0
- package/dist/lib/types/stream.d.ts +4 -0
- package/dist/lib/utils/errorHandling.d.ts +21 -0
- package/dist/lib/utils/errorHandling.js +53 -0
- package/dist/lib/utils/fileDetector.js +9 -6
- package/dist/lib/utils/messageBuilder.js +111 -36
- package/dist/lib/utils/pdfProcessor.d.ts +11 -0
- package/dist/lib/utils/pdfProcessor.js +17 -0
- package/dist/lib/utils/redis.d.ts +60 -1
- package/dist/lib/utils/redis.js +143 -12
- package/dist/processors/media/VideoProcessor.d.ts +13 -3
- package/dist/processors/media/VideoProcessor.js +53 -12
- package/dist/providers/googleAiStudio/client.d.ts +0 -31
- package/dist/providers/googleAiStudio/client.js +118 -1
- package/dist/providers/googleNativeGemini3/utils.d.ts +9 -0
- package/dist/providers/googleNativeGemini3/utils.js +12 -0
- package/dist/providers/googleVertex/client.d.ts +0 -45
- package/dist/providers/googleVertex/client.js +201 -21
- package/dist/types/context.d.ts +9 -0
- package/dist/types/file.d.ts +37 -0
- package/dist/types/generate.d.ts +4 -0
- package/dist/types/stream.d.ts +4 -0
- package/dist/utils/errorHandling.d.ts +21 -0
- package/dist/utils/errorHandling.js +53 -0
- package/dist/utils/fileDetector.js +9 -6
- package/dist/utils/messageBuilder.js +111 -36
- package/dist/utils/pdfProcessor.d.ts +11 -0
- package/dist/utils/pdfProcessor.js +17 -0
- package/dist/utils/redis.d.ts +60 -1
- package/dist/utils/redis.js +143 -12
- package/package.json +3 -1
|
@@ -16,6 +16,8 @@ import { applyVertexAnthropicCacheBreakpoints } from "../../utils/anthropicCache
|
|
|
16
16
|
import { FileDetector } from "../../utils/fileDetector.js";
|
|
17
17
|
import { mergeMediaFileAliases, processUnifiedFilesArray, } from "../../utils/messageBuilder.js";
|
|
18
18
|
import { logger } from "../../utils/logger.js";
|
|
19
|
+
import { GEMINI_ELISION_NOTE, planGeminiLoopReclaim, previewGeminiToolResponseText, } from "../../context/geminiLoopGuard.js";
|
|
20
|
+
import { ANTHROPIC_ELISION_NOTE, planAnthropicLoopReclaim, previewAnthropicToolResultText, } from "../../context/anthropicLoopGuard.js";
|
|
19
21
|
import { hasRestrictedOutputLimit, RESTRICTED_OUTPUT_TOKEN_LIMIT, toVertexAnthropicModelId, } from "../../utils/modelDetection.js";
|
|
20
22
|
import { detectImageMimeType } from "../../utils/imageDetection.js";
|
|
21
23
|
import { resolveClaudeMaxTokens } from "../../utils/tokenLimits.js";
|
|
@@ -25,7 +27,7 @@ import { createNativeThinkingConfig } from "../../utils/thinkingConfig.js";
|
|
|
25
27
|
import { TimeoutError, raceWithAbort, withTimeout, } from "../../utils/async/index.js";
|
|
26
28
|
import { parseTimeout } from "../../utils/timeout.js";
|
|
27
29
|
import { appendStepText, buildAbortedTurnMessage, buildContextCapMessage, buildToolLoopCapMessage, buildTurnStalledMessage, buildTurnTimeoutMessage, buildWrapupNudgeText, createContextGuard, createTextChannel, createTurnClock, extractThoughtSignature, isAbortError, mapGeminiFinishReason, prependConversationMessages, resolveTurnStopReason, DedupExecuteMap, } from "../googleNativeGemini3/index.js";
|
|
28
|
-
import { getContextWindowSize } from "../../constants/contextWindows.js";
|
|
30
|
+
import { getAvailableInputTokens, getContextWindowSize, } from "../../constants/contextWindows.js";
|
|
29
31
|
import { resolveLiveTool } from "../../tools/toolDiscovery.js";
|
|
30
32
|
import { ATTR, LANGFUSE_ATTR, spanJsonAttribute, tracers, withClientSpan, withClientStreamSpan, withSpan, } from "../../telemetry/index.js";
|
|
31
33
|
import { SpanKind, SpanStatusCode, context as otelContext, trace as otelTrace, } from "@opentelemetry/api";
|
|
@@ -587,6 +589,148 @@ const isAnthropicModel = (modelName) => {
|
|
|
587
589
|
* Solution: Simplify schema or reduce number of tools if this occurs.
|
|
588
590
|
* @see https://cloud.google.com/vertex-ai/docs/generative-ai/learn/models
|
|
589
591
|
*/
|
|
592
|
+
/** Byte budget above which an old tool response is previewed, not kept whole. */
|
|
593
|
+
const TOOL_RESPONSE_PREVIEW_BYTES = 2048;
|
|
594
|
+
/**
|
|
595
|
+
* Reclaim context from a Gemini-shaped loop history IN PLACE.
|
|
596
|
+
*
|
|
597
|
+
* Returns true when something was actually reclaimed, which tells the caller
|
|
598
|
+
* it is safe to continue the loop instead of stopping. Mutates `contents` so
|
|
599
|
+
* the caller's array identity (captured by the request builder) stays valid.
|
|
600
|
+
*/
|
|
601
|
+
function reclaimVertexLoopContext(contents, modelName, observedPromptTokens) {
|
|
602
|
+
const plan = planGeminiLoopReclaim({
|
|
603
|
+
contents,
|
|
604
|
+
// The usable INPUT budget, not the whole window: the window has to hold the
|
|
605
|
+
// model's output too, and the AI Studio twin already reclaims against this
|
|
606
|
+
// same definition.
|
|
607
|
+
availableInputTokens: getAvailableInputTokens("vertex", modelName),
|
|
608
|
+
provider: "vertex",
|
|
609
|
+
observedPromptTokens,
|
|
610
|
+
});
|
|
611
|
+
if (!plan) {
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
const dropSet = new Set(plan.drop);
|
|
615
|
+
const truncateSet = new Set(plan.truncate);
|
|
616
|
+
const rebuilt = [];
|
|
617
|
+
for (let i = 0; i < contents.length; i++) {
|
|
618
|
+
if (dropSet.has(i)) {
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const content = contents[i];
|
|
622
|
+
if (truncateSet.has(i) && Array.isArray(content.parts)) {
|
|
623
|
+
rebuilt.push({
|
|
624
|
+
...content,
|
|
625
|
+
parts: content.parts.map((part) => {
|
|
626
|
+
if (!("functionResponse" in part)) {
|
|
627
|
+
return part;
|
|
628
|
+
}
|
|
629
|
+
const fn = part.functionResponse;
|
|
630
|
+
const text = JSON.stringify(fn.response) ?? "";
|
|
631
|
+
if (text.length <= TOOL_RESPONSE_PREVIEW_BYTES) {
|
|
632
|
+
return part;
|
|
633
|
+
}
|
|
634
|
+
// Rebuilt rather than cast: `functionResponse` requires `name`, and
|
|
635
|
+
// Critical Rule 14 forbids casting through `unknown` to paper over it.
|
|
636
|
+
return {
|
|
637
|
+
functionResponse: {
|
|
638
|
+
name: fn.name,
|
|
639
|
+
response: { result: previewGeminiToolResponseText(text) },
|
|
640
|
+
},
|
|
641
|
+
};
|
|
642
|
+
}),
|
|
643
|
+
});
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
rebuilt.push(content);
|
|
647
|
+
}
|
|
648
|
+
if (dropSet.size > 0) {
|
|
649
|
+
// Gemini requires the history to start with a user turn; the note is
|
|
650
|
+
// inserted before the first surviving tool turn, never at the end where a
|
|
651
|
+
// "history was removed" cue would follow the content it refers to.
|
|
652
|
+
let noteIndex = rebuilt.findIndex((c) => Array.isArray(c.parts) &&
|
|
653
|
+
c.parts.some((part) => !!part.functionCall ||
|
|
654
|
+
!!part.functionResponse));
|
|
655
|
+
if (noteIndex < 0) {
|
|
656
|
+
noteIndex = Math.min(1, rebuilt.length);
|
|
657
|
+
}
|
|
658
|
+
rebuilt.splice(noteIndex, 0, {
|
|
659
|
+
role: "user",
|
|
660
|
+
parts: [{ text: GEMINI_ELISION_NOTE }],
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
contents.length = 0;
|
|
664
|
+
contents.push(...rebuilt);
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Reclaim context from a Vertex+Claude loop history IN PLACE.
|
|
669
|
+
*
|
|
670
|
+
* Same parity upgrade as the Gemini path, but this loop carries Anthropic
|
|
671
|
+
* content blocks, so it reuses the Anthropic adapter. Returns true when
|
|
672
|
+
* something was reclaimed and the loop may continue.
|
|
673
|
+
*/
|
|
674
|
+
function reclaimVertexAnthropicContext(messages, modelName, observedPromptTokens) {
|
|
675
|
+
const plan = planAnthropicLoopReclaim({
|
|
676
|
+
conversation: messages,
|
|
677
|
+
// Usable input budget, matching the Gemini twin above.
|
|
678
|
+
availableInputTokens: getAvailableInputTokens("vertex", modelName),
|
|
679
|
+
fixedOverheadTokens: 0,
|
|
680
|
+
provider: "vertex",
|
|
681
|
+
observedPromptTokens,
|
|
682
|
+
// This loop plans only when its context guard trips, so the count it hands
|
|
683
|
+
// over is the guard's projection for the request about to be sent, not a
|
|
684
|
+
// previous request's total. Without saying so the planner has no
|
|
685
|
+
// denominator, calibration stays pinned at 1, and the reclaim is inert:
|
|
686
|
+
// the guard fires on real tokens at the same ratio the planner tests its
|
|
687
|
+
// smaller char estimate against, so the plan never fires and the turn stops
|
|
688
|
+
// instead of continuing.
|
|
689
|
+
observedDescribesCurrentPayload: true,
|
|
690
|
+
});
|
|
691
|
+
if (!plan) {
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
const dropSet = new Set(plan.drop);
|
|
695
|
+
const truncateSet = new Set(plan.truncate);
|
|
696
|
+
const rebuilt = [];
|
|
697
|
+
for (let i = 0; i < messages.length; i++) {
|
|
698
|
+
if (dropSet.has(i)) {
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
const message = messages[i];
|
|
702
|
+
if (truncateSet.has(i) && Array.isArray(message.content)) {
|
|
703
|
+
rebuilt.push({
|
|
704
|
+
...message,
|
|
705
|
+
content: message.content.map((block) => {
|
|
706
|
+
if (block.type !== "tool_result") {
|
|
707
|
+
return block;
|
|
708
|
+
}
|
|
709
|
+
const text = typeof block.content === "string"
|
|
710
|
+
? block.content
|
|
711
|
+
: (JSON.stringify(block.content) ?? "");
|
|
712
|
+
return { ...block, content: previewAnthropicToolResultText(text) };
|
|
713
|
+
}),
|
|
714
|
+
});
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
rebuilt.push(message);
|
|
718
|
+
}
|
|
719
|
+
if (dropSet.size > 0) {
|
|
720
|
+
let noteIndex = rebuilt.findIndex((m) => Array.isArray(m.content) &&
|
|
721
|
+
m.content.some((b) => b.type === "tool_use" || b.type === "tool_result"));
|
|
722
|
+
if (noteIndex < 0) {
|
|
723
|
+
noteIndex = Math.min(1, rebuilt.length);
|
|
724
|
+
}
|
|
725
|
+
rebuilt.splice(noteIndex, 0, {
|
|
726
|
+
role: "user",
|
|
727
|
+
content: [{ type: "text", text: ANTHROPIC_ELISION_NOTE }],
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
messages.length = 0;
|
|
731
|
+
messages.push(...rebuilt);
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
590
734
|
export class GoogleVertexProvider extends BaseProvider {
|
|
591
735
|
projectId;
|
|
592
736
|
location;
|
|
@@ -1574,11 +1718,22 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
1574
1718
|
// conversation crosses the window threshold — synthesize from what
|
|
1575
1719
|
// we have instead of stepping into a provider rejection.
|
|
1576
1720
|
if (contextGuard.shouldStop()) {
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1721
|
+
// Parity upgrade: try to RECLAIM budget and keep going before
|
|
1722
|
+
// falling back to the historic stop-only behaviour. Ending the turn
|
|
1723
|
+
// early is safe but throws away work the model was mid-way through;
|
|
1724
|
+
// dropping the oldest complete tool exchanges usually buys enough
|
|
1725
|
+
// room to finish. Only when reclaiming changes nothing do we stop.
|
|
1726
|
+
const reclaimed = reclaimVertexLoopContext(currentContents, modelName, contextGuard.projectedNextPromptTokens);
|
|
1727
|
+
if (reclaimed) {
|
|
1728
|
+
contextGuard.resetAfterReclaim();
|
|
1729
|
+
}
|
|
1730
|
+
else {
|
|
1731
|
+
hitContextLimit = true;
|
|
1732
|
+
logger.warn(`[GoogleVertex] Gemini turn stopped by the context guard: ` +
|
|
1733
|
+
`projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
|
|
1734
|
+
`>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
|
|
1735
|
+
break;
|
|
1736
|
+
}
|
|
1582
1737
|
}
|
|
1583
1738
|
step++;
|
|
1584
1739
|
turnClock.noteProgress();
|
|
@@ -2550,11 +2705,22 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
2550
2705
|
// conversation crosses the window threshold — synthesize from what
|
|
2551
2706
|
// we have instead of stepping into a provider rejection.
|
|
2552
2707
|
if (contextGuard.shouldStop()) {
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2708
|
+
// Parity upgrade: try to RECLAIM budget and keep going before
|
|
2709
|
+
// falling back to the historic stop-only behaviour. Ending the turn
|
|
2710
|
+
// early is safe but throws away work the model was mid-way through;
|
|
2711
|
+
// dropping the oldest complete tool exchanges usually buys enough
|
|
2712
|
+
// room to finish. Only when reclaiming changes nothing do we stop.
|
|
2713
|
+
const reclaimed = reclaimVertexLoopContext(currentContents, modelName, contextGuard.projectedNextPromptTokens);
|
|
2714
|
+
if (reclaimed) {
|
|
2715
|
+
contextGuard.resetAfterReclaim();
|
|
2716
|
+
}
|
|
2717
|
+
else {
|
|
2718
|
+
hitContextLimit = true;
|
|
2719
|
+
logger.warn(`[GoogleVertex] Gemini turn stopped by the context guard: ` +
|
|
2720
|
+
`projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
|
|
2721
|
+
`>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
|
|
2722
|
+
break;
|
|
2723
|
+
}
|
|
2558
2724
|
}
|
|
2559
2725
|
step++;
|
|
2560
2726
|
turnClock.noteProgress();
|
|
@@ -3671,11 +3837,19 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
3671
3837
|
// Context guard: stop the tool loop before the accumulated
|
|
3672
3838
|
// conversation crosses the window threshold (see generate twin).
|
|
3673
3839
|
if (contextGuard.shouldStop()) {
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3840
|
+
// Parity upgrade: reclaim and continue where possible; the
|
|
3841
|
+
// historic stop-only behaviour remains the fallback.
|
|
3842
|
+
const reclaimed = reclaimVertexAnthropicContext(currentMessages, modelName, contextGuard.projectedNextPromptTokens);
|
|
3843
|
+
if (reclaimed) {
|
|
3844
|
+
contextGuard.resetAfterReclaim();
|
|
3845
|
+
}
|
|
3846
|
+
else {
|
|
3847
|
+
hitContextLimit = true;
|
|
3848
|
+
logger.warn(`[GoogleVertex] Anthropic stream turn stopped by the context guard: ` +
|
|
3849
|
+
`projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
|
|
3850
|
+
`>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
|
|
3851
|
+
break;
|
|
3852
|
+
}
|
|
3679
3853
|
}
|
|
3680
3854
|
step++;
|
|
3681
3855
|
turnClock.noteProgress();
|
|
@@ -4929,11 +5103,17 @@ export class GoogleVertexProvider extends BaseProvider {
|
|
|
4929
5103
|
// this step's appended tool results/output) would cross the window
|
|
4930
5104
|
// threshold — stop the tool loop and synthesize from what we have.
|
|
4931
5105
|
if (contextGuard.shouldStop()) {
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
5106
|
+
const reclaimed = reclaimVertexAnthropicContext(currentMessages, modelName, contextGuard.projectedNextPromptTokens);
|
|
5107
|
+
if (reclaimed) {
|
|
5108
|
+
contextGuard.resetAfterReclaim();
|
|
5109
|
+
}
|
|
5110
|
+
else {
|
|
5111
|
+
hitContextLimit = true;
|
|
5112
|
+
logger.warn(`[GoogleVertex] Anthropic generate turn stopped by the context guard: ` +
|
|
5113
|
+
`projected prompt ~${contextGuard.projectedNextPromptTokens} tokens ` +
|
|
5114
|
+
`>= threshold ${contextGuard.thresholdTokens} (step ${step}) — synthesizing a final answer.`);
|
|
5115
|
+
break;
|
|
5116
|
+
}
|
|
4937
5117
|
}
|
|
4938
5118
|
step++;
|
|
4939
5119
|
turnClock.noteProgress();
|
package/dist/types/context.d.ts
CHANGED
|
@@ -471,6 +471,15 @@ export type AnthropicGuardMessage = {
|
|
|
471
471
|
role: "user" | "assistant" | "system";
|
|
472
472
|
content: string | AnthropicGuardBlock[];
|
|
473
473
|
};
|
|
474
|
+
/**
|
|
475
|
+
* Structural view of one Gemini history entry, loose enough to accept both the
|
|
476
|
+
* native Vertex loop's `{ role, parts }` array and `@google/genai` contents
|
|
477
|
+
* without a cast at either call site.
|
|
478
|
+
*/
|
|
479
|
+
export type GeminiGuardContent = {
|
|
480
|
+
role: string;
|
|
481
|
+
parts: unknown[];
|
|
482
|
+
};
|
|
474
483
|
/** Tuning for {@link planLoopGuardReclaim}. */
|
|
475
484
|
export type LoopGuardPolicy = {
|
|
476
485
|
availableInputTokens: number;
|
package/dist/types/file.d.ts
CHANGED
|
@@ -302,6 +302,21 @@ export type AudioProcessorOptions = {
|
|
|
302
302
|
/** Maximum file size in megabytes */
|
|
303
303
|
maxSizeMB?: number;
|
|
304
304
|
};
|
|
305
|
+
/**
|
|
306
|
+
* Keyframe-extraction knobs for an attached video (#478).
|
|
307
|
+
*
|
|
308
|
+
* These back the `--video-frames` / `--video-quality` / `--video-format` CLI
|
|
309
|
+
* flags and `GenerateOptions.videoOptions`. Each is clamped to the processor's
|
|
310
|
+
* own ceiling — a caller cannot raise `frames` above VIDEO_CONFIG.MAX_FRAMES.
|
|
311
|
+
*/
|
|
312
|
+
export type VideoProcessorOptions = {
|
|
313
|
+
/** Max keyframes to extract. Clamped to the processor's MAX_FRAMES ceiling. */
|
|
314
|
+
frames?: number;
|
|
315
|
+
/** Encoder quality 1-100 for the extracted frames. */
|
|
316
|
+
quality?: number;
|
|
317
|
+
/** Frame encoding. Defaults to jpeg. */
|
|
318
|
+
format?: "jpeg" | "png";
|
|
319
|
+
};
|
|
305
320
|
/**
|
|
306
321
|
* Office processor options for Word, PowerPoint, and Excel documents
|
|
307
322
|
*
|
|
@@ -366,6 +381,7 @@ export type FileDetectorOptions = {
|
|
|
366
381
|
audioOptions?: AudioProcessorOptions;
|
|
367
382
|
csvOptions?: CSVProcessorOptions;
|
|
368
383
|
officeOptions?: OfficeProcessorOptions;
|
|
384
|
+
videoOptions?: VideoProcessorOptions;
|
|
369
385
|
confidenceThreshold?: number;
|
|
370
386
|
provider?: string;
|
|
371
387
|
/** Maximum number of retry attempts for network requests (default: 3) */
|
|
@@ -438,6 +454,27 @@ export type PDFImagePage = {
|
|
|
438
454
|
/** Populated when this page failed to render (#294). */
|
|
439
455
|
error?: string;
|
|
440
456
|
};
|
|
457
|
+
/**
|
|
458
|
+
* A single PDF queued for multimodal message building, normalised from either
|
|
459
|
+
* submission surface — `input.pdfFiles` or `input.content` with `type: "pdf"`
|
|
460
|
+
* — so both can share the aggregate page/size guard (#309).
|
|
461
|
+
*/
|
|
462
|
+
export type MultimodalPdfEntry = {
|
|
463
|
+
/** Raw PDF bytes. */
|
|
464
|
+
buffer: Buffer;
|
|
465
|
+
/** Display name; may be a full path, so log only its basename. */
|
|
466
|
+
filename: string;
|
|
467
|
+
/**
|
|
468
|
+
* Page count when known. Null/undefined on the `input.content` path whenever
|
|
469
|
+
* the caller omitted `metadata.pages`; the aggregate guard resolves those
|
|
470
|
+
* from `buffer` rather than treating them as zero.
|
|
471
|
+
*/
|
|
472
|
+
pageCount?: number | null;
|
|
473
|
+
/** Password for an encrypted PDF (#258). */
|
|
474
|
+
password?: string;
|
|
475
|
+
/** Per-page pixel ceiling for the image fallback (#260). */
|
|
476
|
+
maxCanvasPixels?: number;
|
|
477
|
+
};
|
|
441
478
|
/** Result of PDF to image conversion. */
|
|
442
479
|
export type PDFImageConversionResult = {
|
|
443
480
|
/** Array of base64-encoded PNG images (one per successfully converted page) */
|
package/dist/types/generate.d.ts
CHANGED
|
@@ -135,9 +135,13 @@ export type GenerateOptions = {
|
|
|
135
135
|
maxCanvasPixels?: number;
|
|
136
136
|
};
|
|
137
137
|
videoOptions?: {
|
|
138
|
+
/** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
|
|
138
139
|
frames?: number;
|
|
140
|
+
/** Frame encoder quality, clamped to 1-100. Default 80. */
|
|
139
141
|
quality?: number;
|
|
142
|
+
/** Frame encoding. Default jpeg. */
|
|
140
143
|
format?: "jpeg" | "png";
|
|
144
|
+
/** Not implemented yet (#433) — warns rather than silently doing nothing. */
|
|
141
145
|
transcribeAudio?: boolean;
|
|
142
146
|
};
|
|
143
147
|
/**
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -227,9 +227,13 @@ export type StreamOptions = {
|
|
|
227
227
|
maxCanvasPixels?: number;
|
|
228
228
|
};
|
|
229
229
|
videoOptions?: {
|
|
230
|
+
/** Frames to extract. Unset lets VideoProcessor pick from the clip's duration; clamped to 100. */
|
|
230
231
|
frames?: number;
|
|
232
|
+
/** Frame encoder quality, clamped to 1-100. Default 80. */
|
|
231
233
|
quality?: number;
|
|
234
|
+
/** Frame encoding. Default jpeg. */
|
|
232
235
|
format?: "jpeg" | "png";
|
|
236
|
+
/** Not implemented yet (#433) — warns rather than silently doing nothing. */
|
|
233
237
|
transcribeAudio?: boolean;
|
|
234
238
|
};
|
|
235
239
|
/**
|
|
@@ -39,6 +39,9 @@ export declare const ERROR_CODES: {
|
|
|
39
39
|
readonly FILE_PROCESSING_FAILED: "FILE_PROCESSING_FAILED";
|
|
40
40
|
readonly CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED";
|
|
41
41
|
readonly PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED";
|
|
42
|
+
readonly PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED: "PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED";
|
|
43
|
+
readonly PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED: "PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED";
|
|
44
|
+
readonly PDF_PAGE_COUNT_UNVERIFIABLE: "PDF_PAGE_COUNT_UNVERIFIABLE";
|
|
42
45
|
readonly PDF_PASSWORD_REQUIRED: "PDF_PASSWORD_REQUIRED";
|
|
43
46
|
readonly PDF_INCORRECT_PASSWORD: "PDF_INCORRECT_PASSWORD";
|
|
44
47
|
readonly RATE_LIMITER_QUEUE_FULL: "RATE_LIMITER_QUEUE_FULL";
|
|
@@ -203,6 +206,24 @@ export declare class ErrorFactory {
|
|
|
203
206
|
* Create a PDF page limit exceeded error
|
|
204
207
|
*/
|
|
205
208
|
static pdfPageLimitExceeded(estimatedPages: number, maxPages: number, provider: string): NeuroLinkError;
|
|
209
|
+
/**
|
|
210
|
+
* The combined page count across every PDF in one request exceeds what the
|
|
211
|
+
* provider accepts (#309). Distinct from `pdfPageLimitExceeded`, which is
|
|
212
|
+
* per-file — here each document can be individually legal.
|
|
213
|
+
*/
|
|
214
|
+
static pdfAggregatePageLimitExceeded(fileCount: number, totalPages: number, maxPages: number, provider: string): NeuroLinkError;
|
|
215
|
+
/**
|
|
216
|
+
* The combined byte size across every PDF in one request exceeds what the
|
|
217
|
+
* provider accepts (#309).
|
|
218
|
+
*/
|
|
219
|
+
static pdfAggregateSizeLimitExceeded(fileCount: number, totalMB: number, maxSizeMB: number, provider: string): NeuroLinkError;
|
|
220
|
+
/**
|
|
221
|
+
* A PDF supplied through the untrusted `input.content` surface could not be
|
|
222
|
+
* parsed for a page count (#309). Caller-supplied `metadata.pages` is not
|
|
223
|
+
* authoritative there, so an unreadable document is rejected rather than
|
|
224
|
+
* admitted with an assumed count of zero.
|
|
225
|
+
*/
|
|
226
|
+
static pdfPageCountUnverifiable(filenames: string[], provider: string): NeuroLinkError;
|
|
206
227
|
/**
|
|
207
228
|
* The PDF is encrypted and no password was supplied (#258).
|
|
208
229
|
*/
|
|
@@ -52,6 +52,9 @@ export const ERROR_CODES = {
|
|
|
52
52
|
CSV_PROCESSING_FAILED: "CSV_PROCESSING_FAILED",
|
|
53
53
|
// PDF validation errors
|
|
54
54
|
PDF_PAGE_LIMIT_EXCEEDED: "PDF_PAGE_LIMIT_EXCEEDED",
|
|
55
|
+
PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED: "PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED",
|
|
56
|
+
PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED: "PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED",
|
|
57
|
+
PDF_PAGE_COUNT_UNVERIFIABLE: "PDF_PAGE_COUNT_UNVERIFIABLE",
|
|
55
58
|
PDF_PASSWORD_REQUIRED: "PDF_PASSWORD_REQUIRED",
|
|
56
59
|
PDF_INCORRECT_PASSWORD: "PDF_INCORRECT_PASSWORD",
|
|
57
60
|
// Rate limiter errors
|
|
@@ -557,6 +560,56 @@ export class ErrorFactory {
|
|
|
557
560
|
},
|
|
558
561
|
});
|
|
559
562
|
}
|
|
563
|
+
/**
|
|
564
|
+
* The combined page count across every PDF in one request exceeds what the
|
|
565
|
+
* provider accepts (#309). Distinct from `pdfPageLimitExceeded`, which is
|
|
566
|
+
* per-file — here each document can be individually legal.
|
|
567
|
+
*/
|
|
568
|
+
static pdfAggregatePageLimitExceeded(fileCount, totalPages, maxPages, provider) {
|
|
569
|
+
return new NeuroLinkError({
|
|
570
|
+
code: ERROR_CODES.PDF_AGGREGATE_PAGE_LIMIT_EXCEEDED,
|
|
571
|
+
message: `[PDF] Combined page count across ${fileCount} PDF(s) (${totalPages}) exceeds the ` +
|
|
572
|
+
`${maxPages}-page limit for ${provider}. ` +
|
|
573
|
+
`Split the request or reduce the number of PDFs.`,
|
|
574
|
+
category: ErrorCategory.VALIDATION,
|
|
575
|
+
severity: ErrorSeverity.MEDIUM,
|
|
576
|
+
retriable: false,
|
|
577
|
+
context: { fileCount, totalPages, maxPages, provider },
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* The combined byte size across every PDF in one request exceeds what the
|
|
582
|
+
* provider accepts (#309).
|
|
583
|
+
*/
|
|
584
|
+
static pdfAggregateSizeLimitExceeded(fileCount, totalMB, maxSizeMB, provider) {
|
|
585
|
+
return new NeuroLinkError({
|
|
586
|
+
code: ERROR_CODES.PDF_AGGREGATE_SIZE_LIMIT_EXCEEDED,
|
|
587
|
+
message: `[PDF] Combined size across ${fileCount} PDF(s) (${totalMB.toFixed(2)}MB) exceeds the ` +
|
|
588
|
+
`${maxSizeMB}MB limit for ${provider}.`,
|
|
589
|
+
category: ErrorCategory.VALIDATION,
|
|
590
|
+
severity: ErrorSeverity.MEDIUM,
|
|
591
|
+
retriable: false,
|
|
592
|
+
context: { fileCount, totalMB, maxSizeMB, provider },
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* A PDF supplied through the untrusted `input.content` surface could not be
|
|
597
|
+
* parsed for a page count (#309). Caller-supplied `metadata.pages` is not
|
|
598
|
+
* authoritative there, so an unreadable document is rejected rather than
|
|
599
|
+
* admitted with an assumed count of zero.
|
|
600
|
+
*/
|
|
601
|
+
static pdfPageCountUnverifiable(filenames, provider) {
|
|
602
|
+
return new NeuroLinkError({
|
|
603
|
+
code: ERROR_CODES.PDF_PAGE_COUNT_UNVERIFIABLE,
|
|
604
|
+
message: `[PDF] Cannot verify the page count for ${filenames.length} PDF(s) supplied via ` +
|
|
605
|
+
`input.content (${filenames.join(", ")}). Provide readable PDFs, or submit them ` +
|
|
606
|
+
`via input.pdfFiles where page counts are derived during detection.`,
|
|
607
|
+
category: ErrorCategory.VALIDATION,
|
|
608
|
+
severity: ErrorSeverity.MEDIUM,
|
|
609
|
+
retriable: false,
|
|
610
|
+
context: { filenames, provider },
|
|
611
|
+
});
|
|
612
|
+
}
|
|
560
613
|
/**
|
|
561
614
|
* The PDF is encrypted and no password was supplied (#258).
|
|
562
615
|
*/
|
|
@@ -344,13 +344,13 @@ export class FileDetector {
|
|
|
344
344
|
logger.warn(`[FileDetector] All fallback parsing failed for type "${detection.type}". ` +
|
|
345
345
|
`Attempted: ${options.allowedTypes.join(", ")}. Falling through to universal handler.`);
|
|
346
346
|
const csvOptions = options?.csvOptions;
|
|
347
|
-
const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider);
|
|
347
|
+
const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions);
|
|
348
348
|
FileDetector.setFileResultSpanAttributes(span, result, inputFilename, detection.type);
|
|
349
349
|
return result;
|
|
350
350
|
}
|
|
351
351
|
const content = await FileDetector.loadContent(input, detection, options);
|
|
352
352
|
const csvOptions = options?.csvOptions;
|
|
353
|
-
const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider);
|
|
353
|
+
const result = await FileDetector.processFile(content, detection, csvOptions, options?.provider, options?.videoOptions);
|
|
354
354
|
FileDetector.setFileResultSpanAttributes(span, result, inputFilename, detection.type);
|
|
355
355
|
return result;
|
|
356
356
|
});
|
|
@@ -967,7 +967,7 @@ export class FileDetector {
|
|
|
967
967
|
/**
|
|
968
968
|
* Route to appropriate processor
|
|
969
969
|
*/
|
|
970
|
-
static async processFile(content, detection, options, provider) {
|
|
970
|
+
static async processFile(content, detection, options, provider, videoOptions) {
|
|
971
971
|
switch (detection.type) {
|
|
972
972
|
case "csv":
|
|
973
973
|
// Pass original extension through to CSV processor; if detection has none,
|
|
@@ -985,7 +985,7 @@ export class FileDetector {
|
|
|
985
985
|
// AI providers don't support SVG as image format, so we extract text content
|
|
986
986
|
return await FileDetector.processSvgAsText(content, detection);
|
|
987
987
|
case "video":
|
|
988
|
-
return await FileDetector.processVideoFile(content, detection);
|
|
988
|
+
return await FileDetector.processVideoFile(content, detection, videoOptions);
|
|
989
989
|
case "audio":
|
|
990
990
|
return await FileDetector.processAudioFile(content, detection);
|
|
991
991
|
case "archive":
|
|
@@ -1031,7 +1031,7 @@ export class FileDetector {
|
|
|
1031
1031
|
/**
|
|
1032
1032
|
* Process video file: extract metadata, keyframes, and subtitles via VideoProcessor
|
|
1033
1033
|
*/
|
|
1034
|
-
static async processVideoFile(content, detection) {
|
|
1034
|
+
static async processVideoFile(content, detection, videoOptions) {
|
|
1035
1035
|
const videoFilename = detection.metadata.filename || "video";
|
|
1036
1036
|
try {
|
|
1037
1037
|
const videoResult = await (await getVideoProcessor()).processFile({
|
|
@@ -1040,7 +1040,10 @@ export class FileDetector {
|
|
|
1040
1040
|
mimetype: detection.mimeType || "video/mp4",
|
|
1041
1041
|
size: content.length,
|
|
1042
1042
|
buffer: content,
|
|
1043
|
-
}
|
|
1043
|
+
},
|
|
1044
|
+
// #478: carry the caller's keyframe budget/quality/format through to
|
|
1045
|
+
// the processor; previously these stopped at the CLI layer.
|
|
1046
|
+
videoOptions);
|
|
1044
1047
|
if (videoResult.success && videoResult.data) {
|
|
1045
1048
|
return {
|
|
1046
1049
|
type: "video",
|