@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
@@ -12,38 +12,60 @@
12
12
  import { request as httpRequest } from "node:http";
13
13
  import { request as httpsRequest } from "node:https";
14
14
  import { URL } from "node:url";
15
+ import { buildClipWindowClampedWarning, optionalFraction, optionalInt, optionalNumber } from "../shared/validation.js";
16
+ import { AUDIO_KNOWN_KEYS, DOCUMENT_KNOWN_KEYS, IMAGES_KNOWN_KEYS, VIDEO_KNOWN_KEYS, } from "../tools/AnalyzeMediaParamParser.js";
17
+ import { EnriProxyHttpError, extractServerErrorDetail, } from "./EnriProxyClientContract.js";
18
+ export { EnriProxyHttpError } from "./EnriProxyClientContract.js";
15
19
  /**
16
- * Error thrown when EnriProxy returns a non-2xx HTTP response.
20
+ * Maximum clip-window bound in seconds (0-86400, 24 h).
21
+ *
22
+ * @remarks
23
+ * Mirrors `ANALYZE_MEDIA_LIMITS.maxClipSeconds` and the
24
+ * `AnalyzeMediaParamParser` clip contract so the client never silently
25
+ * coerces out-of-range windows: invalid values throw in Spanish instead.
17
26
  */
18
- export class EnriProxyHttpError extends Error {
19
- /**
20
- * HTTP status code returned by the server.
21
- */
22
- status;
23
- /**
24
- * Response headers returned by the server.
25
- */
26
- headers;
27
- /**
28
- * Response body returned by the server (best-effort UTF-8).
29
- */
30
- body;
31
- /**
32
- * Creates a new {@link EnriProxyHttpError}.
33
- *
34
- * @param message - Error message
35
- * @param status - HTTP status code
36
- * @param headers - Response headers
37
- * @param body - Response body
38
- */
39
- constructor(message, status, headers, body) {
40
- super(message);
41
- this.name = "EnriProxyHttpError";
42
- this.status = status;
43
- this.headers = headers;
44
- this.body = body;
45
- }
46
- }
27
+ const MAX_CLIP_SECONDS = 86400;
28
+ /**
29
+ * Maximum `source_url` characters the proxy ingests (EnriProxy
30
+ * `VISION_MAX_SOURCE_URL_CHARS`): longer URLs fail in the client backstop,
31
+ * before any byte travels, instead of failing at the server after cost.
32
+ */
33
+ const MAX_SOURCE_URL_CHARS = 2048;
34
+ /**
35
+ * Timeout for upload session creation (`POST /v1/uploads`, 60 s).
36
+ *
37
+ * @remarks
38
+ * Mirrors EnriCode `VisionAnalyzeMediaUploadCoordinator` (60 s create
39
+ * budget): session creation is a tiny metadata call, but it deserves more
40
+ * headroom than offset probes. Capped by the operator timeout via `Math.min`.
41
+ */
42
+ export const CREATE_CONTROL_TIMEOUT_MS = 60_000;
43
+ /**
44
+ * Timeout for upload offset probes (`HEAD /v1/uploads/:id`, 15 s).
45
+ *
46
+ * @remarks
47
+ * Mirrors EnriCode (15 s probe budget): offset queries are single-header
48
+ * reads and must fail fast. Capped by the operator timeout via `Math.min`.
49
+ */
50
+ export const PROBE_CONTROL_TIMEOUT_MS = 15_000;
51
+ /**
52
+ * Budget for the fail-open vision-capability probe (`GET /v1/account/models`, 15 s).
53
+ *
54
+ * @remarks
55
+ * Mirrors EnriCode `assertRemoteVisionCapable` and
56
+ * `ANALYZE_MEDIA_LIMITS.visionProbeTimeoutMs` (pinned equal by tests): the
57
+ * probe must never stall session creation.
58
+ */
59
+ export const ACCOUNT_MODELS_PROBE_TIMEOUT_MS = 15_000;
60
+ /**
61
+ * Timeout for best-effort orphan cleanup (`DELETE /v1/uploads/:id`).
62
+ *
63
+ * @remarks
64
+ * The cleanup call runs on an independent signal (the caller may already be
65
+ * cancelled), so it carries its own short budget and never blocks the
66
+ * error path.
67
+ */
68
+ const CLEANUP_TIMEOUT_MS = 15_000;
47
69
  /**
48
70
  * Minimal client for EnriProxy HTTP endpoints.
49
71
  */
@@ -75,6 +97,7 @@ export class EnriProxyClient {
75
97
  *
76
98
  * @param params - Session parameters
77
99
  * @returns Session response
100
+ * @throws Error with the parsed server detail when creation fails, or when the success body is not valid JSON.
78
101
  */
79
102
  async createUploadSession(params) {
80
103
  const url = this.buildUrl("/v1/uploads");
@@ -84,34 +107,79 @@ export class EnriProxyClient {
84
107
  content_type: params.contentType,
85
108
  client_trace_id: params.clientTraceId
86
109
  };
87
- const result = await this.requestJson("POST", url, payload, this.timeoutMs);
110
+ const result = await this.requestJson("POST", url, payload, this.createControlTimeoutMs(), params.signal);
88
111
  if (result.status < 200 || result.status >= 300) {
89
- throw new EnriProxyHttpError(`Falló la creación de la sesión de subida (HTTP ${result.status}).`, result.status, result.headers, result.body);
112
+ throw EnriProxyClient.buildHttpError(`Falló la creación de la sesión de subida (HTTP ${result.status}). / Upload session creation failed (HTTP ${result.status}).`, result);
90
113
  }
91
- const parsed = JSON.parse(result.body);
92
- return parsed;
114
+ return EnriProxyClient.parseJsonBody(result.body, result.status);
93
115
  }
94
116
  /**
95
117
  * Queries the current upload offset for a session.
96
118
  *
97
119
  * @param uploadId - Upload id
120
+ * @param signal - Optional cancellation signal.
98
121
  * @returns Offset in bytes
122
+ * @throws Error with the parsed server detail when the query fails.
99
123
  */
100
- async getUploadOffset(uploadId) {
124
+ async getUploadOffset(uploadId, signal) {
101
125
  const url = this.buildUrl(`/v1/uploads/${encodeURIComponent(uploadId)}`);
102
- const result = await this.requestRaw("HEAD", url, undefined, undefined, this.timeoutMs);
126
+ const result = await this.requestRaw("HEAD", url, undefined, undefined, this.probeControlTimeoutMs(), signal);
103
127
  if (result.status < 200 || result.status >= 300) {
104
- throw new EnriProxyHttpError(`Falló la consulta del offset de subida (HTTP ${result.status}).`, result.status, result.headers, result.body);
128
+ throw EnriProxyClient.buildHttpError(`Falló la consulta del offset de subida (HTTP ${result.status}). / Upload offset query failed (HTTP ${result.status}).`, result);
105
129
  }
106
- const offsetHeader = this.getHeaderValue(result.headers, "upload-offset");
107
- if (!offsetHeader) {
108
- throw new Error("Missing Upload-Offset header in server response.");
130
+ return this.parseUploadOffsetHeader(result.headers);
131
+ }
132
+ /**
133
+ * Deletes an upload session and its stored bytes (best-effort orphan cleanup).
134
+ *
135
+ * @remarks
136
+ * Mirrors `DELETE /v1/uploads/:id` on EnriProxy. Callers must invoke this
137
+ * with an independent timeout signal (never the already-cancelled caller
138
+ * signal) and swallow failures: cleanup must never mask the original
139
+ * upload/analysis error.
140
+ *
141
+ * @param uploadId - Upload id to delete.
142
+ * @param signal - Optional cancellation signal (prefer an independent timeout).
143
+ * @throws Error with an Spanish-first bilingual message when the server rejects the deletion.
144
+ */
145
+ async deleteUploadSession(uploadId, signal) {
146
+ const url = this.buildUrl(`/v1/uploads/${encodeURIComponent(uploadId)}`);
147
+ const result = await this.requestRaw("DELETE", url, undefined, undefined, CLEANUP_TIMEOUT_MS, signal);
148
+ if (result.status < 200 || result.status >= 300) {
149
+ throw EnriProxyClient.buildHttpError(`Falló la eliminación de la sesión de subida (HTTP ${result.status}). / Upload session deletion failed (HTTP ${result.status}).`, result);
150
+ }
151
+ }
152
+ /**
153
+ * Fetches the account model catalog for the fail-open vision probe.
154
+ *
155
+ * @remarks
156
+ * Thin `GET /v1/account/models` reader for `AnalyzeMediaTool` (mirrors
157
+ * EnriCode `assertRemoteVisionCapable`): callers treat every failure as
158
+ * fail-open (proceed with the upload) and only reject on an explicit
159
+ * `vision === false` match, so a stale discovery snapshot never blocks a
160
+ * valid upload. Non-2xx responses throw {@link EnriProxyHttpError} (the
161
+ * caller uses the status to invalidate cached verdicts on 401/404);
162
+ * non-JSON 2xx bodies throw an Spanish-first bilingual error.
163
+ *
164
+ * @param signal - Optional cancellation signal.
165
+ * @returns Parsed response body.
166
+ * @throws Error with an Spanish-first bilingual message when the probe request fails.
167
+ */
168
+ async getAccountModels(signal) {
169
+ const url = this.buildUrl("/v1/account/models");
170
+ const timeoutMs = Number.isFinite(this.timeoutMs) && this.timeoutMs > 0
171
+ ? Math.min(this.timeoutMs, ACCOUNT_MODELS_PROBE_TIMEOUT_MS)
172
+ : ACCOUNT_MODELS_PROBE_TIMEOUT_MS;
173
+ const result = await this.requestRaw("GET", url, undefined, undefined, timeoutMs, signal);
174
+ if (result.status < 200 || result.status >= 300) {
175
+ throw EnriProxyClient.buildHttpError(`Falló la consulta de modelos de la cuenta (HTTP ${result.status}). / Account models query failed (HTTP ${result.status}).`, result);
176
+ }
177
+ try {
178
+ return JSON.parse(result.body);
109
179
  }
110
- const offset = Number.parseInt(offsetHeader, 10);
111
- if (!Number.isFinite(offset) || offset < 0) {
112
- throw new Error(`Invalid Upload-Offset header: ${offsetHeader}`);
180
+ catch {
181
+ throw new Error(`La respuesta del servidor no es JSON válido (HTTP ${String(result.status)}). / Server response is not valid JSON (HTTP ${String(result.status)}).`);
113
182
  }
114
- return offset;
115
183
  }
116
184
  /**
117
185
  * Appends a chunk to an upload session.
@@ -127,78 +195,113 @@ export class EnriProxyClient {
127
195
  "Upload-Offset": String(params.offset),
128
196
  "Content-Length": String(params.chunk.length)
129
197
  };
130
- const result = await this.requestRaw("PATCH", url, headers, params.chunk, timeoutMs);
198
+ const result = await this.requestRaw("PATCH", url, headers, params.chunk, timeoutMs, params.signal);
131
199
  if (result.status < 200 || result.status >= 300) {
132
- throw new EnriProxyHttpError(`Falló la subida del fragmento (HTTP ${result.status}).`, result.status, result.headers, result.body);
200
+ throw EnriProxyClient.buildHttpError(`Falló la subida del fragmento (HTTP ${result.status}). / Chunk upload failed (HTTP ${result.status}).`, result);
133
201
  }
134
202
  const offsetHeader = this.getHeaderValue(result.headers, "upload-offset");
135
203
  if (!offsetHeader) {
136
- throw new Error("Missing Upload-Offset header in server response.");
204
+ throw new Error("Falta el encabezado Upload-Offset en la respuesta del servidor. / Missing Upload-Offset header in the server response.");
137
205
  }
138
- const newOffset = Number.parseInt(offsetHeader, 10);
139
- if (!Number.isFinite(newOffset) || newOffset < 0) {
140
- throw new Error(`Invalid Upload-Offset header: ${offsetHeader}`);
141
- }
142
- return newOffset;
206
+ return this.parseUploadOffsetHeader(result.headers);
143
207
  }
144
208
  /**
145
209
  * Triggers server-side vision analysis for an uploaded file.
146
210
  *
147
211
  * @param params - Analysis parameters
148
212
  * @returns Analysis response
213
+ * @throws Error with the parsed server detail when analysis fails, or when the success body is not valid JSON.
149
214
  */
150
215
  async analyze(params) {
151
216
  const url = this.buildUrl("/v1/vision/analyze");
152
- const payload = {
153
- upload_id: params.uploadId
154
- };
217
+ const localWarnings = [];
218
+ // Pre-upload guards mirroring AnalyzeMediaParamParser: typos must fail
219
+ // here, not after paying a full-file upload plus server analysis.
220
+ EnriProxyClient.requirePreUploadTuning(params);
221
+ const uploadId = typeof params.uploadId === "string" && params.uploadId.trim().length > 0 ? params.uploadId.trim() : undefined;
222
+ const sourceUrl = typeof params.sourceUrl === "string" && params.sourceUrl.trim().length > 0 ? params.sourceUrl.trim() : undefined;
223
+ if ((uploadId === undefined) === (sourceUrl === undefined)) {
224
+ throw new Error("Proporcione exactamente uno de 'uploadId' o 'sourceUrl'. / Provide exactly one of 'uploadId' or 'sourceUrl'.");
225
+ }
226
+ if (typeof sourceUrl === "string" && Array.from(sourceUrl).length > MAX_SOURCE_URL_CHARS) {
227
+ throw new Error(`sourceUrl excede el límite de ingesta del servidor de ${String(MAX_SOURCE_URL_CHARS)} caracteres (EnriProxy VISION_MAX_SOURCE_URL_CHARS); use una URL más corta. / sourceUrl exceeds the ${String(MAX_SOURCE_URL_CHARS)}-char server ingest limit (EnriProxy VISION_MAX_SOURCE_URL_CHARS); use a shorter URL.`);
228
+ }
229
+ // Fail fast on prompt budgets the server 400s after upload cost
230
+ // (mirrors AnalyzeMediaParamParser + EnriCode request records).
231
+ EnriProxyClient.requireBoundedPromptText(params.question, "question");
232
+ EnriProxyClient.requireBoundedPromptText(params.context, "context");
233
+ if (typeof params.model === "string" && Array.from(params.model.trim()).length > 128) {
234
+ throw new Error("model excede el máximo de 128 caracteres. / model exceeds the 128-char maximum.");
235
+ }
236
+ const payload = uploadId !== undefined ? { upload_id: uploadId } : { source_url: sourceUrl };
237
+ if (typeof params.model === "string" && params.model.trim()) {
238
+ payload["model"] = params.model.trim();
239
+ }
155
240
  if (typeof params.context === "string" && params.context.trim())
156
241
  payload["context"] = params.context.trim();
157
242
  if (typeof params.question === "string" && params.question.trim())
158
243
  payload["question"] = params.question.trim();
159
244
  if (typeof params.language === "string" && params.language.trim())
160
245
  payload["language"] = params.language.trim();
161
- if (typeof params.maxFrames === "number" && Number.isFinite(params.maxFrames))
162
- payload["max_frames"] = params.maxFrames;
163
- if (typeof params.transcribe === "boolean")
164
- payload["transcribe"] = params.transcribe;
246
+ const maxFrames = EnriProxyClient.requireOptionalInt(params.maxFrames, "max_frames", 1, 20);
247
+ if (typeof maxFrames !== "undefined") {
248
+ payload["max_frames"] = maxFrames;
249
+ }
250
+ // EnriCode always sends an explicit `request.transcribe ?? true`: omitting
251
+ // the knob would defer to the operator's server-side `transcribe_by_default`
252
+ // (default true, configurable), making MCP behavior diverge from EnriCode
253
+ // on hardened servers. Defaulting here keeps both clients identical.
254
+ payload["transcribe"] = EnriProxyClient.requireTranscribe(params.transcribe);
165
255
  if (typeof params.transcriptionLanguage === "string" && params.transcriptionLanguage.trim()) {
166
256
  payload["transcription_language"] = params.transcriptionLanguage.trim();
167
257
  }
168
258
  if (typeof params.analysisMode === "string" && params.analysisMode.trim()) {
169
259
  payload["analysis_mode"] = params.analysisMode.trim();
170
260
  }
171
- if (typeof params.region === "object" &&
172
- params.region !== null &&
173
- Number.isFinite(params.region.x) &&
174
- Number.isFinite(params.region.y) &&
175
- Number.isFinite(params.region.width) &&
176
- Number.isFinite(params.region.height)) {
177
- payload["region"] = {
178
- x: params.region.x,
179
- y: params.region.y,
180
- width: params.region.width,
181
- height: params.region.height
182
- };
261
+ if (typeof params.region === "object" && params.region !== null) {
262
+ payload["region"] = EnriProxyClient.requireValidRegion(params.region);
183
263
  }
184
264
  if (params.video && typeof params.video === "object") {
185
265
  const videoPayload = {};
186
- if (typeof params.video.clipStartSeconds === "number" && Number.isFinite(params.video.clipStartSeconds)) {
187
- videoPayload["clip_start_seconds"] = Math.max(0, params.video.clipStartSeconds);
266
+ const requestedClipDuration = requireClipDuration(params.video.clipDurationSeconds);
267
+ let clipDurationSeconds = requestedClipDuration;
268
+ // A window anchored only by duration still starts at 0: synthesize
269
+ // it so the server never defaults the offset. Mirrors EnriCode
270
+ // VisionAnalyzeMediaRequestRecords (clip_start_seconds travels even
271
+ // when 0 while a window exists) and AnalyzeMediaParamParser.
272
+ const clipStartRaw = requireClipBound(params.video.clipStartSeconds, "video.clip_start_seconds");
273
+ const clipStartSeconds = typeof clipStartRaw !== "undefined"
274
+ ? clipStartRaw
275
+ : (typeof clipDurationSeconds !== "undefined" ? 0 : undefined);
276
+ if (typeof clipStartSeconds !== "undefined") {
277
+ videoPayload["clip_start_seconds"] = clipStartSeconds;
278
+ }
279
+ // Parity clamp backstop (the tool parser already clamps with a
280
+ // Spanish warning): an overflowing direct-client window is clamped
281
+ // to the 24 h range instead of failing, mirroring the proxy trim.
282
+ if (typeof clipStartSeconds !== "undefined"
283
+ && typeof clipDurationSeconds !== "undefined"
284
+ && clipStartSeconds + clipDurationSeconds > MAX_CLIP_SECONDS) {
285
+ if (clipStartSeconds >= MAX_CLIP_SECONDS) {
286
+ throw new Error(`video.clip_start_seconds (${String(clipStartSeconds)}) ya llegó al límite de ${String(MAX_CLIP_SECONDS)} segundos (24 h): baje el inicio para dejar una ventana analizable. / video.clip_start_seconds (${String(clipStartSeconds)}) already reached the ${String(MAX_CLIP_SECONDS)} s limit (24 h): lower the start to leave an analyzable window.`);
287
+ }
288
+ clipDurationSeconds = MAX_CLIP_SECONDS - clipStartSeconds;
289
+ localWarnings.push(buildClipWindowClampedWarning(clipStartSeconds, requestedClipDuration ?? clipDurationSeconds, clipDurationSeconds, MAX_CLIP_SECONDS));
188
290
  }
189
- if (typeof params.video.clipDurationSeconds === "number" &&
190
- Number.isFinite(params.video.clipDurationSeconds) &&
191
- params.video.clipDurationSeconds > 0) {
192
- videoPayload["clip_duration_seconds"] = params.video.clipDurationSeconds;
291
+ if (typeof clipDurationSeconds !== "undefined") {
292
+ videoPayload["clip_duration_seconds"] = clipDurationSeconds;
193
293
  }
194
- if (typeof params.video.segmentSeconds === "number" && Number.isFinite(params.video.segmentSeconds)) {
195
- videoPayload["segment_seconds"] = params.video.segmentSeconds;
294
+ const segmentSeconds = EnriProxyClient.requireOptionalNumber(params.video.segmentSeconds, "video.segment_seconds", 5, 600);
295
+ if (typeof segmentSeconds !== "undefined") {
296
+ videoPayload["segment_seconds"] = segmentSeconds;
196
297
  }
197
- if (typeof params.video.maxSegments === "number" && Number.isFinite(params.video.maxSegments)) {
198
- videoPayload["max_segments"] = Math.floor(params.video.maxSegments);
298
+ const maxSegments = EnriProxyClient.requireOptionalInt(params.video.maxSegments, "video.max_segments", 1, 60);
299
+ if (typeof maxSegments !== "undefined") {
300
+ videoPayload["max_segments"] = maxSegments;
199
301
  }
200
- if (typeof params.video.maxFramesPerSegment === "number" && Number.isFinite(params.video.maxFramesPerSegment)) {
201
- videoPayload["max_frames_per_segment"] = Math.floor(params.video.maxFramesPerSegment);
302
+ const maxFramesPerSegment = EnriProxyClient.requireOptionalInt(params.video.maxFramesPerSegment, "video.max_frames_per_segment", 1, 20);
303
+ if (typeof maxFramesPerSegment !== "undefined") {
304
+ videoPayload["max_frames_per_segment"] = maxFramesPerSegment;
202
305
  }
203
306
  if (Object.keys(videoPayload).length > 0) {
204
307
  payload["video"] = videoPayload;
@@ -206,18 +309,22 @@ export class EnriProxyClient {
206
309
  }
207
310
  if (params.document && typeof params.document === "object") {
208
311
  const documentPayload = {};
209
- if (typeof params.document.maxPagesTotal === "number" && Number.isFinite(params.document.maxPagesTotal)) {
210
- documentPayload["max_pages_total"] = Math.floor(params.document.maxPagesTotal);
312
+ const maxPagesTotal = EnriProxyClient.requireOptionalInt(params.document.maxPagesTotal, "document.max_pages_total", 1, 200);
313
+ if (typeof maxPagesTotal !== "undefined") {
314
+ documentPayload["max_pages_total"] = maxPagesTotal;
211
315
  }
212
- if (typeof params.document.pagesPerBatch === "number" && Number.isFinite(params.document.pagesPerBatch)) {
213
- documentPayload["pages_per_batch"] = Math.floor(params.document.pagesPerBatch);
316
+ const pagesPerBatch = EnriProxyClient.requireOptionalInt(params.document.pagesPerBatch, "document.pages_per_batch", 1, 200);
317
+ if (typeof pagesPerBatch !== "undefined") {
318
+ documentPayload["pages_per_batch"] = pagesPerBatch;
214
319
  }
215
- if (typeof params.document.maxImagesPerBatch === "number" && Number.isFinite(params.document.maxImagesPerBatch)) {
216
- documentPayload["max_images_per_batch"] = Math.floor(params.document.maxImagesPerBatch);
320
+ EnriProxyClient.throwOnBatchExceedingTotal(pagesPerBatch, maxPagesTotal, "document");
321
+ const maxImagesPerBatch = EnriProxyClient.requireOptionalInt(params.document.maxImagesPerBatch, "document.max_images_per_batch", 0, 20);
322
+ if (typeof maxImagesPerBatch !== "undefined") {
323
+ documentPayload["max_images_per_batch"] = maxImagesPerBatch;
217
324
  }
218
- if (typeof params.document.scannedTextThresholdChars === "number" &&
219
- Number.isFinite(params.document.scannedTextThresholdChars)) {
220
- documentPayload["scanned_text_threshold_chars"] = Math.floor(params.document.scannedTextThresholdChars);
325
+ const scannedTextThresholdChars = EnriProxyClient.requireOptionalInt(params.document.scannedTextThresholdChars, "document.scanned_text_threshold_chars", 0, 5000);
326
+ if (typeof scannedTextThresholdChars !== "undefined") {
327
+ documentPayload["scanned_text_threshold_chars"] = scannedTextThresholdChars;
221
328
  }
222
329
  if (Object.keys(documentPayload).length > 0) {
223
330
  payload["document"] = documentPayload;
@@ -225,14 +332,17 @@ export class EnriProxyClient {
225
332
  }
226
333
  if (params.audio && typeof params.audio === "object") {
227
334
  const audioPayload = {};
228
- if (typeof params.audio.timestamps === "boolean") {
229
- audioPayload["timestamps"] = params.audio.timestamps;
335
+ const timestamps = EnriProxyClient.requireOptionalBoolean(params.audio.timestamps, "audio.timestamps");
336
+ if (typeof timestamps !== "undefined") {
337
+ audioPayload["timestamps"] = timestamps;
230
338
  }
231
- if (typeof params.audio.segmentSeconds === "number" && Number.isFinite(params.audio.segmentSeconds)) {
232
- audioPayload["segment_seconds"] = params.audio.segmentSeconds;
339
+ const audioSegmentSeconds = EnriProxyClient.requireOptionalNumber(params.audio.segmentSeconds, "audio.segment_seconds", 5, 600);
340
+ if (typeof audioSegmentSeconds !== "undefined") {
341
+ audioPayload["segment_seconds"] = audioSegmentSeconds;
233
342
  }
234
- if (typeof params.audio.maxSegments === "number" && Number.isFinite(params.audio.maxSegments)) {
235
- audioPayload["max_segments"] = Math.floor(params.audio.maxSegments);
343
+ const audioMaxSegments = EnriProxyClient.requireOptionalInt(params.audio.maxSegments, "audio.max_segments", 1, 60);
344
+ if (typeof audioMaxSegments !== "undefined") {
345
+ audioPayload["max_segments"] = audioMaxSegments;
236
346
  }
237
347
  if (Object.keys(audioPayload).length > 0) {
238
348
  payload["audio"] = audioPayload;
@@ -240,34 +350,539 @@ export class EnriProxyClient {
240
350
  }
241
351
  if (params.images && typeof params.images === "object") {
242
352
  const imagesPayload = {};
243
- if (typeof params.images.maxImagesTotal === "number" && Number.isFinite(params.images.maxImagesTotal)) {
244
- imagesPayload["max_images_total"] = Math.floor(params.images.maxImagesTotal);
353
+ const maxImagesTotal = EnriProxyClient.requireOptionalInt(params.images.maxImagesTotal, "images.max_images_total", 1, 500);
354
+ if (typeof maxImagesTotal !== "undefined") {
355
+ imagesPayload["max_images_total"] = maxImagesTotal;
245
356
  }
246
- if (typeof params.images.imagesPerBatch === "number" && Number.isFinite(params.images.imagesPerBatch)) {
247
- imagesPayload["images_per_batch"] = Math.floor(params.images.imagesPerBatch);
357
+ const imagesPerBatch = EnriProxyClient.requireOptionalInt(params.images.imagesPerBatch, "images.images_per_batch", 1, 20);
358
+ if (typeof imagesPerBatch !== "undefined") {
359
+ imagesPayload["images_per_batch"] = imagesPerBatch;
248
360
  }
249
- if (typeof params.images.maxDimension === "number" && Number.isFinite(params.images.maxDimension)) {
250
- imagesPayload["max_dimension"] = Math.floor(params.images.maxDimension);
361
+ EnriProxyClient.throwOnBatchExceedingTotal(imagesPerBatch, maxImagesTotal, "images");
362
+ const maxDimension = EnriProxyClient.requireOptionalInt(params.images.maxDimension, "images.max_dimension", 256, 4096);
363
+ if (typeof maxDimension !== "undefined") {
364
+ imagesPayload["max_dimension"] = maxDimension;
251
365
  }
252
366
  if (Object.keys(imagesPayload).length > 0) {
253
367
  payload["images"] = imagesPayload;
254
368
  }
255
369
  }
256
- const result = await this.requestJson("POST", url, payload, this.timeoutMs);
370
+ const analyzeTimeoutMs = typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0
371
+ ? Math.floor(params.timeoutMs)
372
+ : this.timeoutMs;
373
+ const result = await this.requestJson("POST", url, payload, analyzeTimeoutMs, params.signal);
374
+ if (result.status < 200 || result.status >= 300) {
375
+ throw EnriProxyClient.buildHttpError(`El análisis de visión falló (HTTP ${result.status}). / Vision analysis failed (HTTP ${result.status}).`, result);
376
+ }
377
+ return EnriProxyClient.requireValidAnalyzeResponse(EnriProxyClient.parseJsonBody(result.body, result.status), localWarnings);
378
+ }
379
+ /**
380
+ * Reads one continuation window over a truncated media list.
381
+ *
382
+ * @param params - Cursor plus optional offset/limit.
383
+ * @returns Page entries with totals and continuation state.
384
+ * @throws Error with a Spanish-first bilingual message on invalid
385
+ * cursors, expired cursors, or malformed responses.
386
+ */
387
+ async fetchSegmentPage(params) {
388
+ const source = (params ?? {});
389
+ const cursor = source["cursor"];
390
+ if (typeof cursor !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(cursor)) {
391
+ throw new Error("cursor debe ser el cursor opaco devuelto en una respuesta truncada. / cursor must be the opaque cursor from a truncated response.");
392
+ }
393
+ const offset = source["offset"];
394
+ if (typeof offset !== "undefined" && (typeof offset !== "number" || !Number.isFinite(offset) || offset < 0)) {
395
+ throw new Error("offset debe ser un entero mayor o igual que 0. / offset must be an integer greater than or equal to 0.");
396
+ }
397
+ const limit = source["limit"];
398
+ if (typeof limit !== "undefined" && (typeof limit !== "number" || !Number.isFinite(limit) || limit < 1)) {
399
+ throw new Error("limit debe ser un entero mayor o igual que 1. / limit must be an integer greater than or equal to 1.");
400
+ }
401
+ const url = this.buildUrl("/v1/vision/segments");
402
+ const payload = { cursor };
403
+ if (typeof offset !== "undefined") {
404
+ payload["offset"] = Math.floor(offset);
405
+ }
406
+ if (typeof limit !== "undefined") {
407
+ payload["limit"] = Math.floor(limit);
408
+ }
409
+ const timeoutMs = typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) && params.timeoutMs > 0
410
+ ? Math.floor(params.timeoutMs)
411
+ : this.timeoutMs;
412
+ const result = await this.requestJson("POST", url, payload, timeoutMs, params.signal);
257
413
  if (result.status < 200 || result.status >= 300) {
258
- throw new EnriProxyHttpError(`El análisis de visión falló (HTTP ${result.status}).`, result.status, result.headers, result.body);
414
+ throw EnriProxyClient.buildHttpError(`La lectura de continuación falló (HTTP ${result.status}). / Continuation read failed (HTTP ${result.status}).`, result);
415
+ }
416
+ return EnriProxyClient.requireValidSegmentPage(EnriProxyClient.parseJsonBody(result.body, result.status));
417
+ }
418
+ /**
419
+ * Validates one continuation page body.
420
+ *
421
+ * @param raw - Parsed response body.
422
+ * @returns Validated page.
423
+ * @throws Error with a Spanish-first bilingual message when the body
424
+ * carries no usable page.
425
+ */
426
+ static requireValidSegmentPage(raw) {
427
+ const record = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? raw : {};
428
+ if (!Array.isArray(record["entries"])) {
429
+ throw new Error("La respuesta de continuación no trae entries. / Continuation response carries no entries.");
430
+ }
431
+ const total = record["total"];
432
+ if (typeof total !== "number" || !Number.isFinite(total) || total < 0) {
433
+ throw new Error("La respuesta de continuación no trae total. / Continuation response carries no total.");
434
+ }
435
+ const nextOffset = record["next_offset"];
436
+ if (typeof nextOffset !== "number" || !Number.isFinite(nextOffset) || nextOffset < 0) {
437
+ throw new Error("La respuesta de continuación no trae next_offset. / Continuation response carries no next_offset.");
438
+ }
439
+ return {
440
+ entries: record["entries"],
441
+ total: Math.floor(total),
442
+ hasMore: record["has_more"] === true,
443
+ nextOffset: Math.floor(nextOffset),
444
+ cursor: typeof record["cursor"] === "string" ? record["cursor"] : "",
445
+ };
446
+ }
447
+ /**
448
+ * Rejects oversized prompt text before any byte is uploaded.
449
+ *
450
+ * @remarks
451
+ * Mirrors the parser gate (2000 chars): the server 400s after upload cost.
452
+ *
453
+ * @param value - Optional prompt text.
454
+ * @param fieldName - `question` or `context`.
455
+ * @throws Error in Spanish naming the 2000-character cap.
456
+ */
457
+ static requireBoundedPromptText(value, fieldName) {
458
+ if (typeof value === "undefined") {
459
+ return;
460
+ }
461
+ // Strict like the tool parser: a non-string prompt is a caller bug,
462
+ // not an empty prompt.
463
+ if (typeof value !== "string") {
464
+ throw new Error(`${fieldName} debe ser una cadena de texto. / ${fieldName} must be a string.`);
465
+ }
466
+ if (Array.from(value).length > 2000) {
467
+ throw new Error(`${fieldName} excede el máximo de 2000 caracteres. Acorte el texto y reintente: el servidor rechaza este mismo tope después de cobrar el upload. / ${fieldName} exceeds the 2000-char maximum. Shorten the text and retry: the server rejects this same cap after charging the upload.`);
468
+ }
469
+ }
470
+ /**
471
+ * Rejects mistyped tuning before any upload cost on the direct-client path.
472
+ *
473
+ * @remarks
474
+ * Mirrors `AnalyzeMediaParamParser` (enum, language pattern, section
475
+ * key closure): direct callers bypass the tool parser, and a typo would
476
+ * otherwise analyze the whole file at full cost. Exposed for reuse and
477
+ * unit-pinned against the parser key sets.
478
+ *
479
+ * @param params - Analysis parameters about to travel.
480
+ * @throws Error with a Spanish-first bilingual message on the first defect.
481
+ */
482
+ static requirePreUploadTuning(params) {
483
+ if (typeof params.analysisMode === "string" && params.analysisMode.trim()) {
484
+ const mode = params.analysisMode.trim();
485
+ if (mode !== "auto" && mode !== "single" && mode !== "multipass") {
486
+ throw new Error("analysis_mode debe ser uno de: auto|single|multipass. / analysis_mode must be one of: auto|single|multipass.");
487
+ }
488
+ }
489
+ EnriProxyClient.requireLanguageHint(params.language, "language");
490
+ EnriProxyClient.requireLanguageHint(params.transcriptionLanguage, "transcription_language");
491
+ EnriProxyClient.requireKnownSectionKeys(params.video, VIDEO_KNOWN_KEYS, "video");
492
+ EnriProxyClient.requireKnownSectionKeys(params.document, DOCUMENT_KNOWN_KEYS, "document");
493
+ EnriProxyClient.requireKnownSectionKeys(params.audio, AUDIO_KNOWN_KEYS, "audio");
494
+ EnriProxyClient.requireKnownSectionKeys(params.images, IMAGES_KNOWN_KEYS, "images");
495
+ }
496
+ /**
497
+ * Validates one language hint against the parser pattern.
498
+ *
499
+ * @param value - Candidate hint.
500
+ * @param fieldName - Dotted field name for error messages.
501
+ * @returns Nothing.
502
+ * @throws Error with a Spanish-first bilingual message on mismatch.
503
+ */
504
+ static requireLanguageHint(value, fieldName) {
505
+ if (typeof value === "undefined") {
506
+ return;
507
+ }
508
+ if (typeof value !== "string") {
509
+ throw new Error(`${fieldName} debe ser una cadena de texto. / ${fieldName} must be a string.`);
510
+ }
511
+ const trimmed = value.trim();
512
+ if (trimmed.length === 0) {
513
+ return;
514
+ }
515
+ if (trimmed.length > 32 || !/^[A-Za-z]{2,8}([-_][A-Za-z0-9]{1,8}){0,2}$/.test(trimmed)) {
516
+ throw new Error(`${fieldName} debe ser un código de idioma como 'es', 'en' o 'auto' (máximo 32 caracteres). / ${fieldName} must be a language code like 'es', 'en', or 'auto' (max 32 chars).`);
517
+ }
518
+ }
519
+ /**
520
+ * Rejects unknown keys inside one tuning section.
521
+ *
522
+ * @param section - Candidate section object.
523
+ * @param knownKeys - Parser-owned accepted spellings.
524
+ * @param sectionName - Section name for error messages.
525
+ * @returns Nothing.
526
+ * @throws Error with a Spanish-first bilingual message on unknown keys.
527
+ */
528
+ static requireKnownSectionKeys(section, knownKeys, sectionName) {
529
+ if (typeof section === "undefined") {
530
+ return;
531
+ }
532
+ if (section === null || typeof section !== "object" || Array.isArray(section)) {
533
+ throw new Error(`${sectionName} debe ser un objeto. / ${sectionName} must be an object.`);
534
+ }
535
+ const unknown = Object.keys(section).filter((key) => !knownKeys.has(key));
536
+ if (unknown.length > 0) {
537
+ throw new Error(`${sectionName} tiene claves desconocidas (${unknown.join(", ")}): revise la escritura. / ${sectionName} has unknown keys (${unknown.join(", ")}): check the spelling.`);
538
+ }
539
+ }
540
+ /**
541
+ * Rejects per-batch sizes exceeding their totals on the direct-client path.
542
+ *
543
+ * @remarks
544
+ * The tool parser already guards this; direct callers bypass it, and the
545
+ * server would truncate after paid multipass work.
546
+ *
547
+ * @param perBatch - Per-batch value, or undefined when absent.
548
+ * @param total - Total value, or undefined when absent.
549
+ * @param family - `document` or `images`.
550
+ * @throws Error with an Spanish-first bilingual message when the batch exceeds the total.
551
+ */
552
+ static throwOnBatchExceedingTotal(perBatch, total, family) {
553
+ if (typeof perBatch !== "undefined" && typeof total !== "undefined" && perBatch > total) {
554
+ const batchField = family === "document" ? "pages_per_batch" : "images_per_batch";
555
+ const totalField = family === "document" ? "max_pages_total" : "max_images_total";
556
+ throw new Error(`${family}.${batchField} supera a ${totalField} (${String(perBatch)} > ${String(total)}): el lote no puede ser mayor que el total. / ${family}.${batchField} exceeds ${totalField} (${String(perBatch)} > ${String(total)}): a batch cannot be greater than the total.`);
557
+ }
558
+ }
559
+ /**
560
+ * Coerces an optional integer tuning knob, failing loudly on garbage.
561
+ *
562
+ * @remarks
563
+ * Parity with `AnalyzeMediaParamParser` (`optionalInt` accepts `"60"`):
564
+ * integers and complete integer strings travel floored; any other present
565
+ * value throws bilingually instead of being silently dropped (a dropped
566
+ * knob would analyze the whole file at full cost with default tuning).
567
+ *
568
+ * @param raw - Raw knob value.
569
+ * @param fieldName - Dotted field name for error messages.
570
+ * @param min - Inclusive minimum (shares the parser range table so direct
571
+ * callers fail pre-upload like parser-gated calls).
572
+ * @param max - Inclusive maximum (shares the parser range table).
573
+ * @returns Floored integer, or undefined when absent.
574
+ * @throws Error with an Spanish-first bilingual message when present but not an integer within range.
575
+ */
576
+ static requireOptionalInt(raw, fieldName, min, max) {
577
+ if (typeof raw === "undefined") {
578
+ return undefined;
579
+ }
580
+ const parsed = optionalInt(raw);
581
+ if (typeof parsed === "undefined" || !Number.isFinite(parsed)) {
582
+ throw new Error(`${fieldName} debe ser un entero (también vale su forma string como "60"). / ${fieldName} must be an integer (its string form like "60" also works).`);
583
+ }
584
+ const floored = Math.floor(parsed);
585
+ if (typeof min === "number" && typeof max === "number" && (floored < min || floored > max)) {
586
+ throw new Error(`${fieldName} debe ser un entero entre ${String(min)} y ${String(max)} (se recibió ${String(floored)}). / ${fieldName} must be an integer between ${String(min)} and ${String(max)} (got ${String(floored)}).`);
587
+ }
588
+ return floored;
589
+ }
590
+ /**
591
+ * Coerces an optional float tuning knob, failing loudly on garbage.
592
+ *
593
+ * @remarks
594
+ * Parity with `AnalyzeMediaParamParser` (`optionalNumber` accepts
595
+ * `"12.5"`): numbers and complete numeric strings travel; any other
596
+ * present value throws bilingually instead of being silently dropped.
597
+ *
598
+ * @param raw - Raw knob value.
599
+ * @param fieldName - Dotted field name for error messages.
600
+ * @param min - Inclusive minimum (shares the parser range table).
601
+ * @param max - Inclusive maximum (shares the parser range table).
602
+ * @returns Finite number, or undefined when absent.
603
+ * @throws Error with an Spanish-first bilingual message when present but not a number within range.
604
+ */
605
+ static requireOptionalNumber(raw, fieldName, min, max) {
606
+ if (typeof raw === "undefined") {
607
+ return undefined;
608
+ }
609
+ const parsed = typeof raw === "number" && Number.isFinite(raw) ? raw : optionalNumber(raw);
610
+ if (typeof parsed === "undefined" || !Number.isFinite(parsed)) {
611
+ throw new Error(`${fieldName} debe ser un número (también vale su forma string como "12.5"). / ${fieldName} must be a number (its string form like "12.5" also works).`);
612
+ }
613
+ if (typeof min === "number" && typeof max === "number" && (parsed < min || parsed > max)) {
614
+ throw new Error(`${fieldName} debe ser un número entre ${String(min)} y ${String(max)} (se recibió ${String(parsed)}). / ${fieldName} must be a number between ${String(min)} and ${String(max)} (got ${String(parsed)}).`);
259
615
  }
260
- const parsed = JSON.parse(result.body);
261
616
  return parsed;
262
617
  }
618
+ /**
619
+ * Coerces an optional boolean tuning knob, failing loudly on garbage.
620
+ *
621
+ * @remarks
622
+ * Parity with `AnalyzeMediaParamParser` (`assertOptionalBoolean` accepts
623
+ * `"true"`/`"false"`): booleans and true/false strings travel; any
624
+ * other present value throws bilingually instead of being silently dropped.
625
+ *
626
+ * @param raw - Raw knob value.
627
+ * @param fieldName - Dotted field name for error messages.
628
+ * @returns Boolean, or undefined when absent.
629
+ * @throws Error with an Spanish-first bilingual message when present but not a boolean.
630
+ */
631
+ static requireOptionalBoolean(raw, fieldName) {
632
+ if (typeof raw === "undefined") {
633
+ return undefined;
634
+ }
635
+ if (typeof raw === "boolean") {
636
+ return raw;
637
+ }
638
+ if (typeof raw === "string") {
639
+ const normalized = raw.trim().toLowerCase();
640
+ if (normalized === "true") {
641
+ return true;
642
+ }
643
+ if (normalized === "false") {
644
+ return false;
645
+ }
646
+ }
647
+ throw new Error(`${fieldName} debe ser un booleano (true o false; también vale "true"/"false"). / ${fieldName} must be a boolean (true or false; "true"/"false" also work).`);
648
+ }
649
+ /**
650
+ * Resolves the `transcribe` knob, defaulting to true like EnriCode.
651
+ *
652
+ * @remarks
653
+ * A `"false"` string must never collapse to the `true` default (that
654
+ * would pay a full transcription the caller explicitly disabled):
655
+ * true/false strings coerce, anything else present throws in Spanish.
656
+ *
657
+ * @param raw - Raw transcribe value.
658
+ * @returns Resolved transcribe flag.
659
+ * @throws Error with an Spanish-first bilingual message when present but not a boolean nor a true/false string.
660
+ */
661
+ static requireTranscribe(raw) {
662
+ if (typeof raw === "undefined") {
663
+ return true;
664
+ }
665
+ const parsed = EnriProxyClient.requireOptionalBoolean(raw, "transcribe");
666
+ return parsed ?? true;
667
+ }
668
+ /**
669
+ * Validates a `POST /v1/vision/analyze` response body for third-party callers.
670
+ *
671
+ * @remarks
672
+ * A malformed 200 body (`{}`, `analysis: 123`, missing `media_type`)
673
+ * must surface as a Spanish coaching error, never as an English
674
+ * `TypeError` from downstream formatters (model-facing text is always
675
+ * Spanish). Malformed `elements` entries are sanitized (dropped), not
676
+ * fatal: partial grounding still analyzes.
677
+ *
678
+ * @param raw - Parsed response body.
679
+ * @returns Validated analysis response.
680
+ * @throws Error with an Spanish-first bilingual message when the body shape is invalid.
681
+ */
682
+ static requireValidAnalyzeResponse(raw, localWarnings) {
683
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
684
+ throw new Error("La respuesta del servidor es inválida: se esperaba un objeto JSON con 'analysis', 'media_type' y 'extraction'. / Server response is invalid: expected a JSON object with 'analysis', 'media_type', and 'extraction'.");
685
+ }
686
+ const record = raw;
687
+ const analysis = record["analysis"];
688
+ if (typeof analysis !== "string" || analysis.trim().length === 0) {
689
+ throw new Error("La respuesta del servidor es inválida: 'analysis' debe ser texto no vacío. / Server response is invalid: 'analysis' must be non-empty text.");
690
+ }
691
+ const mediaType = record["media_type"];
692
+ if (typeof mediaType !== "string" || mediaType.trim().length === 0) {
693
+ throw new Error("La respuesta del servidor es inválida: 'media_type' debe ser texto no vacío. / Server response is invalid: 'media_type' must be non-empty text.");
694
+ }
695
+ const extraction = record["extraction"];
696
+ if (typeof extraction !== "undefined" && (typeof extraction !== "object" || extraction === null || Array.isArray(extraction))) {
697
+ throw new Error("La respuesta del servidor es inválida: 'extraction' debe ser un objeto. / Server response is invalid: 'extraction' must be an object.");
698
+ }
699
+ const elements = record["elements"];
700
+ if (typeof elements !== "undefined" && !Array.isArray(elements)) {
701
+ throw new Error("La respuesta del servidor es inválida: 'elements' debe ser un arreglo de cajas. / Server response is invalid: 'elements' must be an array of boxes.");
702
+ }
703
+ return {
704
+ analysis,
705
+ media_type: mediaType,
706
+ extraction: (extraction ?? {}),
707
+ ...(typeof elements !== "undefined"
708
+ ? { elements: EnriProxyClient.sanitizeAnalyzeElements(elements) }
709
+ : {}),
710
+ ...(typeof localWarnings !== "undefined" && localWarnings.length > 0 ? { warnings: [...localWarnings] } : {}),
711
+ };
712
+ }
713
+ /**
714
+ * Keeps only well-formed grounded element boxes.
715
+ *
716
+ * @remarks
717
+ * Malformed entries (non-string label, non-finite box coordinates) are
718
+ * dropped so one bad box never fails the whole analysis; downstream
719
+ * formatters can assume `{label, box: {x, y, width, height}}`.
720
+ *
721
+ * @param elements - Raw elements array from the server.
722
+ * @returns Sanitized element boxes.
723
+ */
724
+ static sanitizeAnalyzeElements(elements) {
725
+ const kept = [];
726
+ for (const candidate of elements) {
727
+ if (typeof candidate !== "object" || candidate === null || Array.isArray(candidate)) {
728
+ continue;
729
+ }
730
+ const record = candidate;
731
+ if (typeof record["label"] !== "string") {
732
+ continue;
733
+ }
734
+ const box = record["box"];
735
+ if (typeof box !== "object" || box === null || Array.isArray(box)) {
736
+ continue;
737
+ }
738
+ const boxRecord = box;
739
+ const coords = [boxRecord["x"], boxRecord["y"], boxRecord["width"], boxRecord["height"]];
740
+ if (!coords.every((coord) => typeof coord === "number" && Number.isFinite(coord))) {
741
+ continue;
742
+ }
743
+ kept.push({
744
+ label: record["label"],
745
+ box: {
746
+ x: boxRecord["x"],
747
+ y: boxRecord["y"],
748
+ width: boxRecord["width"],
749
+ height: boxRecord["height"],
750
+ },
751
+ });
752
+ }
753
+ return kept;
754
+ }
755
+ /**
756
+ * Builds an {@link EnriProxyHttpError} embedding the parsed server detail.
757
+ *
758
+ * @param baseMessage - Base message naming the failed operation with its HTTP status.
759
+ * @param result - Raw HTTP result carrying headers and body.
760
+ * @returns HTTP error preserving status, headers, and body.
761
+ */
762
+ static buildHttpError(baseMessage, result) {
763
+ const detail = extractServerErrorDetail(result.body);
764
+ const message = detail ? `${baseMessage} Detalle del servidor: ${detail}` : baseMessage;
765
+ return new EnriProxyHttpError(message, result.status, result.headers, result.body);
766
+ }
767
+ /**
768
+ * Parses a 2xx JSON body with a bilingual guard for non-JSON payloads.
769
+ *
770
+ * @param body - Raw response body.
771
+ * @param status - HTTP status code (reported in the error).
772
+ * @returns Parsed body.
773
+ * @throws Error with an Spanish-first bilingual message when the body is not valid JSON.
774
+ */
775
+ static parseJsonBody(body, status) {
776
+ try {
777
+ return JSON.parse(body);
778
+ }
779
+ catch {
780
+ throw new Error(`La respuesta del servidor no es JSON válido (HTTP ${String(status)}). / Server response is not valid JSON (HTTP ${String(status)}).`);
781
+ }
782
+ }
263
783
  /**
264
784
  * Builds an absolute URL relative to the configured base URL.
265
785
  *
266
- * @param pathname - Pathname to append
786
+ * @remarks
787
+ * The base subpath is preserved (`http://host/proxy` + `/v1/uploads` =
788
+ * `http://host/proxy/v1/uploads`): `new URL(path, base)` alone would
789
+ * discard it. A root base keeps working exactly as before.
790
+ *
791
+ * @param pathname - Pathname to append (must start with `/`).
267
792
  * @returns URL instance
793
+ * @throws Error with an Spanish-first bilingual message when the configured base URL is malformed.
268
794
  */
269
795
  buildUrl(pathname) {
270
- return new URL(pathname, this.baseUrl);
796
+ try {
797
+ return new URL(`${this.baseUrl}${pathname}`);
798
+ }
799
+ catch {
800
+ throw new Error(`ENRIPROXY_URL inválida: '${this.baseUrl}'. Use una URL http(s) completa. / Invalid ENRIPROXY_URL: '${this.baseUrl}'. Use a complete http(s) URL.`);
801
+ }
802
+ }
803
+ /**
804
+ * Parses a strict `Upload-Offset` response header.
805
+ *
806
+ * @remarks
807
+ * Strict by design: only `^\d+$` (after trimming) is accepted, so
808
+ * `"12abc"` or `"1.5"` fail bilingually instead of being prefix-parsed
809
+ * (`parseInt("12abc") === 12`) and resuming at a wrong offset.
810
+ *
811
+ * @param headers - Response headers.
812
+ * @returns Offset in bytes.
813
+ * @throws Error with an Spanish-first bilingual message when the header is missing or malformed.
814
+ */
815
+ parseUploadOffsetHeader(headers) {
816
+ const offsetHeader = this.getHeaderValue(headers, "upload-offset");
817
+ if (!offsetHeader) {
818
+ throw new Error("Falta el encabezado Upload-Offset en la respuesta del servidor. / Missing Upload-Offset header in the server response.");
819
+ }
820
+ const trimmed = offsetHeader.trim();
821
+ if (!/^\d+$/u.test(trimmed)) {
822
+ throw new Error(`Encabezado Upload-Offset inválido: ${offsetHeader} / Invalid Upload-Offset header: ${offsetHeader}.`);
823
+ }
824
+ return Number.parseInt(trimmed, 10);
825
+ }
826
+ /**
827
+ * Validates a relative image region for native-resolution zoom.
828
+ *
829
+ * @remarks
830
+ * Mirrors the `AnalyzeMediaParamParser` region contract (`[0,1]` bounds,
831
+ * positive size, `x+width<=1`/`y+height<=1`, complete numeric strings
832
+ * coerced via `optionalFraction`) so direct client callers get the same
833
+ * Spanish coaching instead of a late server rejection.
834
+ *
835
+ * @param region - Raw region value.
836
+ * @returns Validated region payload.
837
+ * @throws Error with an Spanish-first bilingual message when the region is malformed or out of range.
838
+ */
839
+ static requireValidRegion(region) {
840
+ const unknownKeys = Object.keys(region).filter((key) => key !== "x" && key !== "y" && key !== "width" && key !== "height");
841
+ if (unknownKeys.length > 0) {
842
+ throw new Error(`region trae claves desconocidas (${unknownKeys.join(", ")}): se rechazan. Claves válidas: x, y, width, height. / region has unknown keys (${unknownKeys.join(", ")}): they are rejected. Valid keys: x, y, width, height.`);
843
+ }
844
+ const readFraction = (value, fieldName) => {
845
+ const parsed = typeof value === "number" && Number.isFinite(value) ? value : optionalFraction(value);
846
+ if (typeof parsed === "undefined" || parsed < 0 || parsed > 1) {
847
+ throw new Error(`region.${fieldName} debe ser un número entre 0 y 1 (coordenadas relativas a la imagen original). / region.${fieldName} must be a number between 0 and 1 (coords relative to the original image).`);
848
+ }
849
+ return parsed;
850
+ };
851
+ const valid = {
852
+ x: readFraction(region.x, "x"),
853
+ y: readFraction(region.y, "y"),
854
+ width: readFraction(region.width, "width"),
855
+ height: readFraction(region.height, "height"),
856
+ };
857
+ if (valid.width <= 0 || valid.height <= 0) {
858
+ throw new Error("region.width y region.height deben ser mayores que 0. / region.width and region.height must be greater than 0.");
859
+ }
860
+ if (valid.x + valid.width > 1 || valid.y + valid.height > 1) {
861
+ throw new Error("region debe caber en la imagen original: x+width y y+height no pueden exceder 1. / region must fit inside the original image: x+width and y+height cannot exceed 1.");
862
+ }
863
+ return valid;
864
+ }
865
+ /**
866
+ * Resolves the session-creation control timeout (60 s, mirrors EnriCode).
867
+ *
868
+ * @returns Control timeout in milliseconds (never above 60 s).
869
+ */
870
+ createControlTimeoutMs() {
871
+ if (Number.isFinite(this.timeoutMs) && this.timeoutMs > 0) {
872
+ return Math.min(this.timeoutMs, CREATE_CONTROL_TIMEOUT_MS);
873
+ }
874
+ return CREATE_CONTROL_TIMEOUT_MS;
875
+ }
876
+ /**
877
+ * Resolves the offset-probe control timeout (15 s, mirrors EnriCode).
878
+ *
879
+ * @returns Control timeout in milliseconds (never above 15 s).
880
+ */
881
+ probeControlTimeoutMs() {
882
+ if (Number.isFinite(this.timeoutMs) && this.timeoutMs > 0) {
883
+ return Math.min(this.timeoutMs, PROBE_CONTROL_TIMEOUT_MS);
884
+ }
885
+ return PROBE_CONTROL_TIMEOUT_MS;
271
886
  }
272
887
  /**
273
888
  * Extracts a response header as a single string.
@@ -296,15 +911,16 @@ export class EnriProxyClient {
296
911
  * @param url - Target URL
297
912
  * @param jsonBody - JSON payload
298
913
  * @param timeoutMs - Timeout in milliseconds
914
+ * @param signal - Optional cancellation signal.
299
915
  * @returns HTTP result
300
916
  */
301
- async requestJson(method, url, jsonBody, timeoutMs) {
917
+ async requestJson(method, url, jsonBody, timeoutMs, signal) {
302
918
  const body = JSON.stringify(jsonBody);
303
919
  const headers = {
304
920
  "Content-Type": "application/json",
305
921
  "Content-Length": String(Buffer.byteLength(body))
306
922
  };
307
- return await this.requestRaw(method, url, headers, Buffer.from(body, "utf8"), timeoutMs);
923
+ return await this.requestRaw(method, url, headers, Buffer.from(body, "utf8"), timeoutMs, signal);
308
924
  }
309
925
  /**
310
926
  * Sends an HTTP request with optional headers and body.
@@ -314,9 +930,13 @@ export class EnriProxyClient {
314
930
  * @param headers - Request headers
315
931
  * @param body - Request body
316
932
  * @param timeoutMs - Timeout in milliseconds
933
+ * @param signal - Optional cancellation signal; aborts with a Spanish error.
317
934
  * @returns HTTP result
318
935
  */
319
- async requestRaw(method, url, headers, body, timeoutMs) {
936
+ async requestRaw(method, url, headers, body, timeoutMs, signal) {
937
+ if (signal?.aborted) {
938
+ throw new Error("La solicitud fue cancelada por el cliente. / Request cancelled by the client.");
939
+ }
320
940
  const isHttps = url.protocol === "https:";
321
941
  const reqFn = isHttps ? httpsRequest : httpRequest;
322
942
  const requestHeaders = {
@@ -330,26 +950,83 @@ export class EnriProxyClient {
330
950
  }, (res) => {
331
951
  const chunks = [];
332
952
  let received = 0;
953
+ // Settles exactly once: the overflow path rejects inline (destroy
954
+ // events are unreliable once the socket teardown starts) while a
955
+ // buffered socket may still emit `end` (which would resolve with
956
+ // truncated bytes). The flag plus the overflow marker below make
957
+ // oversize bodies always reject.
958
+ let settled = false;
959
+ let overflowed = false;
333
960
  const maxResponseBytes = 50 * 1024 * 1024; // 50MB safeguard
961
+ const overflowError = () => new Error("La respuesta excedió el tamaño máximo permitido (50 MiB); se descartó, nunca truncada. / Response exceeded the maximum allowed size (50 MiB); it was discarded, never truncated.");
334
962
  res.on("data", (chunk) => {
963
+ if (settled) {
964
+ return;
965
+ }
335
966
  received += chunk.length;
336
967
  if (received > maxResponseBytes) {
337
- req.destroy(new Error("La respuesta excedió el tamaño máximo permitido."));
968
+ // Reject inline: destroying the request/response does not
969
+ // reliably surface another event (the socket teardown can
970
+ // swallow the destroy error), so the promise must settle here.
971
+ // The `end` handler below keeps the overflowed backstop for
972
+ // the same-tick race where `end` wins regardless.
973
+ overflowed = true;
974
+ settled = true;
975
+ try {
976
+ res.destroy();
977
+ }
978
+ catch {
979
+ // Best-effort: the inline rejection below carries the error.
980
+ }
981
+ try {
982
+ req.destroy();
983
+ }
984
+ catch {
985
+ // Best-effort: the inline rejection below carries the error.
986
+ }
987
+ reject(overflowError());
338
988
  return;
339
989
  }
340
990
  chunks.push(chunk);
341
991
  });
342
992
  res.on("end", () => {
993
+ signal?.removeEventListener("abort", onAbort);
994
+ req.setTimeout(0);
995
+ if (settled) {
996
+ return;
997
+ }
998
+ settled = true;
999
+ if (overflowed) {
1000
+ reject(overflowError());
1001
+ return;
1002
+ }
343
1003
  resolve({
344
1004
  status: res.statusCode ?? 0,
345
1005
  headers: res.headers,
346
1006
  body: Buffer.concat(chunks).toString("utf8")
347
1007
  });
348
1008
  });
1009
+ res.on("error", (error) => {
1010
+ signal?.removeEventListener("abort", onAbort);
1011
+ req.setTimeout(0);
1012
+ if (settled) {
1013
+ return;
1014
+ }
1015
+ settled = true;
1016
+ reject(error);
1017
+ });
1018
+ });
1019
+ const onAbort = () => {
1020
+ req.destroy(new Error("La solicitud fue cancelada por el cliente. / Request cancelled by the client."));
1021
+ };
1022
+ signal?.addEventListener("abort", onAbort, { once: true });
1023
+ req.on("error", (error) => {
1024
+ signal?.removeEventListener("abort", onAbort);
1025
+ req.setTimeout(0);
1026
+ reject(error);
349
1027
  });
350
- req.on("error", (error) => reject(error));
351
1028
  req.setTimeout(timeoutMs, () => {
352
- req.destroy(new Error(`La petición expiró después de ${timeoutMs}ms`));
1029
+ req.destroy(new Error(`La petición expiró después de ${timeoutMs}ms / Request timed out after ${timeoutMs}ms.`));
353
1030
  });
354
1031
  if (body && body.length > 0 && method !== "HEAD") {
355
1032
  req.write(body);
@@ -358,4 +1035,53 @@ export class EnriProxyClient {
358
1035
  });
359
1036
  }
360
1037
  }
1038
+ /**
1039
+ * Validates an optional clip-window start bound.
1040
+ *
1041
+ * @remarks
1042
+ * Mirrors `AnalyzeMediaParamParser` float semantics (`0-86400`): only
1043
+ * `undefined` is omitted; numbers and complete numeric strings inside the
1044
+ * range travel, otherwise it throws bilingually instead of being coerced.
1045
+ *
1046
+ * @param raw - Raw bound value.
1047
+ * @param fieldName - Dotted field name for error messages.
1048
+ * @returns Validated bound, or undefined when absent.
1049
+ * @throws Error with an Spanish-first bilingual message when present but not a number within range.
1050
+ */
1051
+ function requireClipBound(raw, fieldName) {
1052
+ if (typeof raw === "undefined") {
1053
+ return undefined;
1054
+ }
1055
+ const parsed = typeof raw === "number" && Number.isFinite(raw) ? raw : optionalNumber(raw);
1056
+ if (typeof parsed === "undefined" || !Number.isFinite(parsed) || parsed < 0 || parsed > MAX_CLIP_SECONDS) {
1057
+ throw new Error(`${fieldName} debe ser un número entre 0 y ${String(MAX_CLIP_SECONDS)} (segundos). / ${fieldName} must be a number between 0 and ${String(MAX_CLIP_SECONDS)} (seconds).`);
1058
+ }
1059
+ return parsed;
1060
+ }
1061
+ /**
1062
+ * Validates an optional clip-window duration.
1063
+ *
1064
+ * @remarks
1065
+ * Mirrors `AnalyzeMediaParamParser` duration semantics (`(0, 86400]`):
1066
+ * only `undefined` is omitted; numbers and complete numeric strings in
1067
+ * range travel, otherwise it throws bilingually instead of being silently
1068
+ * dropped.
1069
+ *
1070
+ * @param raw - Raw duration value.
1071
+ * @returns Validated duration, or undefined when absent.
1072
+ * @throws Error with an Spanish-first bilingual message when present but not a positive number within range.
1073
+ */
1074
+ function requireClipDuration(raw) {
1075
+ if (typeof raw === "undefined") {
1076
+ return undefined;
1077
+ }
1078
+ const parsed = typeof raw === "number" && Number.isFinite(raw) ? raw : optionalNumber(raw);
1079
+ if (typeof parsed === "undefined"
1080
+ || !Number.isFinite(parsed)
1081
+ || parsed <= 0
1082
+ || parsed > MAX_CLIP_SECONDS) {
1083
+ throw new Error(`video.clip_duration_seconds debe ser un número mayor que 0 y menor o igual que ${String(MAX_CLIP_SECONDS)} (segundos). / video.clip_duration_seconds must be a number greater than 0 and at most ${String(MAX_CLIP_SECONDS)} (seconds).`);
1084
+ }
1085
+ return parsed;
1086
+ }
361
1087
  //# sourceMappingURL=EnriProxyClient.js.map