@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.
Files changed (64) hide show
  1. package/README.md +31 -6
  2. package/dist/client/EnriProxyClient.d.ts +289 -244
  3. package/dist/client/EnriProxyClient.d.ts.map +1 -1
  4. package/dist/client/EnriProxyClient.js +841 -115
  5. package/dist/client/EnriProxyClient.js.map +1 -1
  6. package/dist/client/EnriProxyClientContract.d.ts +425 -0
  7. package/dist/client/EnriProxyClientContract.d.ts.map +1 -0
  8. package/dist/client/EnriProxyClientContract.js +87 -0
  9. package/dist/client/EnriProxyClientContract.js.map +1 -0
  10. package/dist/index.js +23 -12
  11. package/dist/index.js.map +1 -1
  12. package/dist/package-info.d.ts +28 -0
  13. package/dist/package-info.d.ts.map +1 -1
  14. package/dist/package-info.js +28 -0
  15. package/dist/package-info.js.map +1 -1
  16. package/dist/server/EnriVisionServer.d.ts +186 -0
  17. package/dist/server/EnriVisionServer.d.ts.map +1 -1
  18. package/dist/server/EnriVisionServer.js +780 -93
  19. package/dist/server/EnriVisionServer.js.map +1 -1
  20. package/dist/shared/codepointTruncation.d.ts +61 -0
  21. package/dist/shared/codepointTruncation.d.ts.map +1 -0
  22. package/dist/shared/codepointTruncation.js +73 -0
  23. package/dist/shared/codepointTruncation.js.map +1 -0
  24. package/dist/shared/mediaUrlFetcher.d.ts +247 -9
  25. package/dist/shared/mediaUrlFetcher.d.ts.map +1 -1
  26. package/dist/shared/mediaUrlFetcher.js +712 -53
  27. package/dist/shared/mediaUrlFetcher.js.map +1 -1
  28. package/dist/shared/tar.d.ts +82 -2
  29. package/dist/shared/tar.d.ts.map +1 -1
  30. package/dist/shared/tar.js +106 -43
  31. package/dist/shared/tar.js.map +1 -1
  32. package/dist/shared/validation.d.ts +96 -2
  33. package/dist/shared/validation.d.ts.map +1 -1
  34. package/dist/shared/validation.js +169 -10
  35. package/dist/shared/validation.js.map +1 -1
  36. package/dist/tools/AnalyzeMediaContract.d.ts +457 -0
  37. package/dist/tools/AnalyzeMediaContract.d.ts.map +1 -0
  38. package/dist/tools/AnalyzeMediaContract.js +161 -0
  39. package/dist/tools/AnalyzeMediaContract.js.map +1 -0
  40. package/dist/tools/AnalyzeMediaExtractionSanitizer.d.ts +35 -0
  41. package/dist/tools/AnalyzeMediaExtractionSanitizer.d.ts.map +1 -0
  42. package/dist/tools/AnalyzeMediaExtractionSanitizer.js +214 -0
  43. package/dist/tools/AnalyzeMediaExtractionSanitizer.js.map +1 -0
  44. package/dist/tools/AnalyzeMediaInputResolver.d.ts +250 -0
  45. package/dist/tools/AnalyzeMediaInputResolver.d.ts.map +1 -0
  46. package/dist/tools/AnalyzeMediaInputResolver.js +430 -0
  47. package/dist/tools/AnalyzeMediaInputResolver.js.map +1 -0
  48. package/dist/tools/AnalyzeMediaParamParser.d.ts +299 -0
  49. package/dist/tools/AnalyzeMediaParamParser.d.ts.map +1 -0
  50. package/dist/tools/AnalyzeMediaParamParser.js +824 -0
  51. package/dist/tools/AnalyzeMediaParamParser.js.map +1 -0
  52. package/dist/tools/AnalyzeMediaResumableUploader.d.ts +244 -0
  53. package/dist/tools/AnalyzeMediaResumableUploader.d.ts.map +1 -0
  54. package/dist/tools/AnalyzeMediaResumableUploader.js +549 -0
  55. package/dist/tools/AnalyzeMediaResumableUploader.js.map +1 -0
  56. package/dist/tools/AnalyzeMediaTarPackager.d.ts +42 -0
  57. package/dist/tools/AnalyzeMediaTarPackager.d.ts.map +1 -0
  58. package/dist/tools/AnalyzeMediaTarPackager.js +245 -0
  59. package/dist/tools/AnalyzeMediaTarPackager.js.map +1 -0
  60. package/dist/tools/AnalyzeMediaTool.d.ts +155 -294
  61. package/dist/tools/AnalyzeMediaTool.d.ts.map +1 -1
  62. package/dist/tools/AnalyzeMediaTool.js +600 -457
  63. package/dist/tools/AnalyzeMediaTool.js.map +1 -1
  64. package/package.json +2 -1
@@ -1,31 +1,27 @@
1
1
  /**
2
2
  * ANALYZE MEDIA TOOL
3
3
  *
4
- * Implements the `analyze_media` MCP tool:
5
- * - Validates local file paths or http(s) URLs
6
- * - Materializes URL inputs via bounded downloads (temporary, owned cleanup)
7
- * - Streams the bytes to EnriProxy via resumable uploads
8
- * - Triggers server-side analysis and returns text-only results
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 { EnriProxyHttpError } from "../client/EnriProxyClient.js";
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 { computeTarSizeBytes, TarStream } from "../shared/tar.js";
21
- /**
22
- * Content type for EnriVision media-set archives (tar, no compression).
23
- */
24
- const ENRIVISION_MEDIA_SET_TAR_CONTENT_TYPE = "application/vnd.enrivision.media-set+tar";
25
- /**
26
- * Fixed manifest entry name inside EnriVision media-set tar archives.
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
- * Bounded http(s) media fetcher used when `path`/`paths` carry URLs.
34
+ * Raw argument validator.
35
+ */
36
+ paramParser;
37
+ /**
38
+ * Local/URL input materializer.
39
+ */
40
+ inputResolver;
41
+ /**
42
+ * Resumable chunk uploader.
39
43
  */
40
- urlFetcher;
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.urlFetcher = urlFetcher;
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
- const obj = assertObject(raw, "arguments");
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
- * @returns Tool result
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
- const requestedPaths = Array.isArray(params.paths) && params.paths.length > 0
175
- ? [...params.paths]
176
- : typeof params.path === "string" && params.path.trim()
177
- ? [params.path.trim()]
178
- : [];
179
- if (requestedPaths.length === 0) {
180
- throw new Error("Proporcione 'path' o 'paths'.");
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
- const inputs = [];
185
- for (const requested of requestedPaths) {
186
- if (!MediaUrlFetcher.isHttpUrl(requested)) {
187
- inputs.push({ localPath: requested });
188
- continue;
189
- }
190
- const fetched = await this.urlFetcher.fetch(requested);
191
- materialized.push(fetched);
192
- inputs.push({ localPath: fetched.localPath, urlContentType: fetched.contentType });
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
- let uploadId;
195
- if (inputs.length > 1) {
196
- uploadId = await this.uploadImageSetAsMediaSetTar(client, inputs, timeoutMs, clientTraceId);
197
- }
198
- else {
199
- const single = inputs[0];
200
- const fileSize = await this.assertReadableFile(single.localPath);
201
- const filename = basename(single.localPath);
202
- const contentType = this.resolveEffectiveContentType(single.localPath, single.urlContentType);
203
- const session = await client.createUploadSession({
204
- filename,
205
- sizeBytes: fileSize,
206
- contentType,
207
- clientTraceId
208
- });
209
- const finalOffset = await this.uploadFileResumable(client, single.localPath, fileSize, session, timeoutMs);
210
- if (finalOffset !== fileSize) {
211
- throw new Error(`Subida incompleta: se enviaron ${finalOffset} de ${fileSize} bytes.`);
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 language = typeof params.language === "string" && params.language.trim()
219
- ? params.language.trim()
220
- : defaultLanguageRaw
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
- return {
223
+ const result = {
240
224
  analysis: analysis.analysis,
241
225
  ...(Array.isArray(analysis.elements) && analysis.elements.length > 0
242
- ? { elements: Object.freeze(analysis.elements.map((element) => Object.freeze({ ...element }))) }
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
- extraction
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
- await fetched.cleanup();
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
- * Resolves the upload content type for one media input.
267
+ * Rejects a `region` zoom against non-image inputs before any upload.
256
268
  *
257
269
  * @remarks
258
- * URL downloads keep their extension-derived type when the extension is
259
- * recognized; otherwise the server-reported content type wins over the
260
- * generic `application/octet-stream` fallback.
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 localPath - Local file path (already materialized for URLs)
263
- * @param urlContentType - Server-reported content type for downloaded URLs
264
- * @returns Effective MIME type
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
- resolveEffectiveContentType(localPath, urlContentType) {
267
- const detected = this.detectMimeType(localPath);
268
- if (detected !== "application/octet-stream") {
269
- return detected;
279
+ rejectRegionForNonImage(region, inputs) {
280
+ if (!region || inputs.length !== 1) {
281
+ return;
270
282
  }
271
- if (urlContentType && urlContentType.trim() && urlContentType !== "application/octet-stream") {
272
- return urlContentType.trim();
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
- * Removes internal identifiers (upload ids, routing-only values, etc.) from the
278
- * extraction payload before returning it to the model.
289
+ * Rejects tuning knobs that the resolved content type would silently ignore.
279
290
  *
280
- * @param extraction - Raw extraction object returned by EnriProxy
281
- * @returns Sanitized extraction object
282
- */
283
- stripInternalExtractionFields(extraction) {
284
- const stripped = this.stripInternalFields(extraction);
285
- return this.asPlainObject(stripped);
286
- }
287
- /**
288
- * Recursively strips internal fields from an unknown value.
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 value - Unknown value to sanitize
291
- * @returns Sanitized value
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
- stripInternalFields(value) {
294
- if (Array.isArray(value)) {
295
- return value.map((item) => this.stripInternalFields(item));
296
- }
297
- if (!value || typeof value !== "object") {
298
- return value;
299
- }
300
- const record = value;
301
- const next = {};
302
- for (const [key, child] of Object.entries(record)) {
303
- if (key === "upload_id" || key === "uploadId") {
304
- continue;
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
- if (key === "detected_media_type") {
307
- continue;
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 (key === "analysis_mode_requested" || key === "analysis_mode_used") {
310
- continue;
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
- if (key === "multipass") {
313
- continue;
314
- }
315
- if (key === "model") {
316
- continue;
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
- * Ensures the sanitized extraction value is a plain JSON object.
385
+ * Resolves the requested server-side model id for dispatch affinity.
324
386
  *
325
- * @param value - Sanitized value
326
- * @returns Plain object
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
- asPlainObject(value) {
329
- if (value && typeof value === "object" && !Array.isArray(value)) {
330
- return value;
395
+ resolveRequestedModel(params) {
396
+ if (typeof params.model === "string" && params.model.trim()) {
397
+ return params.model.trim();
331
398
  }
332
- return {};
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
- * Validates that a path exists and is a readable file.
408
+ * Fails fast when the requested model explicitly lacks vision, before any byte.
336
409
  *
337
- * @param filePath - Local filesystem path
338
- /**
339
- * Parses and validates the optional relative image region.
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 raw - Raw `region` argument.
342
- * @returns Validated region, or undefined when absent.
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
- parseRegion(raw) {
345
- if (raw === undefined || raw === null) {
346
- return undefined;
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 (typeof raw !== "object" || Array.isArray(raw)) {
349
- throw new Error("region debe ser un objeto {x, y, width, height} con coordenadas relativas entre 0 y 1. Nunca invente coordenadas: use las cajas devueltas en 'elements' de un análisis previo de la misma imagen.");
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
- const record = raw;
352
- const readFraction = (fieldName) => {
353
- const value = record[fieldName];
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 parsed;
359
- };
360
- const region = {
361
- x: readFraction("x"),
362
- y: readFraction("y"),
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.");
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
- return region;
464
+ this.rememberVisionProbe(cacheKey, true, now);
370
465
  }
371
466
  /**
372
- * Validates that a path exists and is a readable file.
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 filePath - Local filesystem path
375
- * @returns File size in bytes
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
- async assertReadableFile(filePath) {
378
- if (!existsSync(filePath)) {
379
- throw new Error(`Archivo no encontrado: ${filePath}`);
380
- }
381
- const st = await stat(filePath);
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
- finally {
391
- await handle.close();
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
- * Detects MIME type using file extension.
496
+ * Builds the visionless-model rejection error.
396
497
  *
397
- * @param filePath - File path
398
- * @returns MIME type string
498
+ * @param modelId - Requested model id.
499
+ * @returns Spanish-first bilingual error (no byte was uploaded).
399
500
  */
400
- detectMimeType(filePath) {
401
- const detected = mimeLookup(filePath);
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
- * Uploads multiple local images as a single EnriVision media-set tar archive.
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
- * This avoids creating many concurrent resumable upload sessions (which are
412
- * capped per API key) and enables server-side batching + reduce for large
413
- * screenshot sets.
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 client - EnriProxy client
416
- * @param inputs - Resolved media inputs (local paths, URLs already materialized)
417
- * @param timeoutMs - Request timeout per HTTP request
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
- async uploadImageSetAsMediaSetTar(client, inputs, timeoutMs, clientTraceId) {
422
- if (inputs.length < 2) {
423
- throw new Error("uploadImageSetAsMediaSetTar requires at least 2 file paths.");
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
- const manifest = {
447
- type: "enrivision_media_set",
448
- version: 1,
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
- if (offset !== tarSizeBytes) {
513
- throw new Error(`Subida incompleta: se enviaron ${offset} de ${tarSizeBytes} bytes.`);
572
+ const single = inputs[0];
573
+ if (!single) {
574
+ return undefined;
514
575
  }
515
- return session.upload_id;
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
- * Uploads a file to EnriProxy in resumable chunks.
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 client - EnriProxy client
521
- * @param filePath - Local file path
522
- * @param fileSize - Total file size in bytes
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
- async uploadFileResumable(client, filePath, fileSize, session, timeoutMs) {
528
- const chunkSize = Math.max(1, Math.floor(session.chunk_size_bytes));
529
- const handle = await open(filePath, "r");
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
- let offset = await client.getUploadOffset(session.upload_id);
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
- finally {
548
- await handle.close();
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
- * Uploads a single chunk with retry and offset resync.
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 uploadChunkWithRetry(client, uploadId, offset, chunk, timeoutMs) {
562
- const maxAttempts = 5;
563
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
564
- try {
565
- return await client.appendUploadChunk({ uploadId, offset, chunk, timeoutMs });
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
- catch (error) {
568
- const message = error instanceof Error ? error.message : String(error);
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