@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.
Files changed (64) hide show
  1. package/README.md +43 -9
  2. package/dist/client/EnriProxyClient.d.ts +296 -248
  3. package/dist/client/EnriProxyClient.d.ts.map +1 -1
  4. package/dist/client/EnriProxyClient.js +849 -119
  5. package/dist/client/EnriProxyClient.js.map +1 -1
  6. package/dist/client/EnriProxyClientContract.d.ts +478 -0
  7. package/dist/client/EnriProxyClientContract.d.ts.map +1 -0
  8. package/dist/client/EnriProxyClientContract.js +136 -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 +804 -94
  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 +462 -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 +307 -0
  49. package/dist/tools/AnalyzeMediaParamParser.d.ts.map +1 -0
  50. package/dist/tools/AnalyzeMediaParamParser.js +843 -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 +156 -294
  61. package/dist/tools/AnalyzeMediaTool.d.ts.map +1 -1
  62. package/dist/tools/AnalyzeMediaTool.js +611 -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, params.continuationLimit, 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,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
- return {
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
- ? { elements: Object.freeze(analysis.elements.map((element) => Object.freeze({ ...element }))) }
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
- extraction
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
- await fetched.cleanup();
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
- * Resolves the upload content type for one media input.
276
+ * Rejects a `region` zoom against non-image inputs before any upload.
256
277
  *
257
278
  * @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.
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 localPath - Local file path (already materialized for URLs)
263
- * @param urlContentType - Server-reported content type for downloaded URLs
264
- * @returns Effective MIME type
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
- resolveEffectiveContentType(localPath, urlContentType) {
267
- const detected = this.detectMimeType(localPath);
268
- if (detected !== "application/octet-stream") {
269
- return detected;
288
+ rejectRegionForNonImage(region, inputs) {
289
+ if (!region || inputs.length !== 1) {
290
+ return;
270
291
  }
271
- if (urlContentType && urlContentType.trim() && urlContentType !== "application/octet-stream") {
272
- return urlContentType.trim();
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
- * Removes internal identifiers (upload ids, routing-only values, etc.) from the
278
- * extraction payload before returning it to the model.
298
+ * Rejects tuning knobs that the resolved content type would silently ignore.
279
299
  *
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.
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 value - Unknown value to sanitize
291
- * @returns Sanitized value
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
- 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;
305
- }
306
- if (key === "detected_media_type") {
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
- if (key === "analysis_mode_requested" || key === "analysis_mode_used") {
310
- continue;
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 (key === "multipass") {
313
- continue;
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
- if (key === "model") {
316
- continue;
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
- * Ensures the sanitized extraction value is a plain JSON object.
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 value - Sanitized value
326
- * @returns Plain object
401
+ * @param params - Validated tool parameters.
402
+ * @returns Trimmed model id, or undefined for auto-dispatch.
327
403
  */
328
- asPlainObject(value) {
329
- if (value && typeof value === "object" && !Array.isArray(value)) {
330
- return value;
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
- * Validates that a path exists and is a readable file.
417
+ * Fails fast when the requested model explicitly lacks vision, before any byte.
336
418
  *
337
- * @param filePath - Local filesystem path
338
- /**
339
- * Parses and validates the optional relative image region.
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 raw - Raw `region` argument.
342
- * @returns Validated region, or undefined when absent.
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
- parseRegion(raw) {
345
- if (raw === undefined || raw === null) {
346
- return undefined;
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 (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.");
452
+ if (cached !== undefined) {
453
+ this.visionProbeCache.delete(cacheKey);
350
454
  }
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.`);
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 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.");
463
+ return;
464
+ }
465
+ const entry = findAccountModelEntry(payload, modelId);
466
+ if (entry === null) {
467
+ return;
368
468
  }
369
- return region;
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
- * Validates that a path exists and is a readable file.
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 filePath - Local filesystem path
375
- * @returns File size in bytes
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
- 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;
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
- finally {
391
- await handle.close();
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
- * Detects MIME type using file extension.
505
+ * Builds the visionless-model rejection error.
396
506
  *
397
- * @param filePath - File path
398
- * @returns MIME type string
507
+ * @param modelId - Requested model id.
508
+ * @returns Spanish-first bilingual error (no byte was uploaded).
399
509
  */
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";
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
- * Uploads multiple local images as a single EnriVision media-set tar archive.
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
- * 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.
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 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
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
- 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
- });
575
+ transcribeInapplicableWarning(params, inputs) {
576
+ if (typeof params.transcribe === "undefined") {
577
+ return undefined;
445
578
  }
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
- }
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
- if (offset !== tarSizeBytes) {
513
- throw new Error(`Subida incompleta: se enviaron ${offset} de ${tarSizeBytes} bytes.`);
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
- return session.upload_id;
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
- * Uploads a file to EnriProxy in resumable chunks.
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 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
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
- 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");
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
- 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;
626
+ this.rejectMismatchedTuning(params, [{ localPath: remoteUrl, contentType }]);
546
627
  }
547
- finally {
548
- await handle.close();
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
- * Uploads a single chunk with retry and offset resync.
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 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 });
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
- 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));
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