@bedolla/enrivision 0.1.5 → 0.1.6
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/README.md +31 -6
- package/dist/client/EnriProxyClient.d.ts +289 -244
- package/dist/client/EnriProxyClient.d.ts.map +1 -1
- package/dist/client/EnriProxyClient.js +841 -115
- package/dist/client/EnriProxyClient.js.map +1 -1
- package/dist/client/EnriProxyClientContract.d.ts +425 -0
- package/dist/client/EnriProxyClientContract.d.ts.map +1 -0
- package/dist/client/EnriProxyClientContract.js +87 -0
- package/dist/client/EnriProxyClientContract.js.map +1 -0
- package/dist/index.js +23 -12
- package/dist/index.js.map +1 -1
- package/dist/package-info.d.ts +28 -0
- package/dist/package-info.d.ts.map +1 -1
- package/dist/package-info.js +28 -0
- package/dist/package-info.js.map +1 -1
- package/dist/server/EnriVisionServer.d.ts +186 -0
- package/dist/server/EnriVisionServer.d.ts.map +1 -1
- package/dist/server/EnriVisionServer.js +780 -93
- package/dist/server/EnriVisionServer.js.map +1 -1
- package/dist/shared/codepointTruncation.d.ts +61 -0
- package/dist/shared/codepointTruncation.d.ts.map +1 -0
- package/dist/shared/codepointTruncation.js +73 -0
- package/dist/shared/codepointTruncation.js.map +1 -0
- package/dist/shared/mediaUrlFetcher.d.ts +247 -9
- package/dist/shared/mediaUrlFetcher.d.ts.map +1 -1
- package/dist/shared/mediaUrlFetcher.js +712 -53
- package/dist/shared/mediaUrlFetcher.js.map +1 -1
- package/dist/shared/tar.d.ts +82 -2
- package/dist/shared/tar.d.ts.map +1 -1
- package/dist/shared/tar.js +106 -43
- package/dist/shared/tar.js.map +1 -1
- package/dist/shared/validation.d.ts +96 -2
- package/dist/shared/validation.d.ts.map +1 -1
- package/dist/shared/validation.js +169 -10
- package/dist/shared/validation.js.map +1 -1
- package/dist/tools/AnalyzeMediaContract.d.ts +457 -0
- package/dist/tools/AnalyzeMediaContract.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaContract.js +161 -0
- package/dist/tools/AnalyzeMediaContract.js.map +1 -0
- package/dist/tools/AnalyzeMediaExtractionSanitizer.d.ts +35 -0
- package/dist/tools/AnalyzeMediaExtractionSanitizer.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaExtractionSanitizer.js +214 -0
- package/dist/tools/AnalyzeMediaExtractionSanitizer.js.map +1 -0
- package/dist/tools/AnalyzeMediaInputResolver.d.ts +250 -0
- package/dist/tools/AnalyzeMediaInputResolver.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaInputResolver.js +430 -0
- package/dist/tools/AnalyzeMediaInputResolver.js.map +1 -0
- package/dist/tools/AnalyzeMediaParamParser.d.ts +299 -0
- package/dist/tools/AnalyzeMediaParamParser.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaParamParser.js +824 -0
- package/dist/tools/AnalyzeMediaParamParser.js.map +1 -0
- package/dist/tools/AnalyzeMediaResumableUploader.d.ts +244 -0
- package/dist/tools/AnalyzeMediaResumableUploader.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaResumableUploader.js +549 -0
- package/dist/tools/AnalyzeMediaResumableUploader.js.map +1 -0
- package/dist/tools/AnalyzeMediaTarPackager.d.ts +42 -0
- package/dist/tools/AnalyzeMediaTarPackager.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaTarPackager.js +245 -0
- package/dist/tools/AnalyzeMediaTarPackager.js.map +1 -0
- package/dist/tools/AnalyzeMediaTool.d.ts +155 -294
- package/dist/tools/AnalyzeMediaTool.d.ts.map +1 -1
- package/dist/tools/AnalyzeMediaTool.js +600 -457
- package/dist/tools/AnalyzeMediaTool.js.map +1 -1
- package/package.json +2 -1
|
@@ -1,31 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ANALYZE MEDIA TOOL
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* - Validates local file paths or http(s) URLs
|
|
6
|
-
* - Materializes URL inputs via bounded downloads (
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
4
|
+
* Facade for the `analyze_media` MCP tool:
|
|
5
|
+
* - Validates local file paths or http(s) URLs (param parser)
|
|
6
|
+
* - Materializes URL inputs via bounded downloads (input resolver)
|
|
7
|
+
* - Uploads single files or multi-image tar sets through resumable
|
|
8
|
+
* EnriProxy sessions (uploader + tar packager)
|
|
9
|
+
* - Triggers server-side analysis and returns sanitized text-only results
|
|
9
10
|
*
|
|
10
11
|
* @module tools/AnalyzeMediaTool
|
|
11
12
|
*/
|
|
12
|
-
import { existsSync } from "node:fs";
|
|
13
|
-
import { stat, open } from "node:fs/promises";
|
|
14
|
-
import { basename, extname, isAbsolute } from "node:path";
|
|
15
13
|
import { randomUUID } from "node:crypto";
|
|
16
14
|
import { lookup as mimeLookup } from "mime-types";
|
|
17
|
-
import {
|
|
18
|
-
import { assertHttpUrl, assertNonEmptyString, assertObject, optionalBoolean, optionalInt, optionalNumber, optionalString } from "../shared/validation.js";
|
|
15
|
+
import { assertHttpUrl, assertNonEmptyString } from "../shared/validation.js";
|
|
19
16
|
import { MediaUrlFetcher } from "../shared/mediaUrlFetcher.js";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const ENRIVISION_MEDIA_SET_TAR_MANIFEST_NAME = "manifest.json";
|
|
17
|
+
import { EnriProxyHttpError } from "../client/EnriProxyClient.js";
|
|
18
|
+
import { AnalyzeMediaExtractionSanitizer } from "./AnalyzeMediaExtractionSanitizer.js";
|
|
19
|
+
import { AnalyzeMediaInputResolver } from "./AnalyzeMediaInputResolver.js";
|
|
20
|
+
import { AnalyzeMediaParamParser } from "./AnalyzeMediaParamParser.js";
|
|
21
|
+
import { AnalyzeMediaResumableUploader } from "./AnalyzeMediaResumableUploader.js";
|
|
22
|
+
import { isProgressQuiet, withResumableRetry } from "./AnalyzeMediaResumableUploader.js";
|
|
23
|
+
import { AnalyzeMediaTarPackager } from "./AnalyzeMediaTarPackager.js";
|
|
24
|
+
import { ANALYZE_MEDIA_LIMITS } from "./AnalyzeMediaContract.js";
|
|
29
25
|
/**
|
|
30
26
|
* MCP tool that uploads and analyzes local or http(s) URL media.
|
|
31
27
|
*/
|
|
@@ -35,193 +31,180 @@ export class AnalyzeMediaTool {
|
|
|
35
31
|
*/
|
|
36
32
|
deps;
|
|
37
33
|
/**
|
|
38
|
-
*
|
|
34
|
+
* Raw argument validator.
|
|
35
|
+
*/
|
|
36
|
+
paramParser;
|
|
37
|
+
/**
|
|
38
|
+
* Local/URL input materializer.
|
|
39
|
+
*/
|
|
40
|
+
inputResolver;
|
|
41
|
+
/**
|
|
42
|
+
* Resumable chunk uploader.
|
|
39
43
|
*/
|
|
40
|
-
|
|
44
|
+
uploader;
|
|
45
|
+
/**
|
|
46
|
+
* Multi-image tar set packager.
|
|
47
|
+
*/
|
|
48
|
+
tarPackager;
|
|
49
|
+
/**
|
|
50
|
+
* Extraction output sanitizer.
|
|
51
|
+
*/
|
|
52
|
+
sanitizer;
|
|
53
|
+
/**
|
|
54
|
+
* Cached vision-capability verdicts keyed by endpoint + model.
|
|
55
|
+
*
|
|
56
|
+
* @remarks
|
|
57
|
+
* `execute` builds a fresh client per call, so instance-keyed caching
|
|
58
|
+
* would never hit: the key is `serverUrl + model` (one account per
|
|
59
|
+
* endpoint). Entries live for `ANALYZE_MEDIA_LIMITS.visionProbeCacheTtlMs`
|
|
60
|
+
* (mirrors EnriCode `PROBE_CACHE_TTL_MS` and its per-client scoping).
|
|
61
|
+
*/
|
|
62
|
+
visionProbeCache = new Map();
|
|
41
63
|
/**
|
|
42
64
|
* Creates a new {@link AnalyzeMediaTool}.
|
|
43
65
|
*
|
|
44
|
-
* @param deps - Tool dependencies
|
|
45
|
-
* @param urlFetcher - Optional URL fetcher override for tests
|
|
66
|
+
* @param deps - Tool dependencies.
|
|
67
|
+
* @param urlFetcher - Optional URL fetcher override for tests.
|
|
46
68
|
*/
|
|
47
69
|
constructor(deps, urlFetcher = new MediaUrlFetcher()) {
|
|
48
70
|
this.deps = deps;
|
|
49
|
-
this.
|
|
71
|
+
this.paramParser = new AnalyzeMediaParamParser();
|
|
72
|
+
this.inputResolver = new AnalyzeMediaInputResolver(urlFetcher);
|
|
73
|
+
this.uploader = new AnalyzeMediaResumableUploader();
|
|
74
|
+
this.tarPackager = new AnalyzeMediaTarPackager(this.uploader);
|
|
75
|
+
this.sanitizer = new AnalyzeMediaExtractionSanitizer();
|
|
50
76
|
}
|
|
51
77
|
/**
|
|
52
78
|
* Validates raw MCP tool arguments.
|
|
53
79
|
*
|
|
54
|
-
* @param raw - Raw tool arguments
|
|
55
|
-
* @returns Validated parameters
|
|
80
|
+
* @param raw - Raw tool arguments.
|
|
81
|
+
* @returns Validated parameters.
|
|
82
|
+
* @throws Error with an Spanish-first bilingual message when arguments are missing or out of range.
|
|
56
83
|
*/
|
|
57
84
|
parseParams(raw) {
|
|
58
|
-
|
|
59
|
-
const pathRaw = typeof obj["path"] === "string" ? obj["path"].trim() : "";
|
|
60
|
-
const path = pathRaw ? pathRaw : undefined;
|
|
61
|
-
if (path && !isAbsolute(path) && !MediaUrlFetcher.isHttpUrl(path)) {
|
|
62
|
-
throw new Error("path debe ser una ruta de archivo absoluta o una URL http(s).");
|
|
63
|
-
}
|
|
64
|
-
const pathsRaw = obj["paths"];
|
|
65
|
-
let paths;
|
|
66
|
-
if (typeof pathsRaw !== "undefined") {
|
|
67
|
-
if (!Array.isArray(pathsRaw)) {
|
|
68
|
-
throw new Error("paths debe ser un arreglo de rutas de archivo absolutas.");
|
|
69
|
-
}
|
|
70
|
-
const out = [];
|
|
71
|
-
for (const item of pathsRaw) {
|
|
72
|
-
if (typeof item !== "string" || !item.trim()) {
|
|
73
|
-
continue;
|
|
74
|
-
}
|
|
75
|
-
const p = item.trim();
|
|
76
|
-
if (!isAbsolute(p) && !MediaUrlFetcher.isHttpUrl(p)) {
|
|
77
|
-
throw new Error("paths debe contener sólo rutas de archivo absolutas o URLs http(s).");
|
|
78
|
-
}
|
|
79
|
-
out.push(p);
|
|
80
|
-
}
|
|
81
|
-
if (out.length > 0) {
|
|
82
|
-
paths = out;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
if (!path && (!paths || paths.length === 0)) {
|
|
86
|
-
throw new Error("Proporcione 'path' o 'paths'.");
|
|
87
|
-
}
|
|
88
|
-
const context = optionalString(obj["context"]);
|
|
89
|
-
const question = optionalString(obj["question"]);
|
|
90
|
-
const language = optionalString(obj["language"]);
|
|
91
|
-
const maxFrames = optionalInt(obj["max_frames"]);
|
|
92
|
-
const transcribe = optionalBoolean(obj["transcribe"]);
|
|
93
|
-
const transcriptionLanguage = optionalString(obj["transcription_language"]);
|
|
94
|
-
const analysisModeRaw = optionalString(obj["analysis_mode"]);
|
|
95
|
-
const analysisMode = analysisModeRaw === "auto" || analysisModeRaw === "single" || analysisModeRaw === "multipass"
|
|
96
|
-
? analysisModeRaw
|
|
97
|
-
: analysisModeRaw
|
|
98
|
-
? (() => {
|
|
99
|
-
throw new Error("analysis_mode debe ser uno de: auto|single|multipass.");
|
|
100
|
-
})()
|
|
101
|
-
: undefined;
|
|
102
|
-
const videoRaw = obj["video"];
|
|
103
|
-
let video;
|
|
104
|
-
if (typeof videoRaw !== "undefined") {
|
|
105
|
-
const v = assertObject(videoRaw, "video");
|
|
106
|
-
video = {
|
|
107
|
-
clipStartSeconds: optionalNumber(v["clip_start_seconds"]),
|
|
108
|
-
clipDurationSeconds: optionalNumber(v["clip_duration_seconds"]),
|
|
109
|
-
segmentSeconds: optionalNumber(v["segment_seconds"]),
|
|
110
|
-
maxSegments: optionalInt(v["max_segments"]),
|
|
111
|
-
maxFramesPerSegment: optionalInt(v["max_frames_per_segment"])
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
const documentRaw = obj["document"];
|
|
115
|
-
let document;
|
|
116
|
-
if (typeof documentRaw !== "undefined") {
|
|
117
|
-
const d = assertObject(documentRaw, "document");
|
|
118
|
-
document = {
|
|
119
|
-
maxPagesTotal: optionalInt(d["max_pages_total"]),
|
|
120
|
-
pagesPerBatch: optionalInt(d["pages_per_batch"]),
|
|
121
|
-
maxImagesPerBatch: optionalInt(d["max_images_per_batch"]),
|
|
122
|
-
scannedTextThresholdChars: optionalInt(d["scanned_text_threshold_chars"])
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
const audioRaw = obj["audio"];
|
|
126
|
-
let audio;
|
|
127
|
-
if (typeof audioRaw !== "undefined") {
|
|
128
|
-
const a = assertObject(audioRaw, "audio");
|
|
129
|
-
audio = {
|
|
130
|
-
timestamps: optionalBoolean(a["timestamps"]),
|
|
131
|
-
segmentSeconds: optionalNumber(a["segment_seconds"]),
|
|
132
|
-
maxSegments: optionalInt(a["max_segments"])
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
const imagesRaw = obj["images"];
|
|
136
|
-
let images;
|
|
137
|
-
if (typeof imagesRaw !== "undefined") {
|
|
138
|
-
const img = assertObject(imagesRaw, "images");
|
|
139
|
-
images = {
|
|
140
|
-
maxImagesTotal: optionalInt(img["max_images_total"]),
|
|
141
|
-
imagesPerBatch: optionalInt(img["images_per_batch"]),
|
|
142
|
-
maxDimension: optionalInt(img["max_dimension"])
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
return {
|
|
146
|
-
path,
|
|
147
|
-
paths,
|
|
148
|
-
context,
|
|
149
|
-
question,
|
|
150
|
-
language,
|
|
151
|
-
maxFrames,
|
|
152
|
-
transcribe,
|
|
153
|
-
transcriptionLanguage,
|
|
154
|
-
analysisMode,
|
|
155
|
-
region: this.parseRegion(obj["region"]),
|
|
156
|
-
video,
|
|
157
|
-
document,
|
|
158
|
-
audio,
|
|
159
|
-
images
|
|
160
|
-
};
|
|
85
|
+
return this.paramParser.parseParams(raw);
|
|
161
86
|
}
|
|
162
87
|
/**
|
|
163
88
|
* Executes the tool.
|
|
164
89
|
*
|
|
165
|
-
* @param params - Validated parameters
|
|
166
|
-
* @
|
|
90
|
+
* @param params - Validated parameters.
|
|
91
|
+
* @param options - Optional execution options (cancellation signal).
|
|
92
|
+
* @returns Tool result.
|
|
93
|
+
* @throws Error with an Spanish-first bilingual message when configuration, upload, or analysis fails.
|
|
167
94
|
*/
|
|
168
|
-
async execute(params) {
|
|
95
|
+
async execute(params, options) {
|
|
96
|
+
const signal = options?.signal;
|
|
97
|
+
if (signal?.aborted) {
|
|
98
|
+
throw new Error("La solicitud fue cancelada por el cliente. / Request cancelled by the client.");
|
|
99
|
+
}
|
|
169
100
|
const serverUrl = assertHttpUrl(this.deps.defaultServerUrl, "ENRIPROXY_URL");
|
|
170
101
|
const apiKey = assertNonEmptyString(this.deps.defaultApiKey, "ENRIPROXY_API_KEY");
|
|
171
102
|
const timeoutMs = this.deps.defaultTimeoutMs;
|
|
172
103
|
const clientTraceId = `enrivision_${randomUUID()}`;
|
|
173
104
|
const client = this.deps.createClient(serverUrl, apiKey, timeoutMs);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}
|
|
182
|
-
const materialized = [];
|
|
105
|
+
// Continuation mode: no upload, no analysis, no filesystem touch — only
|
|
106
|
+
// the next window of a previously truncated list.
|
|
107
|
+
if (typeof params.cursor === "string") {
|
|
108
|
+
return await this.executeContinuation(client, params.cursor, params.offset, signal);
|
|
109
|
+
}
|
|
110
|
+
const resolved = await this.inputResolver.resolve(params, signal);
|
|
111
|
+
let uploadId = null;
|
|
183
112
|
try {
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
113
|
+
// Inside the try so the finally below always releases materialized
|
|
114
|
+
// URL downloads, even when the region check throws (A1).
|
|
115
|
+
this.rejectRegionForNonImage(params.region, resolved.inputs);
|
|
116
|
+
this.rejectMismatchedTuning(params, resolved.inputs);
|
|
117
|
+
// Fail-open vision probe (mirrors EnriCode `assertRemoteVisionCapable`):
|
|
118
|
+
// with an explicit visionless model, fail before any session or byte
|
|
119
|
+
// instead of uploading up to 4 GiB first. Only `vision === false`
|
|
120
|
+
// rejects; unknown models and probe failures proceed (fail-open).
|
|
121
|
+
await this.assertVisionCapable(client, this.resolveRequestedModel(params), signal);
|
|
122
|
+
// Advisory mismatch warnings for the remote (`source_url`) branch: the
|
|
123
|
+
// local gates above no-op on the empty input list by design (the server,
|
|
124
|
+
// which sees the served type, decides applicability), but when the URL
|
|
125
|
+
// extension guesses a known media type, likely-ignored tuning is
|
|
126
|
+
// declared as warnings instead of failing.
|
|
127
|
+
const remoteUrl = resolved.remoteUrl;
|
|
128
|
+
const remoteAdvisoryWarnings = remoteUrl !== undefined ? this.advisoryRemoteTuningWarnings(params, remoteUrl) : [];
|
|
129
|
+
const transcribeWarning = this.transcribeInapplicableWarning(params, resolved.inputs);
|
|
130
|
+
// Oversized lone URLs skip upload entirely: the server ingests
|
|
131
|
+
// `source_url` directly (SSRF guards already passed client-side).
|
|
132
|
+
if (remoteUrl === undefined && resolved.inputs.length > 1) {
|
|
133
|
+
uploadId = await this.tarPackager.uploadImageSetAsMediaSetTar(client, resolved.inputs, timeoutMs, clientTraceId, signal);
|
|
193
134
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
135
|
+
else if (remoteUrl === undefined) {
|
|
136
|
+
const single = resolved.inputs[0];
|
|
137
|
+
const session = await withResumableRetry(() => client.createUploadSession({
|
|
138
|
+
filename: single.filename,
|
|
139
|
+
sizeBytes: single.sizeBytes,
|
|
140
|
+
contentType: single.contentType,
|
|
141
|
+
clientTraceId,
|
|
142
|
+
signal,
|
|
143
|
+
}), signal);
|
|
144
|
+
const serverMaxBytes = typeof session.max_file_size_bytes === "number"
|
|
145
|
+
&& Number.isFinite(session.max_file_size_bytes)
|
|
146
|
+
&& session.max_file_size_bytes > 0
|
|
147
|
+
? Math.floor(session.max_file_size_bytes)
|
|
148
|
+
: null;
|
|
149
|
+
if (serverMaxBytes !== null && single.sizeBytes > serverMaxBytes) {
|
|
150
|
+
try {
|
|
151
|
+
await client.deleteUploadSession(session.upload_id, AbortSignal.timeout(15_000));
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// Best-effort: the size error always wins.
|
|
155
|
+
}
|
|
156
|
+
throw new Error(`El archivo excede el tamaño máximo del servidor (${String(serverMaxBytes)} bytes); use un archivo más pequeño. / File exceeds the server maximum size (${String(serverMaxBytes)} bytes); use a smaller file.`);
|
|
157
|
+
}
|
|
158
|
+
const finalOffset = await this.uploader.uploadFileResumable(client, single.localPath, single.sizeBytes, session, timeoutMs, signal, single.stagedIdentity);
|
|
159
|
+
if (finalOffset !== single.sizeBytes) {
|
|
160
|
+
throw new Error(`Subida incompleta: se enviaron ${finalOffset} de ${single.sizeBytes} bytes. / Incomplete upload: sent ${finalOffset} of ${single.sizeBytes} bytes.`);
|
|
212
161
|
}
|
|
213
162
|
uploadId = session.upload_id;
|
|
214
163
|
}
|
|
215
164
|
const defaultLanguageRaw = typeof process.env["ENRIVISION_DEFAULT_LANGUAGE"] === "string"
|
|
216
165
|
? process.env["ENRIVISION_DEFAULT_LANGUAGE"].trim()
|
|
217
166
|
: "";
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
167
|
+
const defaultLanguageValid = defaultLanguageRaw.length > 0
|
|
168
|
+
&& defaultLanguageRaw.length <= 32
|
|
169
|
+
&& /^[A-Za-z]{2,8}([-_][A-Za-z0-9]{1,8}){0,2}$/.test(defaultLanguageRaw);
|
|
170
|
+
if (defaultLanguageRaw && !defaultLanguageValid && !isProgressQuiet()) {
|
|
171
|
+
console.error(`enrivision: invalid ENRIVISION_DEFAULT_LANGUAGE ('${defaultLanguageRaw}'); ignoring it and using the server default. Use a code like 'es', 'en' or 'auto'.`);
|
|
172
|
+
}
|
|
173
|
+
const explicitLanguage = typeof params.language === "string" ? params.language.trim() : "";
|
|
174
|
+
// Precedence: explicit `language` > ENRIVISION_DEFAULT_LANGUAGE > server default.
|
|
175
|
+
const language = explicitLanguage
|
|
176
|
+
? explicitLanguage
|
|
177
|
+
: defaultLanguageValid
|
|
221
178
|
? defaultLanguageRaw
|
|
222
179
|
: undefined;
|
|
180
|
+
if (!explicitLanguage && defaultLanguageRaw && defaultLanguageValid && !isProgressQuiet()) {
|
|
181
|
+
console.error(`enrivision: effective response language '${defaultLanguageRaw}' (ENRIVISION_DEFAULT_LANGUAGE; the explicit 'language' parameter wins).`);
|
|
182
|
+
}
|
|
183
|
+
const envModelRaw = typeof process.env["ENRIVISION_MODEL"] === "string"
|
|
184
|
+
? process.env["ENRIVISION_MODEL"].trim()
|
|
185
|
+
: "";
|
|
186
|
+
const envModelValid = envModelRaw.length > 0 && envModelRaw.length <= 128;
|
|
187
|
+
if (envModelRaw && !envModelValid && !isProgressQuiet()) {
|
|
188
|
+
console.error(`enrivision: invalid ENRIVISION_MODEL (128 characters max); ignoring it and using auto-dispatch.`);
|
|
189
|
+
}
|
|
190
|
+
const requestedModel = typeof params.model === "string" && params.model.trim()
|
|
191
|
+
? params.model.trim()
|
|
192
|
+
: envModelValid
|
|
193
|
+
? envModelRaw
|
|
194
|
+
: undefined;
|
|
195
|
+
// Scale the unary analyze timeout by mode (mirrors EnriCode single 10 min
|
|
196
|
+
// / multipass+auto 20 min, matching the server wall-clock budgets): a
|
|
197
|
+
// single image must not retain a 30 min umbrella, while `auto` shares
|
|
198
|
+
// the multipass budget because the server may escalate to multipass and
|
|
199
|
+
// the client cannot know upfront. Explicit operator timeouts cap via
|
|
200
|
+
// Math.min.
|
|
201
|
+
const analyzeTimeoutMs = params.analysisMode === "single"
|
|
202
|
+
? Math.min(timeoutMs, ANALYZE_MEDIA_LIMITS.singleAnalyzeTimeoutMs)
|
|
203
|
+
: Math.min(timeoutMs, ANALYZE_MEDIA_LIMITS.multipassAnalyzeTimeoutMs);
|
|
223
204
|
const analysis = await client.analyze({
|
|
224
|
-
uploadId,
|
|
205
|
+
...(uploadId !== null ? { uploadId } : { sourceUrl: remoteUrl }),
|
|
206
|
+
...(requestedModel ? { model: requestedModel } : {}),
|
|
207
|
+
timeoutMs: analyzeTimeoutMs,
|
|
225
208
|
context: params.context,
|
|
226
209
|
question: params.question,
|
|
227
210
|
language,
|
|
@@ -233,367 +216,527 @@ export class AnalyzeMediaTool {
|
|
|
233
216
|
video: params.video,
|
|
234
217
|
document: params.document,
|
|
235
218
|
audio: params.audio,
|
|
236
|
-
images: params.images
|
|
219
|
+
images: params.images,
|
|
220
|
+
signal,
|
|
237
221
|
});
|
|
238
222
|
const extraction = this.stripInternalExtractionFields(analysis.extraction);
|
|
239
|
-
|
|
223
|
+
const result = {
|
|
240
224
|
analysis: analysis.analysis,
|
|
241
225
|
...(Array.isArray(analysis.elements) && analysis.elements.length > 0
|
|
242
|
-
? {
|
|
226
|
+
? {
|
|
227
|
+
elements: Object.freeze(analysis.elements.map((element) => Object.freeze({ ...element, box: Object.freeze({ ...element.box }) }))),
|
|
228
|
+
}
|
|
243
229
|
: {}),
|
|
244
230
|
media_type: analysis.media_type,
|
|
245
|
-
|
|
231
|
+
...((params.warnings && params.warnings.length > 0) || transcribeWarning || remoteAdvisoryWarnings.length > 0 || (analysis.warnings && analysis.warnings.length > 0)
|
|
232
|
+
? {
|
|
233
|
+
warnings: [
|
|
234
|
+
...(params.warnings ?? []),
|
|
235
|
+
...(transcribeWarning ? [transcribeWarning] : []),
|
|
236
|
+
...remoteAdvisoryWarnings,
|
|
237
|
+
...(analysis.warnings ?? []),
|
|
238
|
+
],
|
|
239
|
+
}
|
|
240
|
+
: {}),
|
|
241
|
+
extraction,
|
|
246
242
|
};
|
|
243
|
+
// Release server bytes on success too: upload sessions live ~3 h and
|
|
244
|
+
// count against the per-key quota, so a successful analysis must not
|
|
245
|
+
// leak its session (mirrors EnriCode bestEffortDeleteUploadSession).
|
|
246
|
+
await this.deleteUploadedBytesBestEffort(client, uploadId);
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
await this.deleteUploadedBytesBestEffort(client, uploadId);
|
|
251
|
+
throw error;
|
|
247
252
|
}
|
|
248
253
|
finally {
|
|
249
|
-
for (const fetched of materialized) {
|
|
250
|
-
|
|
254
|
+
for (const fetched of resolved.materialized) {
|
|
255
|
+
try {
|
|
256
|
+
await fetched.cleanup();
|
|
257
|
+
}
|
|
258
|
+
catch (cleanupError) {
|
|
259
|
+
if (!isProgressQuiet()) {
|
|
260
|
+
console.error(`enrivision: temp cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
251
263
|
}
|
|
252
264
|
}
|
|
253
265
|
}
|
|
254
266
|
/**
|
|
255
|
-
*
|
|
267
|
+
* Rejects a `region` zoom against non-image inputs before any upload.
|
|
256
268
|
*
|
|
257
269
|
* @remarks
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
270
|
+
* `region` zooms one image at native resolution: sending it with video,
|
|
271
|
+
* audio, PDF, or a tar set wastes an upload plus a server rejection (or
|
|
272
|
+
* a silent ignore). Fail locally in Spanish using the resolved effective
|
|
273
|
+
* content type (server-reported type wins, mirroring the resolver).
|
|
261
274
|
*
|
|
262
|
-
* @param
|
|
263
|
-
* @param
|
|
264
|
-
* @
|
|
275
|
+
* @param region - Validated region, or undefined when absent.
|
|
276
|
+
* @param inputs - Resolved upload-ready inputs.
|
|
277
|
+
* @throws Error with an Spanish-first bilingual message when `region` targets non-image media.
|
|
265
278
|
*/
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return detected;
|
|
279
|
+
rejectRegionForNonImage(region, inputs) {
|
|
280
|
+
if (!region || inputs.length !== 1) {
|
|
281
|
+
return;
|
|
270
282
|
}
|
|
271
|
-
|
|
272
|
-
|
|
283
|
+
const single = inputs[0];
|
|
284
|
+
if (!single.contentType.toLowerCase().startsWith("image/")) {
|
|
285
|
+
throw new Error(`region sólo aplica a imágenes; el archivo es ${single.contentType} (${single.localPath}). Omita 'region' para video, audio o documentos. / region only applies to images; the file is ${single.contentType} (${single.localPath}). Omit 'region' for video, audio, or documents.`);
|
|
273
286
|
}
|
|
274
|
-
return detected;
|
|
275
287
|
}
|
|
276
288
|
/**
|
|
277
|
-
*
|
|
278
|
-
* extraction payload before returning it to the model.
|
|
289
|
+
* Rejects tuning knobs that the resolved content type would silently ignore.
|
|
279
290
|
*
|
|
280
|
-
* @
|
|
281
|
-
*
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
*
|
|
291
|
+
* @remarks
|
|
292
|
+
* Pre-upload gate mirroring EnriCode `VisionAnalyzeMediaRequestRecords`:
|
|
293
|
+
* `video.*` tuning only applies to video, `images.*` only to images
|
|
294
|
+
* and documents (PDF page-image sets on the remote route), `document.*`
|
|
295
|
+
* only to PDF/Office/TXT/CSV, clip/`maxFramesPerSegment` never to audio,
|
|
296
|
+
* `video.segment_seconds`/`max_segments` never to audio without an audio
|
|
297
|
+
* section, and `audio.segment_seconds`/`max_segments` never to video
|
|
298
|
+
* without a video section (shared flats fan out to both sections by
|
|
299
|
+
* design and are exempt). Multi-image sets only accept `images.*`. The top-level
|
|
300
|
+
* `maxFrames` knob is media-agnostic by design (EnriCode declares
|
|
301
|
+
* `max_frames` "sin efecto en imágenes fijas o audio"): it never
|
|
302
|
+
* triggers a mismatch gate and is forwarded for the server to apply or
|
|
303
|
+
* ignore. Unknown content types (`application/octet-stream` included)
|
|
304
|
+
* skip every gate by design so the server (not this gate) decides
|
|
305
|
+
* applicability, mirroring EnriCode `VisionAnalyzeMediaRequestRecords`.
|
|
306
|
+
* Failing here saves the
|
|
307
|
+
* full upload plus a late server rejection.
|
|
289
308
|
*
|
|
290
|
-
* @param
|
|
291
|
-
* @
|
|
309
|
+
* @param params - Validated tool parameters.
|
|
310
|
+
* @param inputs - Resolved upload-ready inputs.
|
|
311
|
+
* @throws Error with an Spanish-first bilingual message when tuning mismatches the content type.
|
|
292
312
|
*/
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
313
|
+
rejectMismatchedTuning(params, inputs) {
|
|
314
|
+
// Top-level `maxFrames` is media-agnostic (EnriCode `max_frames` is
|
|
315
|
+
// "sin efecto en imágenes fijas o audio"): only the `video` section
|
|
316
|
+
// participates in the video-only gate.
|
|
317
|
+
const hasVideoTuning = typeof params.video !== "undefined";
|
|
318
|
+
const hasDocumentTuning = typeof params.document !== "undefined";
|
|
319
|
+
const hasImagesTuning = typeof params.images !== "undefined";
|
|
320
|
+
const hasAudioTuning = typeof params.audio !== "undefined";
|
|
321
|
+
if (!hasVideoTuning && !hasDocumentTuning && !hasImagesTuning && !hasAudioTuning) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (inputs.length > 1) {
|
|
325
|
+
if (hasVideoTuning || hasDocumentTuning || hasAudioTuning) {
|
|
326
|
+
throw new Error("El afinado video/document/audio solo aplica a archivos individuales y se habría ignorado en el conjunto de imágenes: quite esos parámetros o use images.* para conjuntos. / video/document/audio tuning only applies to single files and would have been ignored on the image set: drop those params or use images.* for sets.");
|
|
305
327
|
}
|
|
306
|
-
|
|
307
|
-
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const single = inputs[0];
|
|
331
|
+
if (!single) {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const normalized = single.contentType.trim().toLowerCase();
|
|
335
|
+
const isImage = normalized.startsWith("image/");
|
|
336
|
+
const isVideo = normalized.startsWith("video/");
|
|
337
|
+
const isAudio = normalized.startsWith("audio/");
|
|
338
|
+
const isDocument = isDocumentContentType(normalized);
|
|
339
|
+
// Unknown content types (empty, `application/octet-stream`, or any
|
|
340
|
+
// non-media guess) skip every mismatch gate by design: the server (not
|
|
341
|
+
// this gate) decides applicability. Mirrors EnriCode
|
|
342
|
+
// `VisionAnalyzeMediaRequestRecords` ("Unknown content types ...
|
|
343
|
+
// forward guarded knobs untouched ... so the server decides").
|
|
344
|
+
if (normalized === "" || (!isImage && !isVideo && !isAudio && !isDocument)) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (hasDocumentTuning && !isDocument) {
|
|
348
|
+
throw new Error(`El afinado 'document.*' solo aplica a PDF y documentos de Office/TXT/CSV y se habría ignorado en silencio: quite esos parámetros o use un documento (el archivo es ${single.contentType}). / 'document.*' tuning only applies to PDF and Office/TXT/CSV documents and would have been silently ignored: drop those params or use a document (the file is ${single.contentType}).`);
|
|
349
|
+
}
|
|
350
|
+
if (hasImagesTuning && (isVideo || isAudio)) {
|
|
351
|
+
throw new Error(`El afinado 'images.*' solo aplica a imágenes y documentos y se habría ignorado en silencio: quite esos parámetros o use un archivo de imagen o documento (el archivo es ${single.contentType}). / 'images.*' tuning only applies to images and documents and would have been silently ignored: drop those params or use an image or document file (the file is ${single.contentType}).`);
|
|
352
|
+
}
|
|
353
|
+
if (hasVideoTuning && (isImage || isDocument)) {
|
|
354
|
+
throw new Error(`El afinado de video (clip, segmentSeconds, maxSegments, maxFramesPerSegment) solo aplica a video y se habría ignorado en silencio: quite esos parámetros o use un archivo de video (el archivo es ${single.contentType}). / Video tuning (clip, segmentSeconds, maxSegments, maxFramesPerSegment) only applies to video and would have been silently ignored: drop those params or use a video file (the file is ${single.contentType}).`);
|
|
355
|
+
}
|
|
356
|
+
// Wrong-section scalar knobs are only rejected when the matching section
|
|
357
|
+
// is absent: shared top-level flats fan out to BOTH sections by design
|
|
358
|
+
// (the server applies the media-matching one), so a video+audio pair
|
|
359
|
+
// with equal segment values is the flat fan-out, never a silent drop.
|
|
360
|
+
if (isAudio && typeof params.video !== "undefined") {
|
|
361
|
+
const video = params.video;
|
|
362
|
+
const audioSegmentActive = typeof params.audio?.segmentSeconds !== "undefined"
|
|
363
|
+
|| typeof params.audio?.maxSegments !== "undefined";
|
|
364
|
+
if (typeof video.clipStartSeconds !== "undefined"
|
|
365
|
+
|| typeof video.clipDurationSeconds !== "undefined"
|
|
366
|
+
|| typeof video.maxFramesPerSegment !== "undefined") {
|
|
367
|
+
throw new Error(`El afinado de video (clip o maxFramesPerSegment) no aplica a audio y se habría ignorado en silencio: quite clip/maxFramesPerSegment o use un archivo de video (el archivo es ${single.contentType}). / Video tuning (clip or maxFramesPerSegment) does not apply to audio and would have been silently ignored: drop clip/maxFramesPerSegment or use a video file (the file is ${single.contentType}).`);
|
|
308
368
|
}
|
|
309
|
-
if (
|
|
310
|
-
|
|
369
|
+
if (!audioSegmentActive
|
|
370
|
+
&& (typeof video.segmentSeconds !== "undefined" || typeof video.maxSegments !== "undefined")) {
|
|
371
|
+
throw new Error(`El afinado video.segment_seconds/max_segments no aplica a audio y se habría ignorado en silencio: use audio.segment_seconds/max_segments o los planos segmentSeconds/maxSegments (el archivo es ${single.contentType}). / video.segment_seconds/max_segments tuning does not apply to audio and would have been silently ignored: use audio.segment_seconds/max_segments or the flat segmentSeconds/maxSegments (the file is ${single.contentType}).`);
|
|
311
372
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
373
|
+
}
|
|
374
|
+
if (isVideo && typeof params.audio !== "undefined") {
|
|
375
|
+
const audio = params.audio;
|
|
376
|
+
const videoSegmentActive = typeof params.video?.segmentSeconds !== "undefined"
|
|
377
|
+
|| typeof params.video?.maxSegments !== "undefined";
|
|
378
|
+
if (!videoSegmentActive
|
|
379
|
+
&& (typeof audio.segmentSeconds !== "undefined" || typeof audio.maxSegments !== "undefined")) {
|
|
380
|
+
throw new Error(`El afinado audio.segment_seconds/max_segments no aplica a video y se habría ignorado en silencio: use video.segment_seconds/max_segments o los planos segmentSeconds/maxSegments (el archivo es ${single.contentType}). / audio.segment_seconds/max_segments tuning does not apply to video and would have been silently ignored: use video.segment_seconds/max_segments or the flat segmentSeconds/maxSegments (the file is ${single.contentType}).`);
|
|
317
381
|
}
|
|
318
|
-
next[key] = this.stripInternalFields(child);
|
|
319
382
|
}
|
|
320
|
-
return next;
|
|
321
383
|
}
|
|
322
384
|
/**
|
|
323
|
-
*
|
|
385
|
+
* Resolves the requested server-side model id for dispatch affinity.
|
|
324
386
|
*
|
|
325
|
-
* @
|
|
326
|
-
*
|
|
387
|
+
* @remarks
|
|
388
|
+
* Pure read of `params.model` plus the validated `ENRIVISION_MODEL` env
|
|
389
|
+
* fallback (no operator logging): used by the pre-upload vision probe,
|
|
390
|
+
* which runs before the main model-resolution block in `execute`.
|
|
391
|
+
*
|
|
392
|
+
* @param params - Validated tool parameters.
|
|
393
|
+
* @returns Trimmed model id, or undefined for auto-dispatch.
|
|
327
394
|
*/
|
|
328
|
-
|
|
329
|
-
if (
|
|
330
|
-
return
|
|
395
|
+
resolveRequestedModel(params) {
|
|
396
|
+
if (typeof params.model === "string" && params.model.trim()) {
|
|
397
|
+
return params.model.trim();
|
|
331
398
|
}
|
|
332
|
-
|
|
399
|
+
const envModelRaw = typeof process.env["ENRIVISION_MODEL"] === "string"
|
|
400
|
+
? process.env["ENRIVISION_MODEL"].trim()
|
|
401
|
+
: "";
|
|
402
|
+
if (envModelRaw.length > 0 && envModelRaw.length <= 128) {
|
|
403
|
+
return envModelRaw;
|
|
404
|
+
}
|
|
405
|
+
return undefined;
|
|
333
406
|
}
|
|
334
407
|
/**
|
|
335
|
-
*
|
|
408
|
+
* Fails fast when the requested model explicitly lacks vision, before any byte.
|
|
336
409
|
*
|
|
337
|
-
* @
|
|
338
|
-
|
|
339
|
-
*
|
|
410
|
+
* @remarks
|
|
411
|
+
* Mirrors EnriCode `assertRemoteVisionCapable`: probes `GET
|
|
412
|
+
* `/v1/account/models` (15 s budget inside the client) and matches the
|
|
413
|
+
* requested model by `id`, `requestModelId`, or canonical ids. Only an
|
|
414
|
+
* explicit `vision === false` rejects; unknown models, missing flags, and
|
|
415
|
+
* probe failures stay fail-open so a stale discovery snapshot never blocks
|
|
416
|
+
* a valid upload. Verdicts are cached per client for 5 min; 401/404 probe
|
|
417
|
+
* failures invalidate the entry so revoked keys never ride a stale verdict.
|
|
418
|
+
* Clients without `getAccountModels` (older stubs) skip the probe entirely.
|
|
340
419
|
*
|
|
341
|
-
* @param
|
|
342
|
-
* @
|
|
420
|
+
* @param client - EnriProxy client used for the upload.
|
|
421
|
+
* @param requestedModel - Trimmed model id, or undefined for auto-dispatch.
|
|
422
|
+
* @param signal - Optional cancellation signal.
|
|
423
|
+
* @throws Error with an Spanish-first bilingual message when the model explicitly lacks vision.
|
|
343
424
|
*/
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
425
|
+
async assertVisionCapable(client, requestedModel, signal) {
|
|
426
|
+
const modelId = typeof requestedModel === "string" ? requestedModel.trim() : "";
|
|
427
|
+
if (!modelId || signal?.aborted) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const probe = client;
|
|
431
|
+
if (typeof probe.getAccountModels !== "function") {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
const now = Date.now();
|
|
435
|
+
const cacheKey = `${this.deps.defaultServerUrl}::${modelId}`;
|
|
436
|
+
const cached = this.visionProbeCache.get(cacheKey);
|
|
437
|
+
if (cached !== undefined && cached.expiresAt > now) {
|
|
438
|
+
if (!cached.verdict) {
|
|
439
|
+
throw AnalyzeMediaTool.buildVisionlessModelError(modelId);
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
347
442
|
}
|
|
348
|
-
if (
|
|
349
|
-
|
|
443
|
+
if (cached !== undefined) {
|
|
444
|
+
this.visionProbeCache.delete(cacheKey);
|
|
445
|
+
}
|
|
446
|
+
let payload;
|
|
447
|
+
try {
|
|
448
|
+
payload = await probe.getAccountModels.call(client, signal);
|
|
350
449
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const parsed = typeof value === "number" ? value : Number(value);
|
|
355
|
-
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
|
|
356
|
-
throw new Error(`region.${fieldName} debe ser un número entre 0 y 1 (coordenadas relativas a la imagen original). Use las cajas de 'elements' de un análisis previo.`);
|
|
450
|
+
catch (error) {
|
|
451
|
+
if (error instanceof EnriProxyHttpError && (error.status === 401 || error.status === 404)) {
|
|
452
|
+
this.visionProbeCache.delete(cacheKey);
|
|
357
453
|
}
|
|
358
|
-
return
|
|
359
|
-
}
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
throw new Error("region.width y region.height deben ser mayores que 0.");
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const entry = findAccountModelEntry(payload, modelId);
|
|
457
|
+
if (entry === null) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (entry.vision === false) {
|
|
461
|
+
this.rememberVisionProbe(cacheKey, false, now);
|
|
462
|
+
throw AnalyzeMediaTool.buildVisionlessModelError(modelId);
|
|
368
463
|
}
|
|
369
|
-
|
|
464
|
+
this.rememberVisionProbe(cacheKey, true, now);
|
|
370
465
|
}
|
|
371
466
|
/**
|
|
372
|
-
*
|
|
467
|
+
* Maximum vision-probe cache entries (model ids are caller-controlled).
|
|
468
|
+
*/
|
|
469
|
+
static MAX_VISION_PROBE_ENTRIES = 100;
|
|
470
|
+
/**
|
|
471
|
+
* Remembers one vision-probe verdict, purging expired entries and
|
|
472
|
+
* evicting oldest-first past the cap so long-lived tool instances
|
|
473
|
+
* cannot leak one entry per distinct model id.
|
|
373
474
|
*
|
|
374
|
-
* @param
|
|
375
|
-
* @
|
|
475
|
+
* @param cacheKey - Server plus model cache key.
|
|
476
|
+
* @param verdict - Whether the model has vision.
|
|
477
|
+
* @param now - Current epoch milliseconds.
|
|
478
|
+
* @returns Nothing.
|
|
376
479
|
*/
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (!st.isFile()) {
|
|
383
|
-
throw new Error(`No es un archivo: ${filePath}`);
|
|
384
|
-
}
|
|
385
|
-
// Ensure the file is readable.
|
|
386
|
-
const handle = await open(filePath, "r");
|
|
387
|
-
try {
|
|
388
|
-
return st.size;
|
|
480
|
+
rememberVisionProbe(cacheKey, verdict, now) {
|
|
481
|
+
for (const [key, entry] of this.visionProbeCache) {
|
|
482
|
+
if (entry.expiresAt <= now) {
|
|
483
|
+
this.visionProbeCache.delete(key);
|
|
484
|
+
}
|
|
389
485
|
}
|
|
390
|
-
|
|
391
|
-
|
|
486
|
+
while (this.visionProbeCache.size >= AnalyzeMediaTool.MAX_VISION_PROBE_ENTRIES) {
|
|
487
|
+
const oldest = this.visionProbeCache.keys().next().value;
|
|
488
|
+
if (oldest === undefined) {
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
this.visionProbeCache.delete(oldest);
|
|
392
492
|
}
|
|
493
|
+
this.visionProbeCache.set(cacheKey, { verdict, expiresAt: now + ANALYZE_MEDIA_LIMITS.visionProbeCacheTtlMs });
|
|
393
494
|
}
|
|
394
495
|
/**
|
|
395
|
-
*
|
|
496
|
+
* Builds the visionless-model rejection error.
|
|
396
497
|
*
|
|
397
|
-
* @param
|
|
398
|
-
* @returns
|
|
498
|
+
* @param modelId - Requested model id.
|
|
499
|
+
* @returns Spanish-first bilingual error (no byte was uploaded).
|
|
399
500
|
*/
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (typeof detected === "string" && detected.trim()) {
|
|
403
|
-
return detected.trim();
|
|
404
|
-
}
|
|
405
|
-
return "application/octet-stream";
|
|
501
|
+
static buildVisionlessModelError(modelId) {
|
|
502
|
+
return new Error(`El modelo ${modelId} no tiene capacidad de visión (vision=false en /v1/account/models). Elige un modelo con visión o usa la ruta local; no se subió ningún byte. / Model ${modelId} has no vision capability (vision=false in /v1/account/models). Pick a vision-capable model or use the local route; no bytes were uploaded.`);
|
|
406
503
|
}
|
|
407
504
|
/**
|
|
408
|
-
*
|
|
505
|
+
* Executes one truncated-list continuation read (no upload, no analysis).
|
|
506
|
+
*
|
|
507
|
+
* @param client - EnriProxy client.
|
|
508
|
+
* @param cursor - Opaque cursor from a truncated response.
|
|
509
|
+
* @param offset - Start index, or undefined for the stored next offset.
|
|
510
|
+
* @param signal - Optional cancellation signal.
|
|
511
|
+
* @returns Tool result carrying the page plus chaining state.
|
|
512
|
+
*/
|
|
513
|
+
async executeContinuation(client, cursor, offset, signal) {
|
|
514
|
+
const page = await client.fetchSegmentPage({
|
|
515
|
+
cursor,
|
|
516
|
+
...(typeof offset === "number" ? { offset } : {}),
|
|
517
|
+
...(typeof signal !== "undefined" ? { signal } : {}),
|
|
518
|
+
});
|
|
519
|
+
const lines = page.entries.map((entry) => {
|
|
520
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
521
|
+
return `- ${String(entry ?? "")}`;
|
|
522
|
+
}
|
|
523
|
+
const record = entry;
|
|
524
|
+
const text = record["summary"] ?? record["text"];
|
|
525
|
+
const start = record["start_seconds"] ?? record["start"];
|
|
526
|
+
const end = record["end_seconds"] ?? record["end"];
|
|
527
|
+
const span = typeof start === "number" && typeof end === "number"
|
|
528
|
+
? ` [${start.toFixed(2)}-${end.toFixed(2)}s]`
|
|
529
|
+
: "";
|
|
530
|
+
return `- ${String(text ?? JSON.stringify(entry) ?? "")}${span}`;
|
|
531
|
+
});
|
|
532
|
+
const end = page.nextOffset;
|
|
533
|
+
const start = Math.max(0, end - lines.length);
|
|
534
|
+
const header = `Continuación: entradas ${start}-${end} de ${page.total} / Continuation: entries ${start}-${end} of ${page.total}.`;
|
|
535
|
+
const tail = page.hasMore
|
|
536
|
+
? `Quedan ${page.total - end} entradas: pide más con cursor "${page.cursor}" y offset ${end}. / ${page.total - end} entries remain: ask for more with cursor "${page.cursor}" and offset ${end}.`
|
|
537
|
+
: `No quedan más entradas. / No more entries.`;
|
|
538
|
+
return {
|
|
539
|
+
analysis: `${header}\n${lines.join("\n")}\n${tail}`,
|
|
540
|
+
media_type: "continuation",
|
|
541
|
+
warnings: [],
|
|
542
|
+
extraction: {
|
|
543
|
+
continuation: true,
|
|
544
|
+
total: page.total,
|
|
545
|
+
has_more: page.hasMore,
|
|
546
|
+
next_offset: page.nextOffset,
|
|
547
|
+
cursor: page.cursor,
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Warns when `transcribe` is set for media it cannot affect.
|
|
409
553
|
*
|
|
410
554
|
* @remarks
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
555
|
+
* Mirrors EnriCode `LocalMediaAnalysisService` (transcribe is inapplicable
|
|
556
|
+
* for images and documents): instead of failing, the knob is forwarded
|
|
557
|
+
* and declared as a bilingual honesty warning so the model never assumes
|
|
558
|
+
* audio was transcribed. Videos and audio files never warn.
|
|
414
559
|
*
|
|
415
|
-
* @param
|
|
416
|
-
* @param inputs - Resolved
|
|
417
|
-
* @
|
|
418
|
-
* @param clientTraceId - Client trace id for correlation
|
|
419
|
-
* @returns Upload id for the created tar session
|
|
560
|
+
* @param params - Validated tool parameters.
|
|
561
|
+
* @param inputs - Resolved upload-ready inputs.
|
|
562
|
+
* @returns Bilingual warning, or undefined when `transcribe` may apply.
|
|
420
563
|
*/
|
|
421
|
-
|
|
422
|
-
if (
|
|
423
|
-
|
|
424
|
-
}
|
|
425
|
-
const files = [];
|
|
426
|
-
for (let i = 0; i < inputs.length; i += 1) {
|
|
427
|
-
const input = inputs[i];
|
|
428
|
-
const sizeBytes = await this.assertReadableFile(input.localPath);
|
|
429
|
-
const filename = basename(input.localPath);
|
|
430
|
-
const contentType = this.resolveEffectiveContentType(input.localPath, input.urlContentType);
|
|
431
|
-
if (!contentType.toLowerCase().startsWith("image/")) {
|
|
432
|
-
throw new Error(`paths debe contener sólo archivos de imagen. No es imagen: ${input.localPath} (${contentType})`);
|
|
433
|
-
}
|
|
434
|
-
const extRaw = extname(filename).toLowerCase();
|
|
435
|
-
const ext = extRaw && /^[a-z0-9.]+$/.test(extRaw) ? extRaw : ".img";
|
|
436
|
-
const entryName = `${String(i + 1).padStart(6, "0")}${ext}`;
|
|
437
|
-
files.push({
|
|
438
|
-
index: i + 1,
|
|
439
|
-
path: input.localPath,
|
|
440
|
-
filename,
|
|
441
|
-
sizeBytes,
|
|
442
|
-
contentType,
|
|
443
|
-
entryName
|
|
444
|
-
});
|
|
564
|
+
transcribeInapplicableWarning(params, inputs) {
|
|
565
|
+
if (typeof params.transcribe === "undefined") {
|
|
566
|
+
return undefined;
|
|
445
567
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
media_type: "image_set",
|
|
450
|
-
items: files.map((f) => ({
|
|
451
|
-
index: f.index,
|
|
452
|
-
name: f.entryName,
|
|
453
|
-
filename: f.filename,
|
|
454
|
-
content_type: f.contentType,
|
|
455
|
-
size_bytes: f.sizeBytes
|
|
456
|
-
}))
|
|
457
|
-
};
|
|
458
|
-
const manifestBuffer = Buffer.from(JSON.stringify(manifest), "utf8");
|
|
459
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
460
|
-
const entries = [
|
|
461
|
-
{
|
|
462
|
-
name: ENRIVISION_MEDIA_SET_TAR_MANIFEST_NAME,
|
|
463
|
-
source: { type: "buffer", buffer: manifestBuffer },
|
|
464
|
-
mtimeSeconds: nowSeconds
|
|
465
|
-
},
|
|
466
|
-
...files.map((f) => ({
|
|
467
|
-
name: f.entryName,
|
|
468
|
-
source: { type: "file", path: f.path, sizeBytes: f.sizeBytes },
|
|
469
|
-
mtimeSeconds: nowSeconds
|
|
470
|
-
}))
|
|
471
|
-
];
|
|
472
|
-
const tarSizeBytes = computeTarSizeBytes(entries);
|
|
473
|
-
const tar = new TarStream(entries);
|
|
474
|
-
if (tar.getSizeBytes() !== tarSizeBytes) {
|
|
475
|
-
throw new Error("Internal error: tar size mismatch.");
|
|
476
|
-
}
|
|
477
|
-
const session = await client.createUploadSession({
|
|
478
|
-
filename: "enrivision-image-set.tar",
|
|
479
|
-
sizeBytes: tarSizeBytes,
|
|
480
|
-
contentType: ENRIVISION_MEDIA_SET_TAR_CONTENT_TYPE,
|
|
481
|
-
clientTraceId
|
|
482
|
-
});
|
|
483
|
-
const chunkSize = Math.max(1, Math.floor(session.chunk_size_bytes));
|
|
484
|
-
let offset = await client.getUploadOffset(session.upload_id);
|
|
485
|
-
if (offset < 0 || offset > tarSizeBytes) {
|
|
486
|
-
throw new Error(`Invalid server offset for tar upload: ${offset}`);
|
|
487
|
-
}
|
|
488
|
-
while (offset < tarSizeBytes) {
|
|
489
|
-
let madeProgress = false;
|
|
490
|
-
for await (const chunk of tar.iterateChunks(offset, chunkSize)) {
|
|
491
|
-
if (chunk.length === 0) {
|
|
492
|
-
continue;
|
|
493
|
-
}
|
|
494
|
-
const expectedOffset = offset;
|
|
495
|
-
const nextOffset = await this.uploadChunkWithRetry(client, session.upload_id, expectedOffset, chunk, timeoutMs);
|
|
496
|
-
offset = nextOffset;
|
|
497
|
-
madeProgress = true;
|
|
498
|
-
const progress = Math.floor((offset / tarSizeBytes) * 100);
|
|
499
|
-
console.error(`enrivision: upload ${progress}% (${offset}/${tarSizeBytes} bytes)`);
|
|
500
|
-
// Offset resync: restart generation from the server-provided offset.
|
|
501
|
-
if (offset !== expectedOffset + chunk.length) {
|
|
502
|
-
break;
|
|
503
|
-
}
|
|
504
|
-
if (offset >= tarSizeBytes) {
|
|
505
|
-
break;
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
if (!madeProgress) {
|
|
509
|
-
throw new Error("Subida estancada: no hubo progreso al enviar los fragmentos del tar.");
|
|
510
|
-
}
|
|
568
|
+
if (inputs.length > 1) {
|
|
569
|
+
return ("transcribe no tiene efecto en conjuntos de varias imágenes y se ignora. / " +
|
|
570
|
+
"transcribe has no effect on multi-image sets and is ignored.");
|
|
511
571
|
}
|
|
512
|
-
|
|
513
|
-
|
|
572
|
+
const single = inputs[0];
|
|
573
|
+
if (!single) {
|
|
574
|
+
return undefined;
|
|
514
575
|
}
|
|
515
|
-
|
|
576
|
+
const normalized = single.contentType.trim().toLowerCase();
|
|
577
|
+
if (normalized.startsWith("image/")) {
|
|
578
|
+
return ("transcribe no tiene efecto en imágenes y se ignora. / " +
|
|
579
|
+
"transcribe has no effect on images and is ignored.");
|
|
580
|
+
}
|
|
581
|
+
if (isDocumentContentType(normalized)) {
|
|
582
|
+
return ("transcribe no tiene efecto en documentos y se ignora. / " +
|
|
583
|
+
"transcribe has no effect on documents and is ignored.");
|
|
584
|
+
}
|
|
585
|
+
return undefined;
|
|
516
586
|
}
|
|
517
587
|
/**
|
|
518
|
-
*
|
|
588
|
+
* Runs the mismatch gates on the remote (`source_url`) branch as advisories.
|
|
589
|
+
*
|
|
590
|
+
* @remarks
|
|
591
|
+
* The local gates no-op on the empty remote input list by design (the
|
|
592
|
+
* server, which sees the served type, decides applicability). When the URL
|
|
593
|
+
* extension guesses a known media type, the same gates run against the
|
|
594
|
+
* guess and likely-ignored tuning is declared as bilingual warnings
|
|
595
|
+
* instead of errors; URLs with no usable extension stay server-decides
|
|
596
|
+
* with no warnings.
|
|
519
597
|
*
|
|
520
|
-
* @param
|
|
521
|
-
* @param
|
|
522
|
-
* @
|
|
523
|
-
* @param session - Server-created session
|
|
524
|
-
* @param timeoutMs - Request timeout in milliseconds
|
|
525
|
-
* @returns Final offset
|
|
598
|
+
* @param params - Validated tool parameters.
|
|
599
|
+
* @param remoteUrl - Remote http(s) URL the server will ingest.
|
|
600
|
+
* @returns Advisory warnings (empty when nothing looks mismatched).
|
|
526
601
|
*/
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
602
|
+
advisoryRemoteTuningWarnings(params, remoteUrl) {
|
|
603
|
+
const guessed = mimeLookup(remoteUrl.split(/[?#]/)[0] ?? remoteUrl);
|
|
604
|
+
if (typeof guessed !== "string") {
|
|
605
|
+
return [];
|
|
606
|
+
}
|
|
607
|
+
const contentType = guessed.trim().toLowerCase();
|
|
608
|
+
if (!MediaUrlFetcher.isAllowedMediaContentType(contentType)) {
|
|
609
|
+
return [];
|
|
610
|
+
}
|
|
611
|
+
const advisories = [];
|
|
612
|
+
const wrapAdvisory = (detail) => `La URL remota parece ${contentType} por extensión, así que el servidor podría ignorar este afinado (solo aviso; el servidor decide): ${detail} / ` +
|
|
613
|
+
`Remote URL looks like ${contentType} by extension, so this tuning may be ignored by the server (advisory only; the server decides): ${detail}`;
|
|
530
614
|
try {
|
|
531
|
-
|
|
532
|
-
while (offset < fileSize) {
|
|
533
|
-
const remaining = fileSize - offset;
|
|
534
|
-
const nextSize = Math.min(chunkSize, remaining);
|
|
535
|
-
const buffer = Buffer.allocUnsafe(nextSize);
|
|
536
|
-
const read = await handle.read(buffer, 0, nextSize, offset);
|
|
537
|
-
if (read.bytesRead <= 0) {
|
|
538
|
-
break;
|
|
539
|
-
}
|
|
540
|
-
const chunk = read.bytesRead === buffer.length ? buffer : buffer.subarray(0, read.bytesRead);
|
|
541
|
-
offset = await this.uploadChunkWithRetry(client, session.upload_id, offset, chunk, timeoutMs);
|
|
542
|
-
const progress = Math.floor((offset / fileSize) * 100);
|
|
543
|
-
console.error(`enrivision: upload ${progress}% (${offset}/${fileSize} bytes)`);
|
|
544
|
-
}
|
|
545
|
-
return offset;
|
|
615
|
+
this.rejectMismatchedTuning(params, [{ localPath: remoteUrl, contentType }]);
|
|
546
616
|
}
|
|
547
|
-
|
|
548
|
-
|
|
617
|
+
catch (error) {
|
|
618
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
619
|
+
advisories.push(wrapAdvisory(detail));
|
|
620
|
+
}
|
|
621
|
+
try {
|
|
622
|
+
this.rejectRegionForNonImage(params.region, [{ localPath: remoteUrl, contentType }]);
|
|
549
623
|
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
626
|
+
advisories.push(wrapAdvisory(detail));
|
|
627
|
+
}
|
|
628
|
+
const transcribeWarning = this.transcribeInapplicableWarning(params, [{ localPath: remoteUrl, contentType }]);
|
|
629
|
+
if (typeof transcribeWarning !== "undefined") {
|
|
630
|
+
advisories.push(wrapAdvisory(transcribeWarning));
|
|
631
|
+
}
|
|
632
|
+
return advisories;
|
|
550
633
|
}
|
|
551
634
|
/**
|
|
552
|
-
*
|
|
635
|
+
* Releases uploaded bytes when the analysis fails or is cancelled.
|
|
636
|
+
*
|
|
637
|
+
* @remarks
|
|
638
|
+
* Best-effort by design: it runs on an independent 15 s signal (the
|
|
639
|
+
* caller signal may already be cancelled) and never throws, so the
|
|
640
|
+
* original upload/analysis error always reaches the model unchanged.
|
|
553
641
|
*
|
|
554
|
-
* @param client - EnriProxy client
|
|
555
|
-
* @param uploadId - Upload id
|
|
556
|
-
* @param offset - Expected offset
|
|
557
|
-
* @param chunk - Chunk bytes
|
|
558
|
-
* @param timeoutMs - Timeout in ms
|
|
559
|
-
* @returns New offset
|
|
642
|
+
* @param client - EnriProxy client used for the upload.
|
|
643
|
+
* @param uploadId - Upload id to release, or null when nothing was uploaded.
|
|
560
644
|
*/
|
|
561
|
-
async
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
645
|
+
async deleteUploadedBytesBestEffort(client, uploadId) {
|
|
646
|
+
if (!uploadId || typeof client.deleteUploadSession !== "function") {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
try {
|
|
650
|
+
await client.deleteUploadSession(uploadId, AbortSignal.timeout(15_000));
|
|
651
|
+
}
|
|
652
|
+
catch (cleanupError) {
|
|
653
|
+
// A second delete after an already-handled deadline/identity branch
|
|
654
|
+
// 404s: that is the expected terminal state, not noise.
|
|
655
|
+
const status = cleanupError?.status;
|
|
656
|
+
const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
657
|
+
if (status === 404 || /HTTP 404/u.test(message)) {
|
|
658
|
+
return;
|
|
566
659
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
if (error instanceof EnriProxyHttpError) {
|
|
570
|
-
// Offset mismatch: resync and let the caller re-read at the correct offset.
|
|
571
|
-
if (error.status === 409) {
|
|
572
|
-
const actual = await client.getUploadOffset(uploadId);
|
|
573
|
-
if (actual !== offset) {
|
|
574
|
-
console.error(`enrivision: offset resync (${offset} -> ${actual})`);
|
|
575
|
-
return actual;
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
// Do not retry on client errors (except 409 which is handled above).
|
|
579
|
-
if (error.status === 400 ||
|
|
580
|
-
error.status === 401 ||
|
|
581
|
-
error.status === 403 ||
|
|
582
|
-
error.status === 404 ||
|
|
583
|
-
error.status === 410 ||
|
|
584
|
-
error.status === 413) {
|
|
585
|
-
throw error;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
if (attempt === maxAttempts) {
|
|
589
|
-
throw error;
|
|
590
|
-
}
|
|
591
|
-
const backoffMs = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
|
|
592
|
-
console.error(`enrivision: retry ${attempt}/${maxAttempts} after ${backoffMs}ms (${message})`);
|
|
593
|
-
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
|
660
|
+
if (!isProgressQuiet()) {
|
|
661
|
+
console.error(`enrivision: could not release upload ${uploadId}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
594
662
|
}
|
|
595
663
|
}
|
|
596
|
-
throw new Error("La subida falló tras los reintentos.");
|
|
597
664
|
}
|
|
665
|
+
/**
|
|
666
|
+
* Removes internal identifiers from the extraction payload.
|
|
667
|
+
*
|
|
668
|
+
* @param extraction - Raw extraction object returned by EnriProxy.
|
|
669
|
+
* @returns Sanitized extraction object.
|
|
670
|
+
*/
|
|
671
|
+
stripInternalExtractionFields(extraction) {
|
|
672
|
+
return this.sanitizer.sanitize(extraction);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Reports whether one normalized content type is a document type.
|
|
677
|
+
*
|
|
678
|
+
* @remarks
|
|
679
|
+
* Shared by the mismatch gate and the `transcribe` inapplicability warning
|
|
680
|
+
* so both agree on what counts as a document (PDF, Office, TXT/CSV, RTF).
|
|
681
|
+
*
|
|
682
|
+
* @param normalizedContentType - Lowercased content type.
|
|
683
|
+
* @returns True for document types.
|
|
684
|
+
*/
|
|
685
|
+
function isDocumentContentType(normalizedContentType) {
|
|
686
|
+
return (normalizedContentType === "application/pdf"
|
|
687
|
+
|| normalizedContentType.includes("wordprocessing")
|
|
688
|
+
|| normalizedContentType.includes("msword")
|
|
689
|
+
|| normalizedContentType.includes("spreadsheet")
|
|
690
|
+
|| normalizedContentType.includes("excel")
|
|
691
|
+
|| normalizedContentType.includes("presentation")
|
|
692
|
+
|| normalizedContentType.includes("powerpoint")
|
|
693
|
+
|| normalizedContentType.includes("oasis.opendocument")
|
|
694
|
+
|| normalizedContentType === "text/plain"
|
|
695
|
+
|| normalizedContentType === "text/csv"
|
|
696
|
+
|| normalizedContentType.includes("rtf"));
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Finds one account-model entry matching a requested model id.
|
|
700
|
+
*
|
|
701
|
+
* @remarks
|
|
702
|
+
* Mirrors EnriCode `assertRemoteVisionCapable` matching: the entry matches
|
|
703
|
+
* on `id`, `requestModelId`/`request_model_id`, or any canonical id
|
|
704
|
+
* (`canonicalIds`/`canonical_ids`). Non-object payloads, missing `data`,
|
|
705
|
+
* and non-array `data` yield null (fail-open: the caller proceeds).
|
|
706
|
+
*
|
|
707
|
+
* @param payload - Parsed `/v1/account/models` body.
|
|
708
|
+
* @param modelId - Trimmed requested model id.
|
|
709
|
+
* @returns Matched entry, or null when no entry matches.
|
|
710
|
+
*/
|
|
711
|
+
function findAccountModelEntry(payload, modelId) {
|
|
712
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
713
|
+
return null;
|
|
714
|
+
}
|
|
715
|
+
const data = payload["data"];
|
|
716
|
+
if (!Array.isArray(data)) {
|
|
717
|
+
return null;
|
|
718
|
+
}
|
|
719
|
+
for (const candidate of data) {
|
|
720
|
+
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const record = candidate;
|
|
724
|
+
const ids = [
|
|
725
|
+
record["id"],
|
|
726
|
+
record["requestModelId"],
|
|
727
|
+
record["request_model_id"],
|
|
728
|
+
];
|
|
729
|
+
const canonical = record["canonicalIds"] ?? record["canonical_ids"];
|
|
730
|
+
if (Array.isArray(canonical)) {
|
|
731
|
+
ids.push(...canonical);
|
|
732
|
+
}
|
|
733
|
+
const matches = ids.some((id) => typeof id === "string" && id.trim() === modelId);
|
|
734
|
+
if (!matches) {
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const vision = record["vision"];
|
|
738
|
+
return { vision: typeof vision === "boolean" ? vision : undefined };
|
|
739
|
+
}
|
|
740
|
+
return null;
|
|
598
741
|
}
|
|
599
742
|
//# sourceMappingURL=AnalyzeMediaTool.js.map
|