@bedolla/enrivision 0.1.5 → 0.1.7
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 +43 -9
- package/dist/client/EnriProxyClient.d.ts +296 -248
- package/dist/client/EnriProxyClient.d.ts.map +1 -1
- package/dist/client/EnriProxyClient.js +849 -119
- package/dist/client/EnriProxyClient.js.map +1 -1
- package/dist/client/EnriProxyClientContract.d.ts +478 -0
- package/dist/client/EnriProxyClientContract.d.ts.map +1 -0
- package/dist/client/EnriProxyClientContract.js +136 -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 +804 -94
- 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 +462 -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 +307 -0
- package/dist/tools/AnalyzeMediaParamParser.d.ts.map +1 -0
- package/dist/tools/AnalyzeMediaParamParser.js +843 -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 +156 -294
- package/dist/tools/AnalyzeMediaTool.d.ts.map +1 -1
- package/dist/tools/AnalyzeMediaTool.js +611 -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, params.continuationLimit, 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,538 @@ 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
|
+
// Surface the effective model + correlation id from the server: the
|
|
224
|
+
// model id tells the caller which dispatch branch actually answered
|
|
225
|
+
// (useful with auto-dispatch and affinity hints).
|
|
226
|
+
if (typeof analysis.model === "string" && analysis.model.length > 0) {
|
|
227
|
+
extraction["model_used"] = analysis.model;
|
|
228
|
+
}
|
|
229
|
+
if (typeof analysis.request_id === "string" && analysis.request_id.length > 0) {
|
|
230
|
+
extraction["request_id"] = analysis.request_id;
|
|
231
|
+
}
|
|
232
|
+
const result = {
|
|
240
233
|
analysis: analysis.analysis,
|
|
241
234
|
...(Array.isArray(analysis.elements) && analysis.elements.length > 0
|
|
242
|
-
? {
|
|
235
|
+
? {
|
|
236
|
+
elements: Object.freeze(analysis.elements.map((element) => Object.freeze({ ...element, box: Object.freeze({ ...element.box }) }))),
|
|
237
|
+
}
|
|
243
238
|
: {}),
|
|
244
239
|
media_type: analysis.media_type,
|
|
245
|
-
|
|
240
|
+
...((params.warnings && params.warnings.length > 0) || transcribeWarning || remoteAdvisoryWarnings.length > 0 || (analysis.warnings && analysis.warnings.length > 0)
|
|
241
|
+
? {
|
|
242
|
+
warnings: [
|
|
243
|
+
...(params.warnings ?? []),
|
|
244
|
+
...(transcribeWarning ? [transcribeWarning] : []),
|
|
245
|
+
...remoteAdvisoryWarnings,
|
|
246
|
+
...(analysis.warnings ?? []),
|
|
247
|
+
],
|
|
248
|
+
}
|
|
249
|
+
: {}),
|
|
250
|
+
extraction,
|
|
246
251
|
};
|
|
252
|
+
// Release server bytes on success too: upload sessions live ~3 h and
|
|
253
|
+
// count against the per-key quota, so a successful analysis must not
|
|
254
|
+
// leak its session (mirrors EnriCode bestEffortDeleteUploadSession).
|
|
255
|
+
await this.deleteUploadedBytesBestEffort(client, uploadId);
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
await this.deleteUploadedBytesBestEffort(client, uploadId);
|
|
260
|
+
throw error;
|
|
247
261
|
}
|
|
248
262
|
finally {
|
|
249
|
-
for (const fetched of materialized) {
|
|
250
|
-
|
|
263
|
+
for (const fetched of resolved.materialized) {
|
|
264
|
+
try {
|
|
265
|
+
await fetched.cleanup();
|
|
266
|
+
}
|
|
267
|
+
catch (cleanupError) {
|
|
268
|
+
if (!isProgressQuiet()) {
|
|
269
|
+
console.error(`enrivision: temp cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
251
272
|
}
|
|
252
273
|
}
|
|
253
274
|
}
|
|
254
275
|
/**
|
|
255
|
-
*
|
|
276
|
+
* Rejects a `region` zoom against non-image inputs before any upload.
|
|
256
277
|
*
|
|
257
278
|
* @remarks
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
279
|
+
* `region` zooms one image at native resolution: sending it with video,
|
|
280
|
+
* audio, PDF, or a tar set wastes an upload plus a server rejection (or
|
|
281
|
+
* a silent ignore). Fail locally in Spanish using the resolved effective
|
|
282
|
+
* content type (server-reported type wins, mirroring the resolver).
|
|
261
283
|
*
|
|
262
|
-
* @param
|
|
263
|
-
* @param
|
|
264
|
-
* @
|
|
284
|
+
* @param region - Validated region, or undefined when absent.
|
|
285
|
+
* @param inputs - Resolved upload-ready inputs.
|
|
286
|
+
* @throws Error with an Spanish-first bilingual message when `region` targets non-image media.
|
|
265
287
|
*/
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return detected;
|
|
288
|
+
rejectRegionForNonImage(region, inputs) {
|
|
289
|
+
if (!region || inputs.length !== 1) {
|
|
290
|
+
return;
|
|
270
291
|
}
|
|
271
|
-
|
|
272
|
-
|
|
292
|
+
const single = inputs[0];
|
|
293
|
+
if (!single.contentType.toLowerCase().startsWith("image/")) {
|
|
294
|
+
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
295
|
}
|
|
274
|
-
return detected;
|
|
275
296
|
}
|
|
276
297
|
/**
|
|
277
|
-
*
|
|
278
|
-
* extraction payload before returning it to the model.
|
|
298
|
+
* Rejects tuning knobs that the resolved content type would silently ignore.
|
|
279
299
|
*
|
|
280
|
-
* @
|
|
281
|
-
*
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
*
|
|
300
|
+
* @remarks
|
|
301
|
+
* Pre-upload gate mirroring EnriCode `VisionAnalyzeMediaRequestRecords`:
|
|
302
|
+
* `video.*` tuning only applies to video, `images.*` only to images
|
|
303
|
+
* and documents (PDF page-image sets on the remote route), `document.*`
|
|
304
|
+
* only to PDF/Office/TXT/CSV, clip/`maxFramesPerSegment` never to audio,
|
|
305
|
+
* `video.segment_seconds`/`max_segments` never to audio without an audio
|
|
306
|
+
* section, and `audio.segment_seconds`/`max_segments` never to video
|
|
307
|
+
* without a video section (shared flats fan out to both sections by
|
|
308
|
+
* design and are exempt). Multi-image sets only accept `images.*`. The top-level
|
|
309
|
+
* `maxFrames` knob is media-agnostic by design (EnriCode declares
|
|
310
|
+
* `max_frames` "sin efecto en imágenes fijas o audio"): it never
|
|
311
|
+
* triggers a mismatch gate and is forwarded for the server to apply or
|
|
312
|
+
* ignore. Unknown content types (`application/octet-stream` included)
|
|
313
|
+
* skip every gate by design so the server (not this gate) decides
|
|
314
|
+
* applicability, mirroring EnriCode `VisionAnalyzeMediaRequestRecords`.
|
|
315
|
+
* Failing here saves the
|
|
316
|
+
* full upload plus a late server rejection.
|
|
289
317
|
*
|
|
290
|
-
* @param
|
|
291
|
-
* @
|
|
318
|
+
* @param params - Validated tool parameters.
|
|
319
|
+
* @param inputs - Resolved upload-ready inputs.
|
|
320
|
+
* @throws Error with an Spanish-first bilingual message when tuning mismatches the content type.
|
|
292
321
|
*/
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
continue;
|
|
322
|
+
rejectMismatchedTuning(params, inputs) {
|
|
323
|
+
// Top-level `maxFrames` is media-agnostic (EnriCode `max_frames` is
|
|
324
|
+
// "sin efecto en imágenes fijas o audio"): only the `video` section
|
|
325
|
+
// participates in the video-only gate.
|
|
326
|
+
const hasVideoTuning = typeof params.video !== "undefined";
|
|
327
|
+
const hasDocumentTuning = typeof params.document !== "undefined";
|
|
328
|
+
const hasImagesTuning = typeof params.images !== "undefined";
|
|
329
|
+
const hasAudioTuning = typeof params.audio !== "undefined";
|
|
330
|
+
if (!hasVideoTuning && !hasDocumentTuning && !hasImagesTuning && !hasAudioTuning) {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (inputs.length > 1) {
|
|
334
|
+
if (hasVideoTuning || hasDocumentTuning || hasAudioTuning) {
|
|
335
|
+
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.");
|
|
308
336
|
}
|
|
309
|
-
|
|
310
|
-
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const single = inputs[0];
|
|
340
|
+
if (!single) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const normalized = single.contentType.trim().toLowerCase();
|
|
344
|
+
const isImage = normalized.startsWith("image/");
|
|
345
|
+
const isVideo = normalized.startsWith("video/");
|
|
346
|
+
const isAudio = normalized.startsWith("audio/");
|
|
347
|
+
const isDocument = isDocumentContentType(normalized);
|
|
348
|
+
// Unknown content types (empty, `application/octet-stream`, or any
|
|
349
|
+
// non-media guess) skip every mismatch gate by design: the server (not
|
|
350
|
+
// this gate) decides applicability. Mirrors EnriCode
|
|
351
|
+
// `VisionAnalyzeMediaRequestRecords` ("Unknown content types ...
|
|
352
|
+
// forward guarded knobs untouched ... so the server decides").
|
|
353
|
+
if (normalized === "" || (!isImage && !isVideo && !isAudio && !isDocument)) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (hasDocumentTuning && !isDocument) {
|
|
357
|
+
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}).`);
|
|
358
|
+
}
|
|
359
|
+
if (hasImagesTuning && (isVideo || isAudio)) {
|
|
360
|
+
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}).`);
|
|
361
|
+
}
|
|
362
|
+
if (hasVideoTuning && (isImage || isDocument)) {
|
|
363
|
+
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}).`);
|
|
364
|
+
}
|
|
365
|
+
// Wrong-section scalar knobs are only rejected when the matching section
|
|
366
|
+
// is absent: shared top-level flats fan out to BOTH sections by design
|
|
367
|
+
// (the server applies the media-matching one), so a video+audio pair
|
|
368
|
+
// with equal segment values is the flat fan-out, never a silent drop.
|
|
369
|
+
if (isAudio && typeof params.video !== "undefined") {
|
|
370
|
+
const video = params.video;
|
|
371
|
+
const audioSegmentActive = typeof params.audio?.segmentSeconds !== "undefined"
|
|
372
|
+
|| typeof params.audio?.maxSegments !== "undefined";
|
|
373
|
+
if (typeof video.clipStartSeconds !== "undefined"
|
|
374
|
+
|| typeof video.clipDurationSeconds !== "undefined"
|
|
375
|
+
|| typeof video.maxFramesPerSegment !== "undefined") {
|
|
376
|
+
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}).`);
|
|
311
377
|
}
|
|
312
|
-
if (
|
|
313
|
-
|
|
378
|
+
if (!audioSegmentActive
|
|
379
|
+
&& (typeof video.segmentSeconds !== "undefined" || typeof video.maxSegments !== "undefined")) {
|
|
380
|
+
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}).`);
|
|
314
381
|
}
|
|
315
|
-
|
|
316
|
-
|
|
382
|
+
}
|
|
383
|
+
if (isVideo && typeof params.audio !== "undefined") {
|
|
384
|
+
const audio = params.audio;
|
|
385
|
+
const videoSegmentActive = typeof params.video?.segmentSeconds !== "undefined"
|
|
386
|
+
|| typeof params.video?.maxSegments !== "undefined";
|
|
387
|
+
if (!videoSegmentActive
|
|
388
|
+
&& (typeof audio.segmentSeconds !== "undefined" || typeof audio.maxSegments !== "undefined")) {
|
|
389
|
+
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
390
|
}
|
|
318
|
-
next[key] = this.stripInternalFields(child);
|
|
319
391
|
}
|
|
320
|
-
return next;
|
|
321
392
|
}
|
|
322
393
|
/**
|
|
323
|
-
*
|
|
394
|
+
* Resolves the requested server-side model id for dispatch affinity.
|
|
395
|
+
*
|
|
396
|
+
* @remarks
|
|
397
|
+
* Pure read of `params.model` plus the validated `ENRIVISION_MODEL` env
|
|
398
|
+
* fallback (no operator logging): used by the pre-upload vision probe,
|
|
399
|
+
* which runs before the main model-resolution block in `execute`.
|
|
324
400
|
*
|
|
325
|
-
* @param
|
|
326
|
-
* @returns
|
|
401
|
+
* @param params - Validated tool parameters.
|
|
402
|
+
* @returns Trimmed model id, or undefined for auto-dispatch.
|
|
327
403
|
*/
|
|
328
|
-
|
|
329
|
-
if (
|
|
330
|
-
return
|
|
404
|
+
resolveRequestedModel(params) {
|
|
405
|
+
if (typeof params.model === "string" && params.model.trim()) {
|
|
406
|
+
return params.model.trim();
|
|
407
|
+
}
|
|
408
|
+
const envModelRaw = typeof process.env["ENRIVISION_MODEL"] === "string"
|
|
409
|
+
? process.env["ENRIVISION_MODEL"].trim()
|
|
410
|
+
: "";
|
|
411
|
+
if (envModelRaw.length > 0 && envModelRaw.length <= 128) {
|
|
412
|
+
return envModelRaw;
|
|
331
413
|
}
|
|
332
|
-
return
|
|
414
|
+
return undefined;
|
|
333
415
|
}
|
|
334
416
|
/**
|
|
335
|
-
*
|
|
417
|
+
* Fails fast when the requested model explicitly lacks vision, before any byte.
|
|
336
418
|
*
|
|
337
|
-
* @
|
|
338
|
-
|
|
339
|
-
*
|
|
419
|
+
* @remarks
|
|
420
|
+
* Mirrors EnriCode `assertRemoteVisionCapable`: probes `GET
|
|
421
|
+
* `/v1/account/models` (15 s budget inside the client) and matches the
|
|
422
|
+
* requested model by `id`, `requestModelId`, or canonical ids. Only an
|
|
423
|
+
* explicit `vision === false` rejects; unknown models, missing flags, and
|
|
424
|
+
* probe failures stay fail-open so a stale discovery snapshot never blocks
|
|
425
|
+
* a valid upload. Verdicts are cached per client for 5 min; 401/404 probe
|
|
426
|
+
* failures invalidate the entry so revoked keys never ride a stale verdict.
|
|
427
|
+
* Clients without `getAccountModels` (older stubs) skip the probe entirely.
|
|
340
428
|
*
|
|
341
|
-
* @param
|
|
342
|
-
* @
|
|
429
|
+
* @param client - EnriProxy client used for the upload.
|
|
430
|
+
* @param requestedModel - Trimmed model id, or undefined for auto-dispatch.
|
|
431
|
+
* @param signal - Optional cancellation signal.
|
|
432
|
+
* @throws Error with an Spanish-first bilingual message when the model explicitly lacks vision.
|
|
343
433
|
*/
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
434
|
+
async assertVisionCapable(client, requestedModel, signal) {
|
|
435
|
+
const modelId = typeof requestedModel === "string" ? requestedModel.trim() : "";
|
|
436
|
+
if (!modelId || signal?.aborted) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const probe = client;
|
|
440
|
+
if (typeof probe.getAccountModels !== "function") {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const now = Date.now();
|
|
444
|
+
const cacheKey = `${this.deps.defaultServerUrl}::${modelId}`;
|
|
445
|
+
const cached = this.visionProbeCache.get(cacheKey);
|
|
446
|
+
if (cached !== undefined && cached.expiresAt > now) {
|
|
447
|
+
if (!cached.verdict) {
|
|
448
|
+
throw AnalyzeMediaTool.buildVisionlessModelError(modelId);
|
|
449
|
+
}
|
|
450
|
+
return;
|
|
347
451
|
}
|
|
348
|
-
if (
|
|
349
|
-
|
|
452
|
+
if (cached !== undefined) {
|
|
453
|
+
this.visionProbeCache.delete(cacheKey);
|
|
350
454
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
455
|
+
let payload;
|
|
456
|
+
try {
|
|
457
|
+
payload = await probe.getAccountModels.call(client, signal);
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
if (error instanceof EnriProxyHttpError && (error.status === 401 || error.status === 404)) {
|
|
461
|
+
this.visionProbeCache.delete(cacheKey);
|
|
357
462
|
}
|
|
358
|
-
return
|
|
359
|
-
}
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
width: readFraction("width"),
|
|
364
|
-
height: readFraction("height")
|
|
365
|
-
};
|
|
366
|
-
if (region.width <= 0 || region.height <= 0) {
|
|
367
|
-
throw new Error("region.width y region.height deben ser mayores que 0.");
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const entry = findAccountModelEntry(payload, modelId);
|
|
466
|
+
if (entry === null) {
|
|
467
|
+
return;
|
|
368
468
|
}
|
|
369
|
-
|
|
469
|
+
if (entry.vision === false) {
|
|
470
|
+
this.rememberVisionProbe(cacheKey, false, now);
|
|
471
|
+
throw AnalyzeMediaTool.buildVisionlessModelError(modelId);
|
|
472
|
+
}
|
|
473
|
+
this.rememberVisionProbe(cacheKey, true, now);
|
|
370
474
|
}
|
|
371
475
|
/**
|
|
372
|
-
*
|
|
476
|
+
* Maximum vision-probe cache entries (model ids are caller-controlled).
|
|
477
|
+
*/
|
|
478
|
+
static MAX_VISION_PROBE_ENTRIES = 100;
|
|
479
|
+
/**
|
|
480
|
+
* Remembers one vision-probe verdict, purging expired entries and
|
|
481
|
+
* evicting oldest-first past the cap so long-lived tool instances
|
|
482
|
+
* cannot leak one entry per distinct model id.
|
|
373
483
|
*
|
|
374
|
-
* @param
|
|
375
|
-
* @
|
|
484
|
+
* @param cacheKey - Server plus model cache key.
|
|
485
|
+
* @param verdict - Whether the model has vision.
|
|
486
|
+
* @param now - Current epoch milliseconds.
|
|
487
|
+
* @returns Nothing.
|
|
376
488
|
*/
|
|
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;
|
|
489
|
+
rememberVisionProbe(cacheKey, verdict, now) {
|
|
490
|
+
for (const [key, entry] of this.visionProbeCache) {
|
|
491
|
+
if (entry.expiresAt <= now) {
|
|
492
|
+
this.visionProbeCache.delete(key);
|
|
493
|
+
}
|
|
389
494
|
}
|
|
390
|
-
|
|
391
|
-
|
|
495
|
+
while (this.visionProbeCache.size >= AnalyzeMediaTool.MAX_VISION_PROBE_ENTRIES) {
|
|
496
|
+
const oldest = this.visionProbeCache.keys().next().value;
|
|
497
|
+
if (oldest === undefined) {
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
this.visionProbeCache.delete(oldest);
|
|
392
501
|
}
|
|
502
|
+
this.visionProbeCache.set(cacheKey, { verdict, expiresAt: now + ANALYZE_MEDIA_LIMITS.visionProbeCacheTtlMs });
|
|
393
503
|
}
|
|
394
504
|
/**
|
|
395
|
-
*
|
|
505
|
+
* Builds the visionless-model rejection error.
|
|
396
506
|
*
|
|
397
|
-
* @param
|
|
398
|
-
* @returns
|
|
507
|
+
* @param modelId - Requested model id.
|
|
508
|
+
* @returns Spanish-first bilingual error (no byte was uploaded).
|
|
399
509
|
*/
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
if (typeof detected === "string" && detected.trim()) {
|
|
403
|
-
return detected.trim();
|
|
404
|
-
}
|
|
405
|
-
return "application/octet-stream";
|
|
510
|
+
static buildVisionlessModelError(modelId) {
|
|
511
|
+
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
512
|
}
|
|
407
513
|
/**
|
|
408
|
-
*
|
|
514
|
+
* Executes one truncated-list continuation read (no upload, no analysis).
|
|
515
|
+
*
|
|
516
|
+
* @param client - EnriProxy client.
|
|
517
|
+
* @param cursor - Opaque cursor from a truncated response.
|
|
518
|
+
* @param offset - Start index, or undefined for the stored next offset.
|
|
519
|
+
* @param limit - Window length (1-100), or undefined for the server default.
|
|
520
|
+
* @param signal - Optional cancellation signal.
|
|
521
|
+
* @returns Tool result carrying the page plus chaining state.
|
|
522
|
+
*/
|
|
523
|
+
async executeContinuation(client, cursor, offset, limit, signal) {
|
|
524
|
+
const page = await client.fetchSegmentPage({
|
|
525
|
+
cursor,
|
|
526
|
+
...(typeof offset === "number" ? { offset } : {}),
|
|
527
|
+
...(typeof limit === "number" ? { limit } : {}),
|
|
528
|
+
...(typeof signal !== "undefined" ? { signal } : {}),
|
|
529
|
+
});
|
|
530
|
+
const lines = page.entries.map((entry) => {
|
|
531
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
532
|
+
return `- ${String(entry ?? "")}`;
|
|
533
|
+
}
|
|
534
|
+
const record = entry;
|
|
535
|
+
const text = record["summary"] ?? record["text"];
|
|
536
|
+
const start = record["start_seconds"] ?? record["start"];
|
|
537
|
+
const end = record["end_seconds"] ?? record["end"];
|
|
538
|
+
const span = typeof start === "number" && typeof end === "number"
|
|
539
|
+
? ` [${start.toFixed(2)}-${end.toFixed(2)}s]`
|
|
540
|
+
: "";
|
|
541
|
+
return `- ${String(text ?? JSON.stringify(entry) ?? "")}${span}`;
|
|
542
|
+
});
|
|
543
|
+
const end = page.nextOffset;
|
|
544
|
+
const start = Math.max(0, end - lines.length);
|
|
545
|
+
const header = `Continuación: entradas ${start}-${end} de ${page.total} / Continuation: entries ${start}-${end} of ${page.total}.`;
|
|
546
|
+
const tail = page.hasMore
|
|
547
|
+
? `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}.`
|
|
548
|
+
: `No quedan más entradas. / No more entries.`;
|
|
549
|
+
return {
|
|
550
|
+
analysis: `${header}\n${lines.join("\n")}\n${tail}`,
|
|
551
|
+
media_type: "continuation",
|
|
552
|
+
warnings: [],
|
|
553
|
+
extraction: {
|
|
554
|
+
continuation: true,
|
|
555
|
+
total: page.total,
|
|
556
|
+
has_more: page.hasMore,
|
|
557
|
+
next_offset: page.nextOffset,
|
|
558
|
+
cursor: page.cursor,
|
|
559
|
+
},
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Warns when `transcribe` is set for media it cannot affect.
|
|
409
564
|
*
|
|
410
565
|
* @remarks
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
566
|
+
* Mirrors EnriCode `LocalMediaAnalysisService` (transcribe is inapplicable
|
|
567
|
+
* for images and documents): instead of failing, the knob is forwarded
|
|
568
|
+
* and declared as a bilingual honesty warning so the model never assumes
|
|
569
|
+
* audio was transcribed. Videos and audio files never warn.
|
|
414
570
|
*
|
|
415
|
-
* @param
|
|
416
|
-
* @param inputs - Resolved
|
|
417
|
-
* @
|
|
418
|
-
* @param clientTraceId - Client trace id for correlation
|
|
419
|
-
* @returns Upload id for the created tar session
|
|
571
|
+
* @param params - Validated tool parameters.
|
|
572
|
+
* @param inputs - Resolved upload-ready inputs.
|
|
573
|
+
* @returns Bilingual warning, or undefined when `transcribe` may apply.
|
|
420
574
|
*/
|
|
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
|
-
});
|
|
575
|
+
transcribeInapplicableWarning(params, inputs) {
|
|
576
|
+
if (typeof params.transcribe === "undefined") {
|
|
577
|
+
return undefined;
|
|
445
578
|
}
|
|
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
|
-
}
|
|
579
|
+
if (inputs.length > 1) {
|
|
580
|
+
return ("transcribe no tiene efecto en conjuntos de varias imágenes y se ignora. / " +
|
|
581
|
+
"transcribe has no effect on multi-image sets and is ignored.");
|
|
511
582
|
}
|
|
512
|
-
|
|
513
|
-
|
|
583
|
+
const single = inputs[0];
|
|
584
|
+
if (!single) {
|
|
585
|
+
return undefined;
|
|
586
|
+
}
|
|
587
|
+
const normalized = single.contentType.trim().toLowerCase();
|
|
588
|
+
if (normalized.startsWith("image/")) {
|
|
589
|
+
return ("transcribe no tiene efecto en imágenes y se ignora. / " +
|
|
590
|
+
"transcribe has no effect on images and is ignored.");
|
|
514
591
|
}
|
|
515
|
-
|
|
592
|
+
if (isDocumentContentType(normalized)) {
|
|
593
|
+
return ("transcribe no tiene efecto en documentos y se ignora. / " +
|
|
594
|
+
"transcribe has no effect on documents and is ignored.");
|
|
595
|
+
}
|
|
596
|
+
return undefined;
|
|
516
597
|
}
|
|
517
598
|
/**
|
|
518
|
-
*
|
|
599
|
+
* Runs the mismatch gates on the remote (`source_url`) branch as advisories.
|
|
600
|
+
*
|
|
601
|
+
* @remarks
|
|
602
|
+
* The local gates no-op on the empty remote input list by design (the
|
|
603
|
+
* server, which sees the served type, decides applicability). When the URL
|
|
604
|
+
* extension guesses a known media type, the same gates run against the
|
|
605
|
+
* guess and likely-ignored tuning is declared as bilingual warnings
|
|
606
|
+
* instead of errors; URLs with no usable extension stay server-decides
|
|
607
|
+
* with no warnings.
|
|
519
608
|
*
|
|
520
|
-
* @param
|
|
521
|
-
* @param
|
|
522
|
-
* @
|
|
523
|
-
* @param session - Server-created session
|
|
524
|
-
* @param timeoutMs - Request timeout in milliseconds
|
|
525
|
-
* @returns Final offset
|
|
609
|
+
* @param params - Validated tool parameters.
|
|
610
|
+
* @param remoteUrl - Remote http(s) URL the server will ingest.
|
|
611
|
+
* @returns Advisory warnings (empty when nothing looks mismatched).
|
|
526
612
|
*/
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
613
|
+
advisoryRemoteTuningWarnings(params, remoteUrl) {
|
|
614
|
+
const guessed = mimeLookup(remoteUrl.split(/[?#]/)[0] ?? remoteUrl);
|
|
615
|
+
if (typeof guessed !== "string") {
|
|
616
|
+
return [];
|
|
617
|
+
}
|
|
618
|
+
const contentType = guessed.trim().toLowerCase();
|
|
619
|
+
if (!MediaUrlFetcher.isAllowedMediaContentType(contentType)) {
|
|
620
|
+
return [];
|
|
621
|
+
}
|
|
622
|
+
const advisories = [];
|
|
623
|
+
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} / ` +
|
|
624
|
+
`Remote URL looks like ${contentType} by extension, so this tuning may be ignored by the server (advisory only; the server decides): ${detail}`;
|
|
530
625
|
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;
|
|
626
|
+
this.rejectMismatchedTuning(params, [{ localPath: remoteUrl, contentType }]);
|
|
546
627
|
}
|
|
547
|
-
|
|
548
|
-
|
|
628
|
+
catch (error) {
|
|
629
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
630
|
+
advisories.push(wrapAdvisory(detail));
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
this.rejectRegionForNonImage(params.region, [{ localPath: remoteUrl, contentType }]);
|
|
634
|
+
}
|
|
635
|
+
catch (error) {
|
|
636
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
637
|
+
advisories.push(wrapAdvisory(detail));
|
|
638
|
+
}
|
|
639
|
+
const transcribeWarning = this.transcribeInapplicableWarning(params, [{ localPath: remoteUrl, contentType }]);
|
|
640
|
+
if (typeof transcribeWarning !== "undefined") {
|
|
641
|
+
advisories.push(wrapAdvisory(transcribeWarning));
|
|
549
642
|
}
|
|
643
|
+
return advisories;
|
|
550
644
|
}
|
|
551
645
|
/**
|
|
552
|
-
*
|
|
646
|
+
* Releases uploaded bytes when the analysis fails or is cancelled.
|
|
647
|
+
*
|
|
648
|
+
* @remarks
|
|
649
|
+
* Best-effort by design: it runs on an independent 15 s signal (the
|
|
650
|
+
* caller signal may already be cancelled) and never throws, so the
|
|
651
|
+
* original upload/analysis error always reaches the model unchanged.
|
|
553
652
|
*
|
|
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
|
|
653
|
+
* @param client - EnriProxy client used for the upload.
|
|
654
|
+
* @param uploadId - Upload id to release, or null when nothing was uploaded.
|
|
560
655
|
*/
|
|
561
|
-
async
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
656
|
+
async deleteUploadedBytesBestEffort(client, uploadId) {
|
|
657
|
+
if (!uploadId || typeof client.deleteUploadSession !== "function") {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
try {
|
|
661
|
+
await client.deleteUploadSession(uploadId, AbortSignal.timeout(15_000));
|
|
662
|
+
}
|
|
663
|
+
catch (cleanupError) {
|
|
664
|
+
// A second delete after an already-handled deadline/identity branch
|
|
665
|
+
// 404s: that is the expected terminal state, not noise.
|
|
666
|
+
const status = cleanupError?.status;
|
|
667
|
+
const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
668
|
+
if (status === 404 || /HTTP 404/u.test(message)) {
|
|
669
|
+
return;
|
|
566
670
|
}
|
|
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));
|
|
671
|
+
if (!isProgressQuiet()) {
|
|
672
|
+
console.error(`enrivision: could not release upload ${uploadId}: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
|
|
594
673
|
}
|
|
595
674
|
}
|
|
596
|
-
throw new Error("La subida falló tras los reintentos.");
|
|
597
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* Removes internal identifiers from the extraction payload.
|
|
678
|
+
*
|
|
679
|
+
* @param extraction - Raw extraction object returned by EnriProxy.
|
|
680
|
+
* @returns Sanitized extraction object.
|
|
681
|
+
*/
|
|
682
|
+
stripInternalExtractionFields(extraction) {
|
|
683
|
+
return this.sanitizer.sanitize(extraction);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Reports whether one normalized content type is a document type.
|
|
688
|
+
*
|
|
689
|
+
* @remarks
|
|
690
|
+
* Shared by the mismatch gate and the `transcribe` inapplicability warning
|
|
691
|
+
* so both agree on what counts as a document (PDF, Office, TXT/CSV, RTF).
|
|
692
|
+
*
|
|
693
|
+
* @param normalizedContentType - Lowercased content type.
|
|
694
|
+
* @returns True for document types.
|
|
695
|
+
*/
|
|
696
|
+
function isDocumentContentType(normalizedContentType) {
|
|
697
|
+
return (normalizedContentType === "application/pdf"
|
|
698
|
+
|| normalizedContentType.includes("wordprocessing")
|
|
699
|
+
|| normalizedContentType.includes("msword")
|
|
700
|
+
|| normalizedContentType.includes("spreadsheet")
|
|
701
|
+
|| normalizedContentType.includes("excel")
|
|
702
|
+
|| normalizedContentType.includes("presentation")
|
|
703
|
+
|| normalizedContentType.includes("powerpoint")
|
|
704
|
+
|| normalizedContentType.includes("oasis.opendocument")
|
|
705
|
+
|| normalizedContentType === "text/plain"
|
|
706
|
+
|| normalizedContentType === "text/csv"
|
|
707
|
+
|| normalizedContentType.includes("rtf"));
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Finds one account-model entry matching a requested model id.
|
|
711
|
+
*
|
|
712
|
+
* @remarks
|
|
713
|
+
* Mirrors EnriCode `assertRemoteVisionCapable` matching: the entry matches
|
|
714
|
+
* on `id`, `requestModelId`/`request_model_id`, or any canonical id
|
|
715
|
+
* (`canonicalIds`/`canonical_ids`). Non-object payloads, missing `data`,
|
|
716
|
+
* and non-array `data` yield null (fail-open: the caller proceeds).
|
|
717
|
+
*
|
|
718
|
+
* @param payload - Parsed `/v1/account/models` body.
|
|
719
|
+
* @param modelId - Trimmed requested model id.
|
|
720
|
+
* @returns Matched entry, or null when no entry matches.
|
|
721
|
+
*/
|
|
722
|
+
function findAccountModelEntry(payload, modelId) {
|
|
723
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
const data = payload["data"];
|
|
727
|
+
if (!Array.isArray(data)) {
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
for (const candidate of data) {
|
|
731
|
+
if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const record = candidate;
|
|
735
|
+
const ids = [
|
|
736
|
+
record["id"],
|
|
737
|
+
record["requestModelId"],
|
|
738
|
+
record["request_model_id"],
|
|
739
|
+
];
|
|
740
|
+
const canonical = record["canonicalIds"] ?? record["canonical_ids"];
|
|
741
|
+
if (Array.isArray(canonical)) {
|
|
742
|
+
ids.push(...canonical);
|
|
743
|
+
}
|
|
744
|
+
const matches = ids.some((id) => typeof id === "string" && id.trim() === modelId);
|
|
745
|
+
if (!matches) {
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
const vision = record["vision"];
|
|
749
|
+
return { vision: typeof vision === "boolean" ? vision : undefined };
|
|
750
|
+
}
|
|
751
|
+
return null;
|
|
598
752
|
}
|
|
599
753
|
//# sourceMappingURL=AnalyzeMediaTool.js.map
|