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