@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/cjs/index.cjs +429 -162
- package/dist/esm/index.d.ts +464 -36
- package/dist/esm/index.js +427 -161
- package/package.json +8 -8
- package/readme.md +403 -286
package/dist/esm/index.js
CHANGED
|
@@ -1,46 +1,105 @@
|
|
|
1
|
-
|
|
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(
|
|
13
|
-
super(
|
|
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(
|
|
19
|
-
super(
|
|
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(
|
|
25
|
-
super(
|
|
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(
|
|
37
|
-
super(
|
|
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(
|
|
43
|
-
super(
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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 ===
|
|
80
|
-
throw new
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
// ──
|
|
285
|
+
// ── Agent API ───────────────────────────────────────
|
|
159
286
|
/**
|
|
160
|
-
*
|
|
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
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
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.
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
358
|
+
yield* this.parseSSE(resp);
|
|
181
359
|
}
|
|
182
360
|
/**
|
|
183
|
-
*
|
|
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
|
-
*
|
|
186
|
-
*
|
|
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
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
|
243
|
-
PromptevClient.raiseForStatus(resp.status,
|
|
244
|
-
const data = JSON.parse(
|
|
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
|
-
*
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
276
|
-
if (
|
|
277
|
-
|
|
278
|
-
PromptevClient.raiseForStatus(resp.status, text);
|
|
458
|
+
const headers = this.authHeaders();
|
|
459
|
+
if (options?.idempotencyKey) {
|
|
460
|
+
headers["Idempotency-Key"] = options.idempotencyKey;
|
|
279
461
|
}
|
|
280
|
-
|
|
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;
|