@reqvet-sdk/sdk 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.d.ts ADDED
@@ -0,0 +1,261 @@
1
+ // @reqvet/sdk — Type definitions
2
+
3
+ export interface ReqVetOptions {
4
+ baseUrl?: string;
5
+ pollInterval?: number;
6
+ timeout?: number;
7
+ }
8
+
9
+ export interface UploadResult {
10
+ /** Canonical path to the uploaded audio in ReqVet storage */
11
+ audio_file: string;
12
+ /** Alias for audio_file (for DX / backward compatibility) */
13
+ path: string;
14
+ size_bytes: number;
15
+ content_type: string;
16
+ }
17
+
18
+ export interface JobResult {
19
+ job_id: string;
20
+ status: 'pending' | 'transcribing' | 'generating' | 'completed' | 'failed' | 'amending';
21
+ }
22
+
23
+ // ─── Field Extraction Types ─────────────────────────────────
24
+
25
+ /** Supported field types for structured extraction */
26
+ export type FieldType = 'string' | 'text' | 'number' | 'boolean' | 'array' | 'html';
27
+
28
+ /** A single field definition in an organization's field_schema */
29
+ export interface FieldDefinition {
30
+ /** Unique key in snake_case (e.g. "espece", "poids", "traitements") */
31
+ key: string;
32
+ /** Human-readable label (e.g. "Espèce", "Poids (kg)") */
33
+ label: string;
34
+ /** Data type for this field */
35
+ type: FieldType;
36
+ }
37
+
38
+ /**
39
+ * Extracted fields from a generation.
40
+ * Keys match the field_schema definitions on the organization.
41
+ * Values are typed according to the field type, or null if not mentioned.
42
+ */
43
+ export type ExtractedFields = Record<string, string | number | boolean | string[] | null>;
44
+
45
+ // ─── Report & Job Types ─────────────────────────────────────
46
+
47
+ export interface ReqVetReport {
48
+ jobId: string;
49
+ html: string;
50
+ /** Structured fields extracted from the transcription (null if org has no field_schema) */
51
+ fields: ExtractedFields | null;
52
+ transcription: string;
53
+ animalName: string;
54
+ cost: {
55
+ transcription_usd: number;
56
+ generation_usd: number;
57
+ total_usd: number;
58
+ };
59
+ metadata: Record<string, unknown>;
60
+ }
61
+
62
+ export interface Template {
63
+ id: string;
64
+ org_id: string | null;
65
+ name: string;
66
+ description: string;
67
+ content: string;
68
+ is_default: boolean;
69
+ created_at: string;
70
+ updated_at: string;
71
+ }
72
+
73
+ // ─── Params ──────────────────────────────────────────────────
74
+
75
+ export interface GenerateReportParams {
76
+ audio: Blob | Buffer | File;
77
+ animalName: string;
78
+ templateId: string;
79
+ fileName?: string;
80
+ /**
81
+ * Optional webhook URL.
82
+ * If provided, ReqVet will POST the final result to this endpoint when ready.
83
+ */
84
+ callbackUrl?: string;
85
+ metadata?: Record<string, unknown>;
86
+ /** Optional: extra instructions injected into the generation prompt (kept separate from metadata) */
87
+ extraInstructions?: string;
88
+ /**
89
+ * When true, generateReport() will poll until completion and return the final report.
90
+ * When false (default), it returns immediately after creating the job.
91
+ */
92
+ waitForResult?: boolean;
93
+ /** Called on each poll with current status (only when waitForResult=true). */
94
+ onStatus?: (status: string) => void;
95
+ }
96
+
97
+ export interface CreateJobParams {
98
+ audioFile: string;
99
+ animalName: string;
100
+ templateId: string;
101
+ /** Optional webhook URL (falls back to the org default webhook_url server-side if omitted). */
102
+ callbackUrl?: string;
103
+ metadata?: Record<string, unknown>;
104
+ extraInstructions?: string; // 👈 add
105
+ }
106
+
107
+ export interface CreateTemplateParams {
108
+ name: string;
109
+ content: string;
110
+ description?: string;
111
+ is_default?: boolean;
112
+ }
113
+
114
+ export interface ListJobsOptions {
115
+ limit?: number;
116
+ offset?: number;
117
+ status?: 'pending' | 'transcribing' | 'generating' | 'completed' | 'failed' | 'amending';
118
+ sort?: 'created_at' | 'updated_at';
119
+ order?: 'asc' | 'desc';
120
+ }
121
+
122
+ export interface JobSummary {
123
+ id: string;
124
+ status: 'pending' | 'transcribing' | 'generating' | 'completed' | 'failed' | 'amending';
125
+ animal_name: string;
126
+ template_id: string;
127
+ metadata: Record<string, unknown>;
128
+ created_at: string;
129
+ updated_at: string;
130
+ error_message?: string | null;
131
+ }
132
+
133
+ export interface ListJobsResult {
134
+ jobs: JobSummary[];
135
+ pagination: {
136
+ total: number;
137
+ limit: number;
138
+ offset: number;
139
+ has_more: boolean;
140
+ };
141
+ }
142
+
143
+ export interface RegenerateOptions {
144
+ extraInstructions?: string;
145
+ templateId?: string;
146
+ }
147
+
148
+ export interface RegenerateResult {
149
+ job_id: string;
150
+ status: string;
151
+ result: {
152
+ html: string;
153
+ fields?: ExtractedFields;
154
+ };
155
+ }
156
+
157
+ export type ReformulationPurpose =
158
+ | 'owner'
159
+ | 'referral'
160
+ | 'summary'
161
+ | 'custom'
162
+ | 'diagnostic_hypothesis';
163
+
164
+ export interface ReformulateParams {
165
+ purpose: ReformulationPurpose;
166
+ customInstructions?: string;
167
+ }
168
+
169
+ export interface ReqVetReformulation {
170
+ id: string;
171
+ job_id: string;
172
+ purpose: ReformulationPurpose;
173
+ html: string;
174
+ custom_instructions?: string;
175
+ cost: {
176
+ model: string;
177
+ prompt_tokens: number;
178
+ completion_tokens: number;
179
+ cost_usd: number;
180
+ };
181
+ created_at: string;
182
+ }
183
+
184
+ // ─── Client ──────────────────────────────────────────────────
185
+
186
+ export declare class ReqVetError extends Error {
187
+ status: number;
188
+ body: unknown;
189
+ constructor(message: string, status: number, body: unknown);
190
+ }
191
+
192
+ export declare class ReqVet {
193
+ constructor(apiKey: string, options?: ReqVetOptions);
194
+
195
+ /**
196
+ * Convenience helper.
197
+ * - Default: upload → create job → return {job_id, status}
198
+ * - If waitForResult=true: upload → create job → poll → return final report
199
+ */
200
+ generateReport(params: GenerateReportParams & { waitForResult: true }): Promise<ReqVetReport>;
201
+ generateReport(params: GenerateReportParams & { waitForResult?: false }): Promise<JobResult>;
202
+ generateReport(params: GenerateReportParams): Promise<JobResult | ReqVetReport>;
203
+
204
+ /** Upload an audio file */
205
+ uploadAudio(audio: Blob | Buffer | File, fileName?: string): Promise<UploadResult>;
206
+
207
+ /** Create a generation job */
208
+ createJob(params: CreateJobParams): Promise<JobResult>;
209
+
210
+ /** List jobs with optional pagination and filtering */
211
+ listJobs(options?: ListJobsOptions): Promise<ListJobsResult>;
212
+
213
+ /** Get job status */
214
+ getJob(jobId: string): Promise<Record<string, unknown>>;
215
+
216
+ /** Wait for a job to finish */
217
+ waitForJob(jobId: string, onStatus?: (status: string) => void): Promise<ReqVetReport>;
218
+
219
+ /** Regenerate a completed report */
220
+ regenerateJob(jobId: string, options?: RegenerateOptions): Promise<RegenerateResult>;
221
+
222
+ /** Add an audio complement to a completed job */
223
+ amendJob(
224
+ jobId: string,
225
+ params: { audioFile: string; templateId?: string },
226
+ ): Promise<{
227
+ job_id: string;
228
+ status: 'amending';
229
+ amendment_number: number;
230
+ message: string;
231
+ }>;
232
+
233
+ /** Reformulate a completed report for a specific audience */
234
+ reformulateReport(jobId: string, params: ReformulateParams): Promise<ReqVetReformulation>;
235
+
236
+ /** List all reformulations for a job */
237
+ listReformulations(jobId: string): Promise<{ reformulations: ReqVetReformulation[] }>;
238
+
239
+ /** List templates */
240
+ listTemplates(): Promise<{ custom: Template[]; system: Template[] }>;
241
+
242
+ /** Get a template */
243
+ getTemplate(templateId: string): Promise<Template>;
244
+
245
+ /** Create a template */
246
+ createTemplate(params: CreateTemplateParams): Promise<Template>;
247
+
248
+ /** Update a template */
249
+ updateTemplate(
250
+ templateId: string,
251
+ updates: Partial<Pick<CreateTemplateParams, 'name' | 'content' | 'description' | 'is_default'>>,
252
+ ): Promise<Template>;
253
+
254
+ /** Delete a template */
255
+ deleteTemplate(templateId: string): Promise<{ success: boolean }>;
256
+
257
+ /** Health check */
258
+ health(): Promise<{ status: string; services: Record<string, string> }>;
259
+ }
260
+
261
+ export default ReqVet;
package/src/index.js ADDED
@@ -0,0 +1,444 @@
1
+ // @reqvet/sdk — Official JavaScript SDK
2
+ // Zero dependencies. Works in Node.js 18+ and modern browsers.
3
+
4
+ class ReqVetError extends Error {
5
+ constructor(message, status, body) {
6
+ super(message);
7
+ this.name = 'ReqVetError';
8
+ this.status = status;
9
+ this.body = body;
10
+ }
11
+ }
12
+
13
+ function mimeFromFileName(fileName) {
14
+ const name = (fileName || '').toLowerCase();
15
+ const ext = name.split('.').pop();
16
+ switch (ext) {
17
+ case 'mp3':
18
+ return 'audio/mpeg';
19
+ case 'wav':
20
+ return 'audio/wav';
21
+ case 'webm':
22
+ return 'audio/webm';
23
+ case 'ogg':
24
+ return 'audio/ogg';
25
+ case 'm4a':
26
+ return 'audio/mp4';
27
+ case 'aac':
28
+ return 'audio/aac';
29
+ case 'flac':
30
+ return 'audio/flac';
31
+ default:
32
+ return '';
33
+ }
34
+ }
35
+
36
+ class ReqVet {
37
+ /**
38
+ * Create a ReqVet client.
39
+ *
40
+ * @param {string} apiKey - Your API key (rqv_live_...)
41
+ * @param {Object} [options]
42
+ * @param {string} [options.baseUrl] - API base URL (default: https://api.reqvet.com)
43
+ * @param {number} [options.pollInterval] - Polling interval in ms (default: 5000)
44
+ * @param {number} [options.timeout] - Max wait time in ms (default: 300000 = 5 min)
45
+ */
46
+ constructor(apiKey, options = {}) {
47
+ if (!apiKey?.startsWith('rqv_')) {
48
+ throw new Error('Invalid API key. Must start with "rqv_".');
49
+ }
50
+
51
+ this.apiKey = apiKey;
52
+ this.baseUrl = (options.baseUrl || 'https://api.reqvet.com').replace(/\/$/, '');
53
+ this.pollInterval = options.pollInterval || 5000;
54
+ this.timeout = options.timeout || 300_000;
55
+ }
56
+
57
+ // ─── Core: Generate a report (upload + job + poll) ─────────
58
+
59
+ /**
60
+ * Generate a veterinary report from an audio file.
61
+ * Handles the full flow: upload → create job → wait for result.
62
+ *
63
+ * @param {Object} params
64
+ * @param {Blob|Buffer|File} params.audio - Audio file
65
+ * @param {string} params.animalName - Name of the animal
66
+ * @param {string} params.templateId - Template UUID
67
+ * @param {string} [params.fileName] - File name (default: audio.webm)
68
+ * @param {string} [params.callbackUrl] - Webhook URL for result
69
+ * @param {Object} [params.metadata] - Custom data passed through
70
+ * @param {Function} [params.onStatus] - Called on each poll with current status
71
+ * @returns {Promise<ReqVetReport>}
72
+ */
73
+ async generateReport({
74
+ audio,
75
+ animalName,
76
+ templateId,
77
+ fileName,
78
+ callbackUrl,
79
+ metadata,
80
+ extraInstructions,
81
+ onStatus,
82
+ waitForResult = false,
83
+ }) {
84
+ // Convenience helper.
85
+ // - Default: webhook-first (returns immediately after job creation). If callbackUrl is provided,
86
+ // ReqVet will POST the final result to that URL when ready.
87
+ // - Optional: polling mode (waitForResult=true) which returns the final report.
88
+
89
+ // Step 1: Upload
90
+ const upload = await this.uploadAudio(audio, fileName);
91
+
92
+ // Step 2: Create job (pipeline starts server-side)
93
+ const job = await this.createJob({
94
+ audioFile: upload.audio_file,
95
+ animalName,
96
+ templateId,
97
+ callbackUrl,
98
+ metadata, // 👈 ne touche pas metadata
99
+ extraInstructions, // 👈 passe le param séparé
100
+ });
101
+
102
+ // Step 3: Either return immediately, or poll until completion.
103
+ if (waitForResult) {
104
+ return this.waitForJob(job.job_id, onStatus);
105
+ }
106
+ return job;
107
+ }
108
+
109
+ // ─── Upload ────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Upload an audio file to ReqVet storage.
113
+ *
114
+ * @param {Blob|Buffer|File} audio - The audio file
115
+ * @param {string} [fileName] - File name
116
+ * @returns {Promise<{audio_file: string, size_bytes: number}>}
117
+ */
118
+ async uploadAudio(audio, fileName) {
119
+ const form = new FormData();
120
+
121
+ if (typeof Blob !== 'undefined' && audio instanceof Blob) {
122
+ form.append('file', audio, fileName || 'audio.webm');
123
+ } else if (Buffer.isBuffer(audio)) {
124
+ // Node.js Buffer → Blob
125
+ const finalName = fileName || 'audio.webm';
126
+ const mime = mimeFromFileName(finalName);
127
+ const blob = mime ? new Blob([audio], { type: mime }) : new Blob([audio]);
128
+ form.append('file', blob, finalName);
129
+ } else {
130
+ throw new ReqVetError('audio must be a Blob, File, or Buffer', 0, null);
131
+ }
132
+
133
+ const res = await this._fetch('POST', '/api/v1/upload', null, form);
134
+ // DX: provide a stable alias `path` for consumers.
135
+ if (res && typeof res === 'object' && res.audio_file && !res.path) {
136
+ return { ...res, path: res.audio_file };
137
+ }
138
+ return res;
139
+ }
140
+
141
+ // ─── Jobs ──────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Create a new CR generation job.
145
+ *
146
+ * @param {Object} params
147
+ * @param {string} params.audioFile - Path from upload
148
+ * @param {string} params.animalName
149
+ * @param {string} params.templateId
150
+ * @param {string} [params.callbackUrl]
151
+ * @param {Object} [params.metadata]
152
+ * @returns {Promise<{job_id: string, status: string}>}
153
+ */
154
+ async createJob({ audioFile, animalName, templateId, callbackUrl, metadata, extraInstructions }) {
155
+ return this._fetch('POST', '/api/v1/jobs', {
156
+ audio_file: audioFile,
157
+ animal_name: animalName,
158
+ template_id: templateId,
159
+ callback_url: callbackUrl,
160
+ metadata,
161
+ extra_instructions: extraInstructions,
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Get the status and result of a job.
167
+ *
168
+ * @param {string} jobId
169
+ * @returns {Promise<ReqVetJob>}
170
+ */
171
+ async getJob(jobId) {
172
+ return this._fetch('GET', `/api/v1/jobs/${jobId}`);
173
+ }
174
+
175
+ /**
176
+ * List jobs with optional pagination and filtering.
177
+ *
178
+ * @param {Object} [options]
179
+ * @param {number} [options.limit] - Number of jobs to return (1-100, default 20)
180
+ * @param {number} [options.offset] - Pagination offset (default 0)
181
+ * @param {string} [options.status] - Filter by status (pending|transcribing|generating|completed|failed|amending)
182
+ * @param {string} [options.sort] - Sort field: 'created_at' (default) or 'updated_at'
183
+ * @param {string} [options.order] - Sort direction: 'desc' (default) or 'asc'
184
+ * @returns {Promise<{jobs: Object[], pagination: {total: number, limit: number, offset: number, has_more: boolean}}>}
185
+ */
186
+ async listJobs({ limit, offset, status, sort, order } = {}) {
187
+ const params = new URLSearchParams();
188
+ if (limit != null) params.set('limit', String(limit));
189
+ if (offset != null) params.set('offset', String(offset));
190
+ if (status != null) params.set('status', status);
191
+ if (sort != null) params.set('sort', sort);
192
+ if (order != null) params.set('order', order);
193
+ const qs = params.toString();
194
+ return this._fetch('GET', `/api/v1/jobs${qs ? `?${qs}` : ''}`);
195
+ }
196
+
197
+ /**
198
+ * Wait for a job to complete by polling.
199
+ *
200
+ * @param {string} jobId
201
+ * @param {Function} [onStatus] - Called on each poll
202
+ * @returns {Promise<ReqVetReport>}
203
+ */
204
+ async waitForJob(jobId, onStatus) {
205
+ const deadline = Date.now() + this.timeout;
206
+
207
+ while (Date.now() < deadline) {
208
+ await this._sleep(this.pollInterval);
209
+
210
+ const job = await this.getJob(jobId);
211
+
212
+ if (onStatus) onStatus(job.status);
213
+
214
+ if (job.status === 'completed') {
215
+ return {
216
+ jobId: job.job_id,
217
+ html: job.result?.html || '',
218
+ fields: job.result?.fields || null,
219
+ transcription: job.transcription || '',
220
+ animalName: job.animal_name,
221
+ cost: job.cost || {},
222
+ metadata: job.metadata || {},
223
+ };
224
+ }
225
+
226
+ if (job.status === 'failed') {
227
+ throw new ReqVetError(`Job failed: ${job.error || 'Unknown error'}`, 0, job);
228
+ }
229
+ }
230
+
231
+ throw new ReqVetError('Timeout waiting for job to complete', 0, { jobId });
232
+ }
233
+
234
+ /**
235
+ * Regenerate a completed report with new instructions.
236
+ *
237
+ * @param {string} jobId
238
+ * @param {Object} [options]
239
+ * @param {string} [options.extraInstructions]
240
+ * @param {string} [options.templateId]
241
+ * @returns {Promise<{job_id: string, html: string}>}
242
+ */
243
+ async regenerateJob(jobId, options = {}) {
244
+ return this._fetch('POST', `/api/v1/jobs/${jobId}/regenerate`, {
245
+ extra_instructions: options.extraInstructions,
246
+ template_id: options.templateId,
247
+ });
248
+ }
249
+
250
+ // ─── Amendments (audio complements) ────────────────────────
251
+
252
+ /**
253
+ * Add an audio complement to a completed job.
254
+ *
255
+ * The new audio is transcribed and merged with the existing
256
+ * transcription(s). The CR is then regenerated with the full context.
257
+ * Supports multiple amendments — each one appends.
258
+ *
259
+ * @param {string} jobId - The completed job ID
260
+ * @param {Object} params
261
+ * @param {string} params.audioFile - Path from signed upload
262
+ * @param {string} [params.templateId] - Optional: switch to a different template
263
+ * @returns {Promise<{job_id: string, status: string, amendment_number: number}>}
264
+ *
265
+ * @example
266
+ * // Upload the complement audio first
267
+ * const { audio_file } = await reqvet.uploadAudio(newAudioBlob, 'complement.webm');
268
+ *
269
+ * // Submit the amendment
270
+ * const amend = await reqvet.amendJob(jobId, { audioFile: audio_file });
271
+ * // amend.status === 'amending'
272
+ *
273
+ * // Poll for completion (same as initial job)
274
+ * const updated = await reqvet.waitForJob(jobId);
275
+ * // updated.html now includes the complement info
276
+ */
277
+ async amendJob(jobId, { audioFile, templateId } = {}) {
278
+ if (!audioFile) throw new Error('audioFile is required for amendJob');
279
+ return this._fetch('POST', `/api/v1/jobs/${jobId}/amend`, {
280
+ audio_file: audioFile,
281
+ template_id: templateId,
282
+ });
283
+ }
284
+
285
+ // ─── Reformulations ─────────────────────────────────────────
286
+
287
+ /**
288
+ * Generate a reformulation of a completed report.
289
+ *
290
+ * Produces an alternative version of the CR adapted to a specific audience.
291
+ *
292
+ * @param {string} jobId - The completed job ID
293
+ * @param {Object} params
294
+ * @param {string} params.purpose - 'owner' | 'referral' | 'summary' | 'custom'
295
+ * @param {string} [params.customInstructions] - Required when purpose is 'custom'
296
+ * @returns {Promise<ReqVetReformulation>}
297
+ *
298
+ * @example
299
+ * // Simplified version for the pet owner
300
+ * const ownerVersion = await reqvet.reformulateReport(jobId, { purpose: 'owner' });
301
+ *
302
+ * // Clinical summary for specialist referral
303
+ * const referral = await reqvet.reformulateReport(jobId, { purpose: 'referral' });
304
+ *
305
+ * // Short internal summary
306
+ * const summary = await reqvet.reformulateReport(jobId, { purpose: 'summary' });
307
+ *
308
+ * // Custom reformulation
309
+ * const custom = await reqvet.reformulateReport(jobId, {
310
+ * purpose: 'custom',
311
+ * customInstructions: 'Reformule en insistant sur le pronostic et le suivi nutritionnel',
312
+ * });
313
+ */
314
+ async reformulateReport(jobId, { purpose, customInstructions } = {}) {
315
+ if (!purpose) {
316
+ throw new ReqVetError(
317
+ 'purpose is required. Must be one of: owner, referral, summary, custom, diagnostic_hypothesis',
318
+ 0,
319
+ null,
320
+ );
321
+ }
322
+ return this._fetch('POST', `/api/v1/jobs/${jobId}/reformulate`, {
323
+ purpose,
324
+ custom_instructions: customInstructions,
325
+ });
326
+ }
327
+
328
+ /**
329
+ * List all reformulations for a completed job.
330
+ *
331
+ * @param {string} jobId
332
+ * @returns {Promise<{reformulations: ReqVetReformulation[]}>}
333
+ */
334
+ async listReformulations(jobId) {
335
+ return this._fetch('GET', `/api/v1/jobs/${jobId}/reformulate`);
336
+ }
337
+
338
+ // ─── Templates ─────────────────────────────────────────────
339
+
340
+ /**
341
+ * List all accessible templates.
342
+ * @returns {Promise<{custom: Template[], system: Template[]}>}
343
+ */
344
+ async listTemplates() {
345
+ return this._fetch('GET', '/api/v1/templates');
346
+ }
347
+
348
+ /**
349
+ * Get a template by ID.
350
+ * @param {string} templateId
351
+ */
352
+ async getTemplate(templateId) {
353
+ return this._fetch('GET', `/api/v1/templates/${templateId}`);
354
+ }
355
+
356
+ /**
357
+ * Create a new template.
358
+ * @param {Object} params
359
+ * @param {string} params.name
360
+ * @param {string} params.content - The prompt instructions
361
+ * @param {string} [params.description]
362
+ */
363
+ async createTemplate({ name, content, description }) {
364
+ return this._fetch('POST', '/api/v1/templates', {
365
+ name,
366
+ content,
367
+ description,
368
+ });
369
+ }
370
+
371
+ /**
372
+ * Update a template.
373
+ * @param {string} templateId
374
+ * @param {Object} updates
375
+ * @param {string} [updates.name]
376
+ * @param {string} [updates.content]
377
+ * @param {string} [updates.description]
378
+ */
379
+ async updateTemplate(templateId, updates) {
380
+ return this._fetch('PUT', `/api/v1/templates/${templateId}`, updates);
381
+ }
382
+
383
+ /**
384
+ * Delete a template.
385
+ * @param {string} templateId
386
+ */
387
+ async deleteTemplate(templateId) {
388
+ return this._fetch('DELETE', `/api/v1/templates/${templateId}`);
389
+ }
390
+
391
+ // ─── Health ────────────────────────────────────────────────
392
+
393
+ /**
394
+ * Check API health.
395
+ * @returns {Promise<{status: string, services: Object}>}
396
+ */
397
+ async health() {
398
+ return this._fetch('GET', '/api/v1/health');
399
+ }
400
+
401
+ // ─── Internal ──────────────────────────────────────────────
402
+
403
+ async _fetch(method, path, body, formData) {
404
+ const url = `${this.baseUrl}${path}`;
405
+ const headers = { Authorization: `Bearer ${this.apiKey}` };
406
+ const opts = { method, headers };
407
+
408
+ const isRealFormData =
409
+ typeof FormData !== 'undefined' && formData && formData instanceof FormData;
410
+
411
+ if (isRealFormData) {
412
+ opts.body = formData;
413
+ } else if (body) {
414
+ headers['Content-Type'] = 'application/json';
415
+ // Strip null/undefined values to keep payloads clean
416
+ const cleaned = Object.fromEntries(
417
+ Object.entries(body).filter(([, v]) => v !== null && v !== undefined),
418
+ );
419
+ opts.body = JSON.stringify(cleaned);
420
+ }
421
+
422
+ const response = await fetch(url, opts);
423
+
424
+ let data;
425
+ try {
426
+ data = await response.json();
427
+ } catch {
428
+ throw new ReqVetError(`Non-JSON response from ${path}`, response.status, null);
429
+ }
430
+
431
+ if (!response.ok) {
432
+ throw new ReqVetError(data?.error || `HTTP ${response.status}`, response.status, data);
433
+ }
434
+
435
+ return data;
436
+ }
437
+
438
+ _sleep(ms) {
439
+ return new Promise((r) => setTimeout(r, ms));
440
+ }
441
+ }
442
+
443
+ export { ReqVet, ReqVetError };
444
+ export default ReqVet;