@promptev/client 0.1.0 → 1.0.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.
@@ -1,55 +1,115 @@
1
1
  "use strict";
2
- // ── Types ───────────────────────────────────────────────
2
+ /**
3
+ * @promptev/client — the official JavaScript/TypeScript client for Promptev.
4
+ *
5
+ * 1.0 talks to Promptev's `/api/v1` surface, authenticated with a workspace
6
+ * API key (`fk_…`) sent as a Bearer token. The key is bound to ONE project and
7
+ * carries its own retrieval identity, so what a caller may read is part of the
8
+ * credential rather than a parameter on the call.
9
+ *
10
+ * Two methods:
11
+ * - `chat()` — stream a turn from an agent (SSE).
12
+ * - `uploadDocument()` — put files into the key's project corpus.
13
+ */
3
14
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.PromptevClient = exports.NetworkError = exports.ServerError = exports.RateLimitError = exports.NotFoundError = exports.AuthenticationError = exports.ValidationError = exports.PromptevError = void 0;
15
+ exports.PromptevClient = exports.NetworkError = exports.ServerError = exports.InsufficientCreditsError = exports.RateLimitError = exports.NotFoundError = exports.AuthenticationError = exports.ValidationError = exports.PromptevError = exports.MAX_FILES_PER_RUN = exports.MAX_UPLOAD_BYTES = exports.MAX_FILES_PER_REQUEST = exports.MAX_MESSAGE_CHARS = exports.DEFAULT_BASE_URL = void 0;
16
+ // ── Configuration ───────────────────────────────────────
17
+ /**
18
+ * The default host. NOT `api.promptev.ai` — that host still serves the
19
+ * previous product and would answer 404 for every `/api/v1` path here.
20
+ * FLIP THIS the day `api.promptev.ai` is repointed at the console backend.
21
+ */
22
+ /**
23
+ * Where the client talks by default.
24
+ *
25
+ * `api.promptev.ai` is the API's permanent home. At the time 1.0 was written
26
+ * that host still served the PREVIOUS product, and it is being repointed at
27
+ * the new backend — so this default is correct for where things are going and
28
+ * must not ship to npm before the DNS moves. Point `baseUrl` at
29
+ * `https://console.promptev.ai` in the meantime, or at your own host if you
30
+ * run Promptev yourself.
31
+ */
32
+ exports.DEFAULT_BASE_URL = "https://api.promptev.ai";
33
+ /** The server's own ceiling on one message (`ApiChatBody.message`). */
34
+ exports.MAX_MESSAGE_CHARS = 20000;
35
+ /** `uploads.MAX_FILES_PER_REQUEST` — files in ONE upload request. */
36
+ exports.MAX_FILES_PER_REQUEST = 20;
37
+ /** `file_types.MAX_UPLOAD_BYTES` — 25 MB per file. */
38
+ exports.MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
39
+ /** Files ONE orchestration run may carry — the same ceiling a chat turn has.
40
+ * Server-side: `run_inputs.MAX_FILE_PARTS`. */
41
+ exports.MAX_FILES_PER_RUN = 5;
5
42
  // ── Errors ──────────────────────────────────────────────
6
43
  class PromptevError extends Error {
7
- constructor(message, statusCode, responseText) {
44
+ constructor(message, statusCode, responseText, field) {
8
45
  super(message);
9
46
  this.name = "PromptevError";
10
47
  this.statusCode = statusCode;
11
48
  this.responseText = responseText;
49
+ this.field = field;
12
50
  }
13
51
  }
14
52
  exports.PromptevError = PromptevError;
53
+ /** 400/422 — the request itself was wrong. `field` says which part. */
15
54
  class ValidationError extends PromptevError {
16
- constructor(message, statusCode, responseText) {
17
- super(message, statusCode, responseText);
55
+ constructor() {
56
+ super(...arguments);
18
57
  this.name = "ValidationError";
19
58
  }
20
59
  }
21
60
  exports.ValidationError = ValidationError;
61
+ /** 401/403 — the key is missing, malformed, revoked or expired. */
22
62
  class AuthenticationError extends PromptevError {
23
- constructor(message, statusCode, responseText) {
24
- super(message, statusCode, responseText);
63
+ constructor() {
64
+ super(...arguments);
25
65
  this.name = "AuthenticationError";
26
66
  }
27
67
  }
28
68
  exports.AuthenticationError = AuthenticationError;
69
+ /**
70
+ * 404 — no such agent, OR an agent your key cannot reach, OR one that has
71
+ * never been published. All three answer identically on purpose: a key holder
72
+ * learns nothing about projects it was not given.
73
+ */
29
74
  class NotFoundError extends PromptevError {
30
- constructor(message, statusCode, responseText) {
31
- super(message, statusCode, responseText);
75
+ constructor() {
76
+ super(...arguments);
32
77
  this.name = "NotFoundError";
33
78
  }
34
79
  }
35
80
  exports.NotFoundError = NotFoundError;
81
+ /** 429 — too many requests. `retryAfter` is the server's own answer, in seconds. */
36
82
  class RateLimitError extends PromptevError {
37
- constructor(message, statusCode, responseText) {
38
- super(message, statusCode, responseText);
83
+ constructor(message, statusCode, responseText, field, retryAfter) {
84
+ super(message, statusCode, responseText, field);
39
85
  this.name = "RateLimitError";
86
+ this.retryAfter = retryAfter;
40
87
  }
41
88
  }
42
89
  exports.RateLimitError = RateLimitError;
90
+ /**
91
+ * 402 — the workspace is out of credits. Not a rate limit: waiting does not
92
+ * fix it, a top-up does.
93
+ */
94
+ class InsufficientCreditsError extends PromptevError {
95
+ constructor() {
96
+ super(...arguments);
97
+ this.name = "InsufficientCreditsError";
98
+ }
99
+ }
100
+ exports.InsufficientCreditsError = InsufficientCreditsError;
101
+ /** 5xx — the server failed, after the client's retries were exhausted. */
43
102
  class ServerError extends PromptevError {
44
- constructor(message, statusCode, responseText) {
45
- super(message, statusCode, responseText);
103
+ constructor() {
104
+ super(...arguments);
46
105
  this.name = "ServerError";
47
106
  }
48
107
  }
49
108
  exports.ServerError = ServerError;
109
+ /** The request never got an answer: DNS, connection, abort, or the client timeout. */
50
110
  class NetworkError extends PromptevError {
51
- constructor(message, statusCode, responseText) {
52
- super(message, statusCode, responseText);
111
+ constructor() {
112
+ super(...arguments);
53
113
  this.name = "NetworkError";
54
114
  }
55
115
  }
@@ -60,67 +120,84 @@ const DEFAULT_MAX_RETRIES = 2;
60
120
  const DEFAULT_BACKOFF = 500; // ms
61
121
  class PromptevClient {
62
122
  constructor(config) {
63
- this.projectKey = config.projectKey;
64
- this.baseUrl = (config.baseUrl ?? "https://api.promptev.ai").replace(/\/+$/, "");
65
- this.headers = {
66
- "Content-Type": "application/json",
67
- ...(config.headers ?? {}),
68
- };
123
+ if (!config || !config.apiKey || !String(config.apiKey).trim()) {
124
+ throw new ValidationError("Pass your Promptev API key: new PromptevClient({ apiKey: 'fk_…' }).", undefined, undefined, "apiKey");
125
+ }
126
+ this.apiKey = String(config.apiKey).trim();
127
+ this.baseUrl = (config.baseUrl ?? exports.DEFAULT_BASE_URL).replace(/\/+$/, "");
128
+ this.headers = { ...(config.headers ?? {}) };
69
129
  this.timeout = config.timeout ?? 30000;
70
130
  this.maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
71
131
  }
72
132
  // ── Error handling ──────────────────────────────────
73
- static raiseForStatus(status, text) {
133
+ /**
134
+ * Every refusal on `/api/v1` is `{"error": {"message", "field"?}}`. The
135
+ * older `{"detail": …}` shape is still read as a fallback, so a proxy or an
136
+ * un-migrated route can never produce an empty message.
137
+ */
138
+ static raiseForStatus(status, text, headers) {
74
139
  if (status < 400)
75
140
  return;
76
- let detail;
141
+ let detail = text || "Unknown error";
142
+ let field;
77
143
  try {
78
144
  const body = JSON.parse(text);
79
- detail =
80
- typeof body.detail === "string" ? body.detail : text || "Unknown error";
145
+ const err = body?.error;
146
+ if (err && typeof err === "object") {
147
+ if (typeof err.message === "string")
148
+ detail = err.message;
149
+ if (typeof err.field === "string")
150
+ field = err.field;
151
+ }
152
+ else if (typeof body?.detail === "string") {
153
+ detail = body.detail;
154
+ }
81
155
  }
82
156
  catch {
83
- detail = text || "Unknown error";
157
+ /* not JSON — the raw body is the best message there is */
84
158
  }
85
- if (status === 400)
86
- throw new ValidationError(detail, status, text);
87
- if (status === 401)
88
- throw new AuthenticationError(detail, status, text);
89
- if (status === 403)
90
- throw new AuthenticationError(detail, status, text);
159
+ if (status === 400 || status === 422)
160
+ throw new ValidationError(detail, status, text, field);
161
+ if (status === 401 || status === 403)
162
+ throw new AuthenticationError(detail, status, text, field);
163
+ if (status === 402)
164
+ throw new InsufficientCreditsError(detail, status, text, field);
91
165
  if (status === 404)
92
- throw new NotFoundError(detail, status, text);
93
- if (status === 429)
94
- throw new RateLimitError(detail, status, text);
166
+ throw new NotFoundError(detail, status, text, field);
167
+ if (status === 429) {
168
+ const raw = headers?.get("retry-after");
169
+ const seconds = raw != null && raw !== "" ? Number(raw) : NaN;
170
+ throw new RateLimitError(detail, status, text, field, Number.isFinite(seconds) ? seconds : undefined);
171
+ }
95
172
  if (status >= 500)
96
- throw new ServerError(detail, status, text);
97
- throw new PromptevError(detail, status, text);
173
+ throw new ServerError(detail, status, text, field);
174
+ throw new PromptevError(detail, status, text, field);
175
+ }
176
+ authHeaders(extra) {
177
+ // Authorization goes on AFTER the caller's headers: a stray `Authorization`
178
+ // in `config.headers` must not be able to displace the key silently.
179
+ return {
180
+ ...this.headers,
181
+ Authorization: `Bearer ${this.apiKey}`,
182
+ ...(extra ?? {}),
183
+ };
98
184
  }
99
185
  // ── Retry helper ────────────────────────────────────
100
186
  async fetchWithRetry(url, init) {
101
187
  let lastError = null;
102
188
  for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
103
189
  try {
104
- const controller = new AbortController();
105
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
106
- const resp = await fetch(url, {
107
- ...init,
108
- signal: controller.signal,
109
- });
110
- clearTimeout(timeoutId);
190
+ const resp = await this.fetchOnce(url, init);
111
191
  if (!RETRYABLE_STATUS.has(resp.status) || attempt === this.maxRetries) {
112
192
  return resp;
113
193
  }
114
194
  lastError = new ServerError(`Server error (${resp.status})`, resp.status);
115
195
  }
116
196
  catch (err) {
117
- if (err.name === "AbortError") {
118
- lastError = new NetworkError("Request timed out");
119
- }
120
- else {
121
- lastError = new NetworkError(`Network error: ${err.message}`);
122
- }
123
- if (attempt === this.maxRetries)
197
+ lastError = err;
198
+ // A caller who aborted wants the call to STOP, not to be tried again.
199
+ const caller = init.signal;
200
+ if (caller?.aborted || attempt === this.maxRetries)
124
201
  throw lastError;
125
202
  }
126
203
  const delay = DEFAULT_BACKOFF * Math.pow(2, attempt);
@@ -128,8 +205,50 @@ class PromptevClient {
128
205
  }
129
206
  throw lastError;
130
207
  }
208
+ /**
209
+ * One `fetch`, with the client timeout applied to the RESPONSE only.
210
+ *
211
+ * The timer is cleared the moment the response resolves, which is what lets
212
+ * a `chat()` stream run for minutes — a tool call, or a human deciding an
213
+ * approval, is not a stalled request.
214
+ */
215
+ async fetchOnce(url, init) {
216
+ const controller = new AbortController();
217
+ const caller = init.signal;
218
+ const onAbort = () => controller.abort();
219
+ if (caller) {
220
+ if (caller.aborted)
221
+ controller.abort();
222
+ else
223
+ caller.addEventListener("abort", onAbort, { once: true });
224
+ }
225
+ let timedOut = false;
226
+ const timeoutId = setTimeout(() => {
227
+ timedOut = true;
228
+ controller.abort();
229
+ }, this.timeout);
230
+ try {
231
+ return await fetch(url, { ...init, signal: controller.signal });
232
+ }
233
+ catch (err) {
234
+ if (err?.name === "AbortError") {
235
+ if (timedOut)
236
+ throw new NetworkError("Request timed out");
237
+ throw new NetworkError("Request was aborted");
238
+ }
239
+ throw new NetworkError(`Network error: ${err?.message ?? err}`);
240
+ }
241
+ finally {
242
+ clearTimeout(timeoutId);
243
+ if (caller)
244
+ caller.removeEventListener("abort", onAbort);
245
+ }
246
+ }
131
247
  // ── SSE parser ──────────────────────────────────────
132
248
  async *parseSSE(response) {
249
+ if (!response.body) {
250
+ throw new NetworkError("The server sent no response body to stream.");
251
+ }
133
252
  const reader = response.body.getReader();
134
253
  const decoder = new TextDecoder();
135
254
  let buffer = "";
@@ -141,154 +260,302 @@ class PromptevClient {
141
260
  buffer += decoder.decode(value, { stream: true });
142
261
  const lines = buffer.split("\n");
143
262
  buffer = lines.pop() || "";
144
- for (const line of lines) {
263
+ for (const raw of lines) {
264
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
145
265
  if (!line.startsWith("data: "))
146
266
  continue;
267
+ let data;
147
268
  try {
148
- const data = JSON.parse(line.slice(6));
149
- const event = {
150
- type: data.type || "unknown",
151
- output: data.output || "",
152
- raw: data,
153
- };
154
- yield event;
155
- if (event.type === "done" || event.type === "error")
156
- return;
269
+ data = JSON.parse(line.slice(6));
157
270
  }
158
271
  catch {
159
- continue;
272
+ continue; // a frame we cannot parse is never a reason to stop
160
273
  }
274
+ if (!data || typeof data !== "object" || typeof data.type !== "string")
275
+ continue;
276
+ const event = data;
277
+ yield event;
278
+ if (event.type === "done" || event.type === "error")
279
+ return;
161
280
  }
162
281
  }
163
282
  }
164
283
  finally {
284
+ // Cancel, not just release: a caller who breaks out of the loop early
285
+ // (or stops at `done`) would otherwise leave the connection open until
286
+ // the socket is collected.
287
+ try {
288
+ await reader.cancel();
289
+ }
290
+ catch {
291
+ /* already closed */
292
+ }
165
293
  reader.releaseLock();
166
294
  }
167
295
  }
168
- // ── Prompt API ──────────────────────────────────────
296
+ // ── Agent API ───────────────────────────────────────
169
297
  /**
170
- * Compile and execute a prompt.
298
+ * Send a message to one of your project's agents and stream the answer.
299
+ *
300
+ * This is the whole conversational surface: it starts a thread or continues
301
+ * one, streams every event the turn produces, and ends with `done` (or
302
+ * `error`). There is no separate "start" call and no session token — the API
303
+ * key IS the credential, and `conversationId` is the thread.
304
+ *
305
+ * @param agentSlug The agent's slug, as shown on its page in Promptev. Only
306
+ * PUBLISHED agents in the key's own project can be reached; anything else
307
+ * answers 404 ({@link NotFoundError}).
308
+ * @param message What to say. Required, non-empty after trimming, at most
309
+ * {@link MAX_MESSAGE_CHARS} characters.
171
310
  *
172
- * If the prompt has a model configured, it compiles the template,
173
- * sends it to the LLM, and returns the AI response. If no model
174
- * is configured, it returns the compiled template string.
311
+ * @example Stream the answer
312
+ * ```ts
313
+ * let conversationId: string | undefined;
314
+ * for await (const ev of client.chat("support-agent", "What is the refund policy?")) {
315
+ * switch (ev.type) {
316
+ * case "conversation": conversationId = ev.id; break;
317
+ * case "token": process.stdout.write(ev.content); break;
318
+ * case "approval_required":
319
+ * console.log(`\n${ev.tool_name} needs approval — ${ev.reason ?? "policy"}`);
320
+ * break;
321
+ * case "error": throw new Error(ev.message);
322
+ * }
323
+ * }
324
+ * // Continue the thread — and see far fewer approval prompts:
325
+ * for await (const ev of client.chat("support-agent", "And for digital goods?", { conversationId })) {
326
+ * if (ev.type === "token") process.stdout.write(ev.content);
327
+ * }
328
+ * ```
175
329
  */
176
- async runPrompt(promptKey, query, variables) {
177
- const url = `${this.baseUrl}/api/sdk/v1/prompt/client/${this.projectKey}/${promptKey}`;
178
- const merged = { query, ...(variables ?? {}) };
179
- const resp = await this.fetchWithRetry(url, {
330
+ async *chat(agentSlug, message, options) {
331
+ const slug = String(agentSlug ?? "").trim();
332
+ if (!slug) {
333
+ throw new ValidationError("Name the agent to talk to.", undefined, undefined, "agentSlug");
334
+ }
335
+ const text = String(message ?? "").trim();
336
+ if (!text) {
337
+ // The server answers 422 for this; refusing here saves the round trip
338
+ // AND the api_call the workspace would still have been metered for.
339
+ throw new ValidationError("Say something to the agent.", undefined, undefined, "message");
340
+ }
341
+ if (text.length > exports.MAX_MESSAGE_CHARS) {
342
+ throw new ValidationError(`A message can be at most ${exports.MAX_MESSAGE_CHARS} characters.`, undefined, undefined, "message");
343
+ }
344
+ const payload = { message: text };
345
+ if (options?.conversationId != null)
346
+ payload.conversation_id = options.conversationId;
347
+ if (options?.onBehalfOf != null) {
348
+ const obo = { subject: options.onBehalfOf.subject };
349
+ if (options.onBehalfOf.groups != null)
350
+ obo.groups = options.onBehalfOf.groups;
351
+ payload.on_behalf_of = obo;
352
+ }
353
+ const url = `${this.baseUrl}/api/v1/agents/${encodeURIComponent(slug)}/chat`;
354
+ // Deliberately NOT fetchWithRetry: a turn is not idempotent — a retried
355
+ // POST could run the agent, and its tools, a second time.
356
+ const resp = await this.fetchOnce(url, {
180
357
  method: "POST",
181
- headers: this.headers,
182
- body: JSON.stringify({ variables: merged }),
358
+ headers: this.authHeaders({
359
+ "Content-Type": "application/json",
360
+ Accept: "text/event-stream",
361
+ }),
362
+ body: JSON.stringify(payload),
363
+ signal: options?.signal,
183
364
  });
184
- const text = await resp.text();
185
- PromptevClient.raiseForStatus(resp.status, text);
186
- const data = JSON.parse(text);
187
- if (typeof data.prompt !== "string") {
188
- throw new PromptevError("Unexpected response: missing 'prompt' field");
365
+ if (resp.status >= 400) {
366
+ const body = await resp.text();
367
+ PromptevClient.raiseForStatus(resp.status, body, resp.headers);
189
368
  }
190
- return data.prompt;
369
+ yield* this.parseSSE(resp);
191
370
  }
192
371
  /**
193
- * Compile and execute a prompt with streaming.
372
+ * Put one or more documents into the project's corpus — the same "Uploads"
373
+ * source the app writes to, so what you upload here is what the agents in
374
+ * that project retrieve.
375
+ *
376
+ * The key names the project, so nothing about the destination is in the
377
+ * request: a key cannot upload into a project it does not reach.
378
+ *
379
+ * Limits, checked here and again on the server: at most
380
+ * {@link MAX_FILES_PER_REQUEST} files per call, at most
381
+ * {@link MAX_UPLOAD_BYTES} (25 MB) per file — a larger set belongs in a
382
+ * connector source. Ingestion is content-addressed: re-uploading identical
383
+ * bytes updates the existing document instead of duplicating it.
194
384
  *
195
- * Use this when the prompt has tools attached or you want real-time
196
- * output. Returns the same event types as agent streaming.
385
+ * Ingestion is slow, and the request does not return until every file is
386
+ * indexed — raise `timeout` well above its 30 s default for real documents.
387
+ *
388
+ * @example
389
+ * ```ts
390
+ * import { readFile } from "node:fs/promises";
391
+ *
392
+ * const { results } = await client.uploadDocument(
393
+ * { name: "handbook.pdf", content: await readFile("handbook.pdf") },
394
+ * { mode: "hybrid" }
395
+ * );
396
+ * for (const r of results) console.log(r.filename, r.status, r.error ?? "");
397
+ * ```
197
398
  */
198
- async *streamPrompt(promptKey, query, variables) {
199
- const url = `${this.baseUrl}/api/sdk/v1/prompt/client/${this.projectKey}/${promptKey}?stream=true`;
200
- const merged = { query, ...(variables ?? {}) };
201
- const controller = new AbortController();
202
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
203
- let resp;
204
- try {
205
- resp = await fetch(url, {
206
- method: "POST",
207
- headers: this.headers,
208
- body: JSON.stringify({ variables: merged }),
209
- signal: controller.signal,
210
- });
399
+ async uploadDocument(files, options) {
400
+ const list = Array.isArray(files) ? files : [files];
401
+ if (list.length === 0) {
402
+ throw new ValidationError("Attach at least one file.", undefined, undefined, "files");
211
403
  }
212
- catch (err) {
213
- clearTimeout(timeoutId);
214
- if (err.name === "AbortError")
215
- throw new NetworkError("Request timed out");
216
- throw new NetworkError(`Network error: ${err.message}`);
404
+ if (list.length > exports.MAX_FILES_PER_REQUEST) {
405
+ throw new ValidationError(`Upload at most ${exports.MAX_FILES_PER_REQUEST} files at a time — sync a larger set through a connector source.`, undefined, undefined, "files");
217
406
  }
218
- clearTimeout(timeoutId);
219
- if (resp.status >= 400) {
220
- const text = await resp.text();
221
- PromptevClient.raiseForStatus(resp.status, text);
407
+ if (options?.mode != null && !["hybrid", "graph"].includes(options.mode)) {
408
+ throw new ValidationError("Mode must be 'hybrid' or 'graph'.", undefined, undefined, "mode");
222
409
  }
223
- const contentType = resp.headers.get("content-type") || "";
224
- // Non-streaming fallback: prompt has no model, backend returns JSON
225
- if (!contentType.includes("text/event-stream")) {
226
- const data = await resp.json();
227
- yield {
228
- type: "done",
229
- output: data.prompt || "",
230
- raw: data,
231
- };
232
- return;
410
+ const form = new FormData();
411
+ for (const file of list) {
412
+ const [name, blob] = toBlob(file);
413
+ if (blob.size > exports.MAX_UPLOAD_BYTES) {
414
+ throw new ValidationError(`${name}: larger than ${exports.MAX_UPLOAD_BYTES / (1024 * 1024)} MB — sync bigger files through a connector source.`, undefined, undefined, "files");
415
+ }
416
+ form.append("files", blob, name);
233
417
  }
234
- yield* this.parseSSE(resp);
235
- }
236
- // ── Agent API ───────────────────────────────────────
237
- /**
238
- * Start a new agent chat session.
239
- */
240
- async startAgent(chatbotId, options) {
241
- const url = `${this.baseUrl}/api/sdk/v1/agent/${this.projectKey}/${chatbotId}/start`;
242
- const payload = {};
243
- if (options?.visitor != null)
244
- payload.visitor = options.visitor;
245
- if (options?.platform && options.platform !== "sdk")
246
- payload.platform = options.platform;
418
+ if (options?.mode != null)
419
+ form.append("mode", options.mode);
420
+ const url = `${this.baseUrl}/api/v1/documents`;
421
+ // Retried on 502/503/504, unlike chat: the same bytes ingest to the same
422
+ // document (its id is a hash of the content), so a retry cannot duplicate one.
247
423
  const resp = await this.fetchWithRetry(url, {
248
424
  method: "POST",
249
- headers: this.headers,
250
- body: JSON.stringify(payload),
425
+ // NO Content-Type — fetch sets the multipart boundary itself.
426
+ headers: this.authHeaders(),
427
+ body: form,
428
+ signal: options?.signal,
251
429
  });
252
- const text = await resp.text();
253
- PromptevClient.raiseForStatus(resp.status, text);
254
- const data = JSON.parse(text);
255
- return {
256
- sessionToken: data.session_token,
257
- chatbotId: data.chatbot_id,
258
- name: data.name,
259
- memoryEnabled: data.memory_enabled ?? false,
260
- messages: data.messages ?? [],
261
- };
430
+ const body = await resp.text();
431
+ PromptevClient.raiseForStatus(resp.status, body, resp.headers);
432
+ const data = JSON.parse(body);
433
+ return { results: Array.isArray(data?.results) ? data.results : [] };
262
434
  }
263
435
  /**
264
- * Stream an agent response as SSE events.
436
+ * Start a run of a published orchestration. Answers as soon as the run is
437
+ * recorded — **not** when it finishes.
438
+ *
439
+ * An orchestration is a team of agents; it runs in a worker for as long as it
440
+ * takes, so this returns a `run_id` and {@link getRun} tells you what became
441
+ * of it. That is the difference from {@link chat}, which streams one agent's
442
+ * answer inline.
443
+ *
444
+ * ```ts
445
+ * const { run_id } = await client.runOrchestration("month-end-close", {
446
+ * period: "2026-03",
447
+ * entity: "Acme GmbH",
448
+ * });
449
+ * ```
450
+ *
451
+ * With files — the part is named after the input it fills:
452
+ *
453
+ * ```ts
454
+ * const { run_id } = await client.runOrchestration(
455
+ * "contract-review",
456
+ * { matter: "Acme renewal" },
457
+ * { files: { contract: { name: "lease.pdf", content: bytes } } },
458
+ * );
459
+ * ```
265
460
  */
266
- async *streamAgent(chatbotId, options) {
267
- const url = `${this.baseUrl}/api/sdk/v1/agent/${this.projectKey}/${chatbotId}/stream?session_token=${encodeURIComponent(options.sessionToken)}`;
268
- const controller = new AbortController();
269
- const timeoutId = setTimeout(() => controller.abort(), this.timeout);
270
- let resp;
271
- try {
272
- resp = await fetch(url, {
273
- method: "POST",
274
- headers: this.headers,
275
- body: JSON.stringify({ query: options.query }),
276
- signal: controller.signal,
277
- });
461
+ async runOrchestration(slug, inputs = {}, options) {
462
+ if (!slug || !slug.trim()) {
463
+ throw new ValidationError("Name the orchestration to run.", undefined, undefined, "slug");
278
464
  }
279
- catch (err) {
280
- clearTimeout(timeoutId);
281
- if (err.name === "AbortError")
282
- throw new NetworkError("Request timed out");
283
- throw new NetworkError(`Network error: ${err.message}`);
465
+ const fileEntries = Object.entries(options?.files ?? {}).flatMap(([key, value]) => (Array.isArray(value) ? value : [value]).map((file) => [key, file]));
466
+ if (fileEntries.length > exports.MAX_FILES_PER_RUN) {
467
+ throw new ValidationError(`At most ${exports.MAX_FILES_PER_RUN} files can be sent with one call.`, undefined, undefined, "files");
284
468
  }
285
- clearTimeout(timeoutId);
286
- if (resp.status >= 400) {
287
- const text = await resp.text();
288
- PromptevClient.raiseForStatus(resp.status, text);
469
+ const headers = this.authHeaders();
470
+ if (options?.idempotencyKey) {
471
+ headers["Idempotency-Key"] = options.idempotencyKey;
289
472
  }
290
- yield* this.parseSSE(resp);
473
+ let body;
474
+ if (fileEntries.length > 0) {
475
+ // MULTIPART when there are files, JSON when there are not — the two
476
+ // shapes the server accepts, and the JSON rides in a `payload` part so
477
+ // the input keys survive untouched.
478
+ const form = new FormData();
479
+ form.append("payload", JSON.stringify(inputs ?? {}));
480
+ for (const [key, file] of fileEntries) {
481
+ const [name, blob] = toBlob(file);
482
+ if (blob.size > exports.MAX_UPLOAD_BYTES) {
483
+ throw new ValidationError(`${name}: larger than ${exports.MAX_UPLOAD_BYTES / (1024 * 1024)} MB — add bigger files to the agent's knowledge instead.`, undefined, undefined, key);
484
+ }
485
+ form.append(key, blob, name);
486
+ }
487
+ body = form;
488
+ // NO Content-Type — fetch sets the multipart boundary itself.
489
+ }
490
+ else {
491
+ headers["Content-Type"] = "application/json";
492
+ body = JSON.stringify(inputs ?? {});
493
+ }
494
+ const url = `${this.baseUrl}/api/v1/orchestrations/${encodeURIComponent(slug)}/run`;
495
+ // NOT retried without an idempotency key: a retried start could fan the
496
+ // team out twice, and only the caller can say whether two calls are one
497
+ // event. With a key the server collapses them, so retrying is safe.
498
+ const resp = options?.idempotencyKey
499
+ ? await this.fetchWithRetry(url, { method: "POST", headers, body, signal: options?.signal })
500
+ : await fetch(url, { method: "POST", headers, body, signal: options?.signal });
501
+ const text = await resp.text();
502
+ PromptevClient.raiseForStatus(resp.status, text, resp.headers);
503
+ const data = JSON.parse(text);
504
+ return { run_id: String(data?.run_id ?? ""), status: "accepted" };
505
+ }
506
+ /**
507
+ * What became of a run — poll this after {@link runOrchestration}.
508
+ *
509
+ * ```ts
510
+ * let run = await client.getRun(run_id);
511
+ * while (run.status === "running" || run.status === "revising") {
512
+ * await new Promise((r) => setTimeout(r, 2000));
513
+ * run = await client.getRun(run_id);
514
+ * }
515
+ * console.log(run.output);
516
+ * ```
517
+ */
518
+ async getRun(runId, options) {
519
+ if (!runId || !runId.trim()) {
520
+ throw new ValidationError("Name the run to read.", undefined, undefined, "runId");
521
+ }
522
+ const url = `${this.baseUrl}/api/v1/runs/${encodeURIComponent(runId)}`;
523
+ // A read is safe to retry.
524
+ const resp = await this.fetchWithRetry(url, {
525
+ method: "GET",
526
+ headers: this.authHeaders(),
527
+ signal: options?.signal,
528
+ });
529
+ const text = await resp.text();
530
+ PromptevClient.raiseForStatus(resp.status, text, resp.headers);
531
+ return JSON.parse(text);
291
532
  }
292
533
  }
293
534
  exports.PromptevClient = PromptevClient;
535
+ /** One upload input, as a named Blob — the only shape `FormData` accepts. */
536
+ function toBlob(file) {
537
+ if (typeof Blob !== "undefined" && file instanceof Blob) {
538
+ // A `File` is a Blob that already has a name; a bare Blob needs one.
539
+ const name = file.name;
540
+ return [typeof name === "string" && name ? name : "upload", file];
541
+ }
542
+ const named = file;
543
+ if (!named || typeof named.name !== "string" || !named.name.trim()) {
544
+ throw new ValidationError("Give each uploaded file a name — the extension is what picks the parser.", undefined, undefined, "files");
545
+ }
546
+ const content = named.content;
547
+ if (content == null) {
548
+ throw new ValidationError(`${named.name}: no content to upload.`, undefined, undefined, "files");
549
+ }
550
+ if (typeof Blob !== "undefined" && content instanceof Blob) {
551
+ return [named.name, content];
552
+ }
553
+ const part = ArrayBuffer.isView(content)
554
+ ? content.buffer.slice(content.byteOffset, content.byteOffset + content.byteLength)
555
+ : content;
556
+ return [
557
+ named.name,
558
+ new Blob([part], named.type ? { type: named.type } : undefined),
559
+ ];
560
+ }
294
561
  exports.default = PromptevClient;