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