dyadic 1.3.1 → 1.7.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/CHANGELOG.md +85 -0
- package/LICENSE +21 -0
- package/README.md +193 -2
- package/dist/blackbox.d.ts +266 -0
- package/dist/blackbox.js +852 -0
- package/dist/index.d.ts +1095 -11
- package/dist/index.js +560 -29
- package/dist/otel.d.ts +103 -0
- package/dist/otel.js +305 -0
- package/package.json +5 -5
package/dist/index.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// The official TypeScript SDK for the Dyadic API: Iris
|
|
2
|
-
// through the OpenAI-compatible endpoints at https://dyadic.app/api/openai/v1
|
|
3
|
-
|
|
2
|
+
// through the OpenAI-compatible endpoints at https://dyadic.app/api/openai/v1, and beside them the Anthropic API
|
|
3
|
+
// (/api/anthropic/v1) and OpenRouter's API through the AI gateway (/api/openrouter/v1).
|
|
4
|
+
export const VERSION = "1.7.0";
|
|
4
5
|
export const DEFAULT_BASE_URL = "https://dyadic.app/api/openai/v1";
|
|
6
|
+
/** The Anthropic API version the `anthropic` calls speak (sent as anthropic-version, as Anthropic's SDKs do). */
|
|
7
|
+
export const ANTHROPIC_VERSION = "2023-06-01";
|
|
5
8
|
/** Bytes from the API (speech audio, file downloads). */
|
|
6
9
|
export class Binary {
|
|
7
10
|
constructor(data, contentType, requestId = "") {
|
|
@@ -40,6 +43,15 @@ function form(fields, files) {
|
|
|
40
43
|
}
|
|
41
44
|
return out;
|
|
42
45
|
}
|
|
46
|
+
/** A skill's upload: each file as a "files" part named by its path in the skill (or one zip). */
|
|
47
|
+
function skillForm(files, makeDefault) {
|
|
48
|
+
const out = new FormData();
|
|
49
|
+
const list = Array.isArray(files) ? files : [files];
|
|
50
|
+
list.forEach((file, index) => { const [blob, name] = toBlob(file, list.length === 1 ? "skill.zip" : `file-${index}`); out.append("files", blob, name); });
|
|
51
|
+
if (makeDefault !== undefined)
|
|
52
|
+
out.append("default", makeDefault ? "true" : "false");
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
43
55
|
/** An error from the API, in OpenAI's shape (status 0 for a network failure). */
|
|
44
56
|
export class DyadicError extends Error {
|
|
45
57
|
constructor(message, init = {}) {
|
|
@@ -53,6 +65,19 @@ export class DyadicError extends Error {
|
|
|
53
65
|
this.body = init.body;
|
|
54
66
|
}
|
|
55
67
|
}
|
|
68
|
+
/** A Black Box run this key made was deleted by the account's owner or the application (HTTP 410, code run_deleted).
|
|
69
|
+
* `deletion` is the signed record of the deletion when the service sends one. */
|
|
70
|
+
export class RunDeletedError extends DyadicError {
|
|
71
|
+
constructor(message, init = {}) {
|
|
72
|
+
super(message, init);
|
|
73
|
+
this.name = "RunDeletedError";
|
|
74
|
+
}
|
|
75
|
+
get deletion() {
|
|
76
|
+
const body = (this.body ?? {});
|
|
77
|
+
const found = body.deletion ?? body.error?.deletion;
|
|
78
|
+
return found && typeof found === "object" ? found : null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
56
81
|
/** A streamed completion: `for await (const chunk of stream)`. `text` and `usage` fill in as it goes. */
|
|
57
82
|
export class Stream {
|
|
58
83
|
constructor(http, controller, onDone = () => { }) {
|
|
@@ -65,12 +90,14 @@ export class Stream {
|
|
|
65
90
|
async *[Symbol.asyncIterator]() {
|
|
66
91
|
const reader = this.http.body.getReader();
|
|
67
92
|
const decoder = new TextDecoder();
|
|
68
|
-
let buffer = "";
|
|
93
|
+
let buffer = "", finished = false;
|
|
69
94
|
try {
|
|
70
95
|
for (;;) {
|
|
71
96
|
const { value, done } = await reader.read();
|
|
72
|
-
if (done)
|
|
97
|
+
if (done) {
|
|
98
|
+
finished = true;
|
|
73
99
|
return;
|
|
100
|
+
}
|
|
74
101
|
buffer += decoder.decode(value, { stream: true });
|
|
75
102
|
let index;
|
|
76
103
|
while ((index = buffer.indexOf("\n\n")) >= 0) {
|
|
@@ -79,8 +106,10 @@ export class Stream {
|
|
|
79
106
|
const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("");
|
|
80
107
|
if (!data)
|
|
81
108
|
continue;
|
|
82
|
-
if (data === "[DONE]")
|
|
109
|
+
if (data === "[DONE]") {
|
|
110
|
+
finished = true;
|
|
83
111
|
return;
|
|
112
|
+
}
|
|
84
113
|
const chunk = JSON.parse(data);
|
|
85
114
|
if (chunk.error)
|
|
86
115
|
throw new DyadicError(chunk.error.message, { status: 200, type: chunk.error.type, code: chunk.error.code, body: chunk });
|
|
@@ -88,7 +117,12 @@ export class Stream {
|
|
|
88
117
|
const failure = chunk.response?.error ?? { message: "The response failed." };
|
|
89
118
|
throw new DyadicError(failure.message, { status: 200, code: failure.code ?? null, body: chunk });
|
|
90
119
|
}
|
|
91
|
-
|
|
120
|
+
// An Anthropic stream's usage starts in message_start, and message_delta adds to it.
|
|
121
|
+
if (chunk.type === "message_start")
|
|
122
|
+
this.usage = chunk.message?.usage ?? this.usage;
|
|
123
|
+
else if (chunk.type === "message_delta" && chunk.usage)
|
|
124
|
+
this.usage = { ...(this.usage ?? {}), ...chunk.usage };
|
|
125
|
+
else if (chunk.usage)
|
|
92
126
|
this.usage = chunk.usage;
|
|
93
127
|
if (chunk.type === "run.completed") {
|
|
94
128
|
this.run = chunk;
|
|
@@ -101,12 +135,19 @@ export class Stream {
|
|
|
101
135
|
}
|
|
102
136
|
const choice = chunk.choices?.[0];
|
|
103
137
|
this.text += chunk.type === "response.output_text.delta" ? chunk.delta ?? "" : chunk.type === "delta" ? chunk.text ?? ""
|
|
104
|
-
:
|
|
138
|
+
: chunk.type === "content_block_delta" ? (chunk.delta?.type === "text_delta" ? chunk.delta.text ?? "" : "")
|
|
139
|
+
: choice?.delta?.content ?? choice?.text ?? "";
|
|
105
140
|
yield chunk;
|
|
106
141
|
}
|
|
107
142
|
}
|
|
108
143
|
}
|
|
109
144
|
finally {
|
|
145
|
+
// Leaving early (break, return, a throw) must end the request too: releasing the reader alone leaves the
|
|
146
|
+
// response, and the completion it is paying for, running.
|
|
147
|
+
if (!finished) {
|
|
148
|
+
await reader.cancel().catch(() => { });
|
|
149
|
+
this.controller.abort();
|
|
150
|
+
}
|
|
110
151
|
reader.releaseLock();
|
|
111
152
|
this.onDone();
|
|
112
153
|
}
|
|
@@ -129,6 +170,83 @@ const sentNothing = (error) => {
|
|
|
129
170
|
const e = error;
|
|
130
171
|
return [e?.code, e?.cause?.code, e?.cause?.cause?.code].some((code) => code !== undefined && NOT_SENT.has(code));
|
|
131
172
|
};
|
|
173
|
+
// Browsers don't let a page set User-Agent (and a custom one would cost a CORS preflight); every other runtime says who calls.
|
|
174
|
+
const IN_BROWSER = typeof globalThis.document !== "undefined"
|
|
175
|
+
|| typeof globalThis.importScripts === "function";
|
|
176
|
+
const USER_AGENT = IN_BROWSER ? undefined : `dyadic-typescript/${VERSION}`;
|
|
177
|
+
// One trace for one piece of work (a bot answering one message): every request after `useTrace()` carries the same W3C
|
|
178
|
+
// traceparent, so Dyadic shows the decision, the model call and the run that answered one message as one turn.
|
|
179
|
+
let currentTraceId = "";
|
|
180
|
+
const randomHex = (bytes) => Array.from({ length: bytes }, () => Math.floor(Math.random() * 256).toString(16).padStart(2, "0")).join("");
|
|
181
|
+
/** Start (or join, given a 32-hex trace id) the trace the following requests carry; returns its id. */
|
|
182
|
+
export function useTrace(traceId) {
|
|
183
|
+
const value = String(traceId ?? "").trim().toLowerCase();
|
|
184
|
+
currentTraceId = /^[0-9a-f]{32}$/.test(value) && !/^0+$/.test(value) ? value : randomHex(16);
|
|
185
|
+
return currentTraceId;
|
|
186
|
+
}
|
|
187
|
+
/** The current trace's traceparent header (a new span each call), or {} when none was started. */
|
|
188
|
+
export function currentTraceparent() {
|
|
189
|
+
return currentTraceId ? { traceparent: `00-${currentTraceId}-${randomHex(8)}-01` } : {};
|
|
190
|
+
}
|
|
191
|
+
// A live stream with no bytes (not even the service's 15-second keep-alives) for this long has dropped.
|
|
192
|
+
const LIVE_IDLE_MS = 60000;
|
|
193
|
+
const pause = (ms, signal) => new Promise((resolve) => {
|
|
194
|
+
const timer = setTimeout(done, ms);
|
|
195
|
+
function done() { clearTimeout(timer); signal?.removeEventListener("abort", done); resolve(); }
|
|
196
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
197
|
+
});
|
|
198
|
+
const aborted = (signal, cause) => signal.reason ?? cause
|
|
199
|
+
?? Object.assign(new Error("The operation was aborted."), { name: "AbortError" });
|
|
200
|
+
function queryString(query) {
|
|
201
|
+
const parts = [];
|
|
202
|
+
for (const [key, value] of Object.entries(query)) {
|
|
203
|
+
if (value === undefined || value === null)
|
|
204
|
+
continue;
|
|
205
|
+
for (const item of Array.isArray(value) ? value : [value])
|
|
206
|
+
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(item))}`);
|
|
207
|
+
}
|
|
208
|
+
return parts.join("&");
|
|
209
|
+
}
|
|
210
|
+
/** Server-sent event frames, [event, id, data], from a body reader; `onBytes` hears every chunk (keep-alives too). */
|
|
211
|
+
async function* sseFrames(reader, onBytes) {
|
|
212
|
+
const decoder = new TextDecoder();
|
|
213
|
+
let buffer = "", event = "", id = null, data = [];
|
|
214
|
+
for (;;) {
|
|
215
|
+
const { value, done } = await reader.read();
|
|
216
|
+
if (done)
|
|
217
|
+
return;
|
|
218
|
+
onBytes();
|
|
219
|
+
buffer += decoder.decode(value, { stream: true });
|
|
220
|
+
let index;
|
|
221
|
+
while ((index = buffer.search(/\r\n|\r|\n/)) >= 0) {
|
|
222
|
+
if (buffer[index] === "\r" && index === buffer.length - 1)
|
|
223
|
+
break; // a \r\n split between chunks
|
|
224
|
+
const line = buffer.slice(0, index);
|
|
225
|
+
buffer = buffer.slice(index + (buffer.startsWith("\r\n", index) ? 2 : 1));
|
|
226
|
+
if (!line) {
|
|
227
|
+
if (data.length || event)
|
|
228
|
+
yield [event || "message", id, data.join("\n")];
|
|
229
|
+
event = "";
|
|
230
|
+
id = null;
|
|
231
|
+
data = [];
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (line.startsWith(":"))
|
|
235
|
+
continue;
|
|
236
|
+
const colon = line.indexOf(":");
|
|
237
|
+
const name = colon < 0 ? line : line.slice(0, colon);
|
|
238
|
+
let field = colon < 0 ? "" : line.slice(colon + 1);
|
|
239
|
+
if (field.startsWith(" "))
|
|
240
|
+
field = field.slice(1);
|
|
241
|
+
if (name === "event")
|
|
242
|
+
event = field;
|
|
243
|
+
else if (name === "data")
|
|
244
|
+
data.push(field);
|
|
245
|
+
else if (name === "id" && !field.includes("\0"))
|
|
246
|
+
id = field;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
132
250
|
const newKey = () => globalThis.crypto?.randomUUID?.()
|
|
133
251
|
?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
|
134
252
|
/** Loopback, a private address or a single-label name (a Docker or LAN service such as dyadic-api): where plain http
|
|
@@ -148,9 +266,10 @@ export class Dyadic {
|
|
|
148
266
|
constructor(options = {}) {
|
|
149
267
|
/** An agent answers a conversation: one of the account's agents by id ("iris") or your own agent inline,
|
|
150
268
|
* with Dyadic's built-in tools and your own functions. A run that calls your functions ends with status
|
|
151
|
-
* "requires_action": run `function_calls`, append `messages` and your results, and run again (or use `run`).
|
|
269
|
+
* "requires_action": run `function_calls`, append `messages` and your results, and run again (or use `run`).
|
|
270
|
+
* With the key's plans switch, `planMode` proposes a plan and `planId` carries out an approved one. */
|
|
152
271
|
this.runs = {
|
|
153
|
-
create: ((params) => this.request("POST", this.platformURL("/runs"), params, Boolean(params.stream), { executes: true })),
|
|
272
|
+
create: ((params) => this.request("POST", this.platformURL("/runs"), this.runBody(params), Boolean(params.stream), { executes: true })),
|
|
154
273
|
/** Run to completion, calling your functions whenever the agent asks. The result's `messages` hold
|
|
155
274
|
* everything the run added, ready to append to your conversation. */
|
|
156
275
|
run: async (params) => {
|
|
@@ -191,6 +310,21 @@ export class Dyadic {
|
|
|
191
310
|
const body = params.stream ? { stream_options: { include_usage: true }, ...params } : params;
|
|
192
311
|
return this.request("POST", "/chat/completions", body, Boolean(params.stream));
|
|
193
312
|
}),
|
|
313
|
+
/** Completions created with store: true (kept 30 days), newest first; `metadata` matches key/values. */
|
|
314
|
+
list: (query = {}) => {
|
|
315
|
+
const { metadata, ...rest } = query;
|
|
316
|
+
const flat = { ...rest };
|
|
317
|
+
for (const [key, value] of Object.entries(metadata ?? {}))
|
|
318
|
+
flat[`metadata[${key}]`] = value;
|
|
319
|
+
return this.request("GET", "/chat/completions", undefined, false, { query: flat });
|
|
320
|
+
},
|
|
321
|
+
retrieve: (id) => this.request("GET", `/chat/completions/${id}`),
|
|
322
|
+
/** Replace a stored completion's metadata. */
|
|
323
|
+
update: (id, params) => this.request("POST", `/chat/completions/${id}`, params, false, { idempotent: true }),
|
|
324
|
+
delete: (id) => this.request("DELETE", `/chat/completions/${id}`),
|
|
325
|
+
messages: {
|
|
326
|
+
list: (id, query = {}) => this.request("GET", `/chat/completions/${id}/messages`, undefined, false, { query }),
|
|
327
|
+
},
|
|
194
328
|
},
|
|
195
329
|
};
|
|
196
330
|
this.completions = {
|
|
@@ -205,6 +339,31 @@ export class Dyadic {
|
|
|
205
339
|
delete: (id) => this.request("DELETE", `/responses/${id}`),
|
|
206
340
|
inputItems: (id, query = {}) => this.request("GET", `/responses/${id}/input_items`, undefined, false, { query }),
|
|
207
341
|
cancel: (id) => this.request("POST", `/responses/${id}/cancel`),
|
|
342
|
+
/** How many input tokens a Responses request would use: an estimate, free (no model runs). */
|
|
343
|
+
inputTokens: {
|
|
344
|
+
count: (params) => this.request("POST", "/responses/input_tokens", params, false, { idempotent: true }),
|
|
345
|
+
},
|
|
346
|
+
/** Your user messages and one compaction item summarising everything else; send its output as the next request's input. */
|
|
347
|
+
compact: (params) => this.request("POST", "/responses/compact", params),
|
|
348
|
+
};
|
|
349
|
+
/** Conversations for the Responses API: pass `conversation` to responses.create and each response's input and output
|
|
350
|
+
* are added to it. */
|
|
351
|
+
this.conversations = {
|
|
352
|
+
create: (params = {}) => this.request("POST", "/conversations", params),
|
|
353
|
+
retrieve: (id) => this.request("GET", `/conversations/${id}`),
|
|
354
|
+
update: (id, params) => this.request("POST", `/conversations/${id}`, params, false, { idempotent: true }),
|
|
355
|
+
/** Deletes the conversation and its items. */
|
|
356
|
+
delete: (id) => this.request("DELETE", `/conversations/${id}`),
|
|
357
|
+
items: {
|
|
358
|
+
create: (conversationId, params) => this.request("POST", `/conversations/${conversationId}/items`, { items: params.items }, false, { query: { "include[]": params.include } }),
|
|
359
|
+
list: (conversationId, query = {}) => {
|
|
360
|
+
const { include, ...rest } = query;
|
|
361
|
+
return this.request("GET", `/conversations/${conversationId}/items`, undefined, false, { query: { ...rest, "include[]": include } });
|
|
362
|
+
},
|
|
363
|
+
retrieve: (itemId, params) => this.request("GET", `/conversations/${params.conversation_id}/items/${itemId}`, undefined, false, { query: { "include[]": params.include } }),
|
|
364
|
+
/** Removes the item; answers with the conversation. */
|
|
365
|
+
delete: (itemId, params) => this.request("DELETE", `/conversations/${params.conversation_id}/items/${itemId}`),
|
|
366
|
+
},
|
|
208
367
|
};
|
|
209
368
|
this.embeddings = {
|
|
210
369
|
create: (params) => this.request("POST", "/embeddings", params),
|
|
@@ -243,6 +402,140 @@ export class Dyadic {
|
|
|
243
402
|
retrieve: (id) => this.request("GET", `/batches/${id}`),
|
|
244
403
|
cancel: (id) => this.request("POST", `/batches/${id}/cancel`),
|
|
245
404
|
};
|
|
405
|
+
/** Containers (cntr_...) for the code interpreter and shell tools; needs the key's code interpreter switch. */
|
|
406
|
+
this.containers = {
|
|
407
|
+
create: (params = {}) => this.request("POST", "/containers", { name: "", ...params }),
|
|
408
|
+
list: (query = {}) => this.request("GET", "/containers", undefined, false, { query }),
|
|
409
|
+
retrieve: (id) => this.request("GET", `/containers/${id}`),
|
|
410
|
+
delete: (id) => this.request("DELETE", `/containers/${id}`),
|
|
411
|
+
files: {
|
|
412
|
+
/** Put a file in the container (at /mnt/data/<name>): upload `file`, or copy an uploaded `file_id`. */
|
|
413
|
+
create: (containerId, params) => params.file !== undefined
|
|
414
|
+
? this.request("POST", `/containers/${containerId}/files`, undefined, false, { form: form({}, { file: [params.file, "file"] }) })
|
|
415
|
+
: this.request("POST", `/containers/${containerId}/files`, { file_id: params.file_id }),
|
|
416
|
+
list: (containerId, query = {}) => this.request("GET", `/containers/${containerId}/files`, undefined, false, { query }),
|
|
417
|
+
retrieve: (fileId, params) => this.request("GET", `/containers/${params.container_id}/files/${fileId}`),
|
|
418
|
+
delete: (fileId, params) => this.request("DELETE", `/containers/${params.container_id}/files/${fileId}`),
|
|
419
|
+
content: {
|
|
420
|
+
retrieve: (fileId, params) => this.request("GET", `/containers/${params.container_id}/files/${fileId}/content`, undefined, false, { raw: true }),
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
/** Vector stores (vs_...) for the file search tool, and searching them directly; needs the key's file search switch.
|
|
425
|
+
* Indexing and search cost no credits. */
|
|
426
|
+
this.vectorStores = {
|
|
427
|
+
create: (params = {}) => this.request("POST", "/vector_stores", params),
|
|
428
|
+
list: (query = {}) => this.request("GET", "/vector_stores", undefined, false, { query }),
|
|
429
|
+
retrieve: (id) => this.request("GET", `/vector_stores/${id}`),
|
|
430
|
+
update: (id, params) => this.request("POST", `/vector_stores/${id}`, params, false, { idempotent: true }),
|
|
431
|
+
delete: (id) => this.request("DELETE", `/vector_stores/${id}`),
|
|
432
|
+
search: (id, params) => this.request("POST", `/vector_stores/${id}/search`, params, false, { idempotent: true }),
|
|
433
|
+
files: {
|
|
434
|
+
/** Add an uploaded file; it is read and indexed in the background. */
|
|
435
|
+
create: (vectorStoreId, params) => this.request("POST", `/vector_stores/${vectorStoreId}/files`, params),
|
|
436
|
+
list: (vectorStoreId, query = {}) => this.request("GET", `/vector_stores/${vectorStoreId}/files`, undefined, false, { query }),
|
|
437
|
+
retrieve: (fileId, params) => this.request("GET", `/vector_stores/${params.vector_store_id}/files/${fileId}`),
|
|
438
|
+
update: (fileId, params) => this.request("POST", `/vector_stores/${params.vector_store_id}/files/${fileId}`, { attributes: params.attributes }, false, { idempotent: true }),
|
|
439
|
+
delete: (fileId, params) => this.request("DELETE", `/vector_stores/${params.vector_store_id}/files/${fileId}`),
|
|
440
|
+
/** The file's parsed text, as indexed. */
|
|
441
|
+
content: (fileId, params) => this.request("GET", `/vector_stores/${params.vector_store_id}/files/${fileId}/content`),
|
|
442
|
+
},
|
|
443
|
+
fileBatches: {
|
|
444
|
+
create: (vectorStoreId, params) => this.request("POST", `/vector_stores/${vectorStoreId}/file_batches`, params),
|
|
445
|
+
retrieve: (batchId, params) => this.request("GET", `/vector_stores/${params.vector_store_id}/file_batches/${batchId}`),
|
|
446
|
+
cancel: (batchId, params) => this.request("POST", `/vector_stores/${params.vector_store_id}/file_batches/${batchId}/cancel`),
|
|
447
|
+
listFiles: (batchId, params) => {
|
|
448
|
+
const { vector_store_id, ...query } = params;
|
|
449
|
+
return this.request("GET", `/vector_stores/${vector_store_id}/file_batches/${batchId}/files`, undefined, false, { query });
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
/** Skills (skill_...): a SKILL.md and its files, mounted into containers; needs the key's skills switch. Upload the files
|
|
454
|
+
* with their paths in the skill as names ({ name: "scripts/run.py", data }), or one zip. */
|
|
455
|
+
this.skills = {
|
|
456
|
+
create: (params) => this.request("POST", "/skills", undefined, false, { form: skillForm(params.files) }),
|
|
457
|
+
list: (query = {}) => this.request("GET", "/skills", undefined, false, { query }),
|
|
458
|
+
retrieve: (id) => this.request("GET", `/skills/${id}`),
|
|
459
|
+
/** Choose the default version. */
|
|
460
|
+
update: (id, params) => this.request("POST", `/skills/${id}`, { default_version: String(params.default_version) }, false, { idempotent: true }),
|
|
461
|
+
delete: (id) => this.request("DELETE", `/skills/${id}`),
|
|
462
|
+
/** The default version's files as a zip. */
|
|
463
|
+
content: { retrieve: (id) => this.request("GET", `/skills/${id}/content`, undefined, false, { raw: true }) },
|
|
464
|
+
versions: {
|
|
465
|
+
create: (skillId, params) => this.request("POST", `/skills/${skillId}/versions`, undefined, false, { form: skillForm(params.files, params.default) }),
|
|
466
|
+
list: (skillId, query = {}) => this.request("GET", `/skills/${skillId}/versions`, undefined, false, { query }),
|
|
467
|
+
retrieve: (version, params) => this.request("GET", `/skills/${params.skill_id}/versions/${version}`),
|
|
468
|
+
delete: (version, params) => this.request("DELETE", `/skills/${params.skill_id}/versions/${version}`),
|
|
469
|
+
content: {
|
|
470
|
+
retrieve: (version, params) => this.request("GET", `/skills/${params.skill_id}/versions/${version}/content`, undefined, false, { raw: true }),
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
/** The Anthropic API through Dyadic (/api/anthropic/v1, with this client's key): Claude's own request and answer shapes,
|
|
475
|
+
* behaving as Anthropic's do, with Dyadic's billing, limits, activity log and Black Box. Anthropic's own SDKs work there
|
|
476
|
+
* too (baseURL https://dyadic.app/api/anthropic). */
|
|
477
|
+
this.anthropic = {
|
|
478
|
+
messages: {
|
|
479
|
+
/** A message, or with `stream: true` Anthropic's events; the stream's `text` and `usage` fill in as they come. */
|
|
480
|
+
create: ((params) => this.anthropicCall("POST", "/messages", params, Boolean(params.stream))),
|
|
481
|
+
/** A request's input tokens (free; no model runs). */
|
|
482
|
+
countTokens: (params) => this.anthropicCall("POST", "/messages/count_tokens", params, false, { idempotent: true }),
|
|
483
|
+
/** Up to 100,000 requests at half price; poll `retrieve` until processing_status is "ended", then read `results`. */
|
|
484
|
+
batches: {
|
|
485
|
+
create: (params) => this.anthropicCall("POST", "/messages/batches", params),
|
|
486
|
+
list: (query = {}) => this.anthropicCall("GET", "/messages/batches", undefined, false, { query }),
|
|
487
|
+
retrieve: (id) => this.anthropicCall("GET", `/messages/batches/${id}`),
|
|
488
|
+
cancel: (id) => this.anthropicCall("POST", `/messages/batches/${id}/cancel`, undefined, false, { idempotent: true }),
|
|
489
|
+
/** Each request's result once the batch has ended, in order. */
|
|
490
|
+
results: async (id) => {
|
|
491
|
+
const lines = await this.anthropicCall("GET", `/messages/batches/${id}/results`, undefined, false, { raw: true });
|
|
492
|
+
return lines.text().split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
|
|
493
|
+
},
|
|
494
|
+
delete: (id) => this.anthropicCall("DELETE", `/messages/batches/${id}`),
|
|
495
|
+
},
|
|
496
|
+
},
|
|
497
|
+
/** The Claude models this key may call. */
|
|
498
|
+
models: {
|
|
499
|
+
list: (query = {}) => this.anthropicCall("GET", "/models", undefined, false, { query }),
|
|
500
|
+
retrieve: (id) => this.anthropicCall("GET", `/models/${id}`),
|
|
501
|
+
},
|
|
502
|
+
/** Files to reference from a message ({ type: "document", source: { type: "file", file_id } }). */
|
|
503
|
+
files: {
|
|
504
|
+
create: (params) => this.anthropicCall("POST", "/files", undefined, false, { form: form({}, { file: [params.file, "file"] }) }),
|
|
505
|
+
list: (query = {}) => this.anthropicCall("GET", "/files", undefined, false, { query }),
|
|
506
|
+
retrieve: (id) => this.anthropicCall("GET", `/files/${id}`),
|
|
507
|
+
content: (id) => this.anthropicCall("GET", `/files/${id}/content`, undefined, false, { raw: true }),
|
|
508
|
+
delete: (id) => this.anthropicCall("DELETE", `/files/${id}`),
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
/** OpenRouter's API through Dyadic's gateway (/api/openrouter/v1, with this client's key). It needs the key's AI gateway,
|
|
512
|
+
* which Dyadic turns on for an app on request (the key's owner can turn it off; a key without it is answered 403
|
|
513
|
+
* gateway_not_allowed). Every call counts against the key's limits and is billed and logged by Dyadic. */
|
|
514
|
+
this.openrouter = {
|
|
515
|
+
chat: {
|
|
516
|
+
completions: {
|
|
517
|
+
create: ((params) => this.request("POST", this.apiURL("openrouter", "/chat/completions"), params, Boolean(params.stream))),
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
completions: {
|
|
521
|
+
create: ((params) => this.request("POST", this.apiURL("openrouter", "/completions"), params, Boolean(params.stream))),
|
|
522
|
+
},
|
|
523
|
+
/** Embeddings (the key's Embeddings switch). */
|
|
524
|
+
embeddings: {
|
|
525
|
+
create: (params) => this.request("POST", this.apiURL("openrouter", "/embeddings"), params),
|
|
526
|
+
},
|
|
527
|
+
models: {
|
|
528
|
+
list: (query = {}) => this.request("GET", this.apiURL("openrouter", "/models"), undefined, false, { query }),
|
|
529
|
+
/** A model's providers and their prices. */
|
|
530
|
+
endpoints: (author, slug) => this.request("GET", this.apiURL("openrouter", `/models/${author}/${slug}/endpoints`)),
|
|
531
|
+
},
|
|
532
|
+
/** A generation's cost and tokens (this key's own requests only). */
|
|
533
|
+
generation: (id) => this.request("GET", this.apiURL("openrouter", "/generation"), undefined, false, { query: { id } }),
|
|
534
|
+
/** This Dyadic key's usage and limits, as OpenRouter's /key answers (answered by Dyadic). */
|
|
535
|
+
key: () => this.request("GET", this.apiURL("openrouter", "/key")),
|
|
536
|
+
/** The account's allowance and use this week, in dollars (answered by Dyadic). */
|
|
537
|
+
credits: () => this.request("GET", this.apiURL("openrouter", "/credits")),
|
|
538
|
+
};
|
|
246
539
|
/** Connected apps. Show the person `authorize_url` when `authorized` is false. */
|
|
247
540
|
this.connections = {
|
|
248
541
|
discord: (discordId) => this.request("GET", this.platformURL(`/connections/discord/${discordId}`)),
|
|
@@ -255,6 +548,117 @@ export class Dyadic {
|
|
|
255
548
|
create: ({ discord_id, ...fields }) => this.request("POST", this.platformURL("/workspace/saves"), { user: { discord_id: String(discord_id) }, ...fields }, false, { executes: true }),
|
|
256
549
|
},
|
|
257
550
|
};
|
|
551
|
+
/** Agent Black Box: send what your agent does (or record it with BlackBoxRecorder), read the runs this key made (also
|
|
552
|
+
* live), export and verify evidence, simulate (nothing ever runs: not the chat's Replay from here, which asks again for
|
|
553
|
+
* real), triage findings, route records to webhooks and collectors. A run of the key's that was deleted throws
|
|
554
|
+
* RunDeletedError (HTTP 410). */
|
|
555
|
+
this.blackbox = {
|
|
556
|
+
send: (batch, options = {}) => this.request("POST", this.platformURL("/blackbox/events"), batch, false, { idempotent: true, idempotencyKey: options.idempotencyKey }),
|
|
557
|
+
/** OTLP/HTTP traces: an object is sent as OTLP/JSON, bytes as OTLP protobuf (application/x-protobuf; the answer is then
|
|
558
|
+
* the protobuf ExportTraceServiceResponse, as Binary). */
|
|
559
|
+
sendOtlp: (request) => request instanceof Uint8Array || request instanceof ArrayBuffer
|
|
560
|
+
? this.request("POST", this.platformURL("/blackbox/otlp/v1/traces"), undefined, false, { idempotent: true, content: request,
|
|
561
|
+
headers: { "Content-Type": "application/x-protobuf", Accept: "application/x-protobuf" } })
|
|
562
|
+
: this.request("POST", this.platformURL("/blackbox/otlp/v1/traces"), request, false, { idempotent: true }),
|
|
563
|
+
/** Counts for this key's runs: by status and outcome, open findings, recording gaps. */
|
|
564
|
+
overview: () => this.request("GET", this.platformURL("/blackbox/overview")),
|
|
565
|
+
/** What the account keeps (recording, capture by kind) and for how long. */
|
|
566
|
+
settings: () => this.request("GET", this.platformURL("/blackbox/settings")),
|
|
567
|
+
runs: {
|
|
568
|
+
list: (query = {}) => this.request("GET", this.platformURL("/blackbox/runs"), undefined, false, { query }),
|
|
569
|
+
retrieve: (id) => this.request("GET", this.platformURL(`/blackbox/runs/${id}`)),
|
|
570
|
+
/** The run's events in chain order; `types` keeps only those types (up to 20). */
|
|
571
|
+
events: (id, query = {}) => this.request("GET", this.platformURL(`/blackbox/runs/${id}/events`), undefined, false, { query: { after: query.after, limit: query.limit, types: Array.isArray(query.types) ? query.types.join(",") || undefined : query.types } }),
|
|
572
|
+
graph: (id) => this.request("GET", this.platformURL(`/blackbox/runs/${id}/graph`)),
|
|
573
|
+
verify: (id) => this.request("GET", this.platformURL(`/blackbox/runs/${id}/verify`)),
|
|
574
|
+
/** The run's signed checkpoints and the anchor (an RFC 3161 timestamp over a Merkle root) that fixes them in time. */
|
|
575
|
+
checkpoints: (id) => this.request("GET", this.platformURL(`/blackbox/runs/${id}/checkpoints`)),
|
|
576
|
+
/** The run's kept content, opened (all of it, or `refs`); needs the key's content permission and is logged. */
|
|
577
|
+
content: (id, options = {}) => this.request("POST", this.platformURL(`/blackbox/runs/${id}/content`), options.refs ? { refs: options.refs } : {}, false, { idempotent: true }),
|
|
578
|
+
export: (id, options = {}) => this.request("GET", this.platformURL(`/blackbox/runs/${id}/export`), undefined, false, { query: { format: options.format ?? "json", content: options.content ? "true" : undefined }, raw: options.format === "zip" }),
|
|
579
|
+
otlp: (id, options = {}) => this.request("GET", this.platformURL(`/blackbox/runs/${id}/otlp`), undefined, false, { query: { content: options.content ? "true" : undefined } }),
|
|
580
|
+
simulate: (id, params) => this.request("POST", this.platformURL(`/blackbox/runs/${id}/simulate`), params),
|
|
581
|
+
/** Erase the run's content, keeping a signed erasure record (the key's manage permission; a running run answers 409). */
|
|
582
|
+
erase: (id, options = {}) => this.request("POST", this.platformURL(`/blackbox/runs/${id}/erase`), undefined, false, { query: { reason: options.reason }, idempotent: true }),
|
|
583
|
+
/** Delete the run, leaving a signed deletion record (the key's manage permission); later reads throw RunDeletedError. */
|
|
584
|
+
delete: (id, options = {}) => this.request("DELETE", this.platformURL(`/blackbox/runs/${id}`), undefined, false, { query: { reason: options.reason } }),
|
|
585
|
+
/** The run's events live (server-sent events): `event` events (`id` the sequence number, `data` the envelope) from `after`
|
|
586
|
+
* on, then `end` ({ status, outcome, events }) or `deleted`, after which it stops. It connects again by itself when asked
|
|
587
|
+
* to or when the connection drops, resuming after the last event; `signal` (or leaving the loop) ends it. */
|
|
588
|
+
stream: (id, options = {}) => this.live(this.platformURL(`/blackbox/runs/${id}/stream`), { after: options.after ?? 0, terminal: ["end", "deleted"], runId: id,
|
|
589
|
+
signal: options.signal, maxReconnects: options.maxReconnects }),
|
|
590
|
+
},
|
|
591
|
+
bookmarks: {
|
|
592
|
+
list: (runId) => this.request("GET", this.platformURL(`/blackbox/runs/${runId}/bookmarks`)),
|
|
593
|
+
/** Mark event `seq` of a run with a note (the key's write permission). */
|
|
594
|
+
create: (runId, params) => this.request("POST", this.platformURL(`/blackbox/runs/${runId}/bookmarks`), { note: "", ...params }),
|
|
595
|
+
delete: (bookmarkId) => this.request("DELETE", this.platformURL(`/blackbox/bookmarks/${bookmarkId}`)),
|
|
596
|
+
},
|
|
597
|
+
/** What the detection rules noticed in this key's runs (hypotheses with evidence). Call it, or use `list`; `update` triages. */
|
|
598
|
+
findings: Object.assign((query = {}) => this.findingsList(query), {
|
|
599
|
+
list: (query = {}) => this.findingsList(query),
|
|
600
|
+
update: (id, params) => this.request("PATCH", this.platformURL(`/blackbox/findings/${id}`), params, false, { idempotent: true }),
|
|
601
|
+
}),
|
|
602
|
+
rules: {
|
|
603
|
+
/** The detection rules and the account's configuration of them. */
|
|
604
|
+
list: () => this.request("GET", this.platformURL("/blackbox/rules")),
|
|
605
|
+
/** What detection would raise with these settings over this key's recent runs; nothing is saved. */
|
|
606
|
+
preview: (params) => this.request("POST", this.platformURL("/blackbox/rules/preview"), params, false, { idempotent: true }),
|
|
607
|
+
},
|
|
608
|
+
/** Where this key's records go as they happen: webhooks (signed; check them with verifyWebhook) and OpenTelemetry
|
|
609
|
+
* collectors. Needs the key's manage permission; a key sees only its own sinks and runs. */
|
|
610
|
+
sinks: {
|
|
611
|
+
list: () => this.request("GET", this.platformURL("/blackbox/sinks")),
|
|
612
|
+
/** A webhook's `secret` (whsec_...) is in this answer only. */
|
|
613
|
+
create: (params) => this.request("POST", this.platformURL("/blackbox/sinks"), params),
|
|
614
|
+
retrieve: (id) => this.request("GET", this.platformURL(`/blackbox/sinks/${id}`)),
|
|
615
|
+
update: (id, params) => this.request("PATCH", this.platformURL(`/blackbox/sinks/${id}`), params, false, { idempotent: true }),
|
|
616
|
+
delete: (id) => this.request("DELETE", this.platformURL(`/blackbox/sinks/${id}`)),
|
|
617
|
+
/** Queue a test message (a ping). */
|
|
618
|
+
test: (id) => this.request("POST", this.platformURL(`/blackbox/sinks/${id}/test`)),
|
|
619
|
+
/** A webhook's new secret (in this answer only); the old one stops working. */
|
|
620
|
+
rotate: (id) => this.request("POST", this.platformURL(`/blackbox/sinks/${id}/rotate`)),
|
|
621
|
+
deliveries: (id, query = {}) => this.request("GET", this.platformURL(`/blackbox/sinks/${id}/deliveries`), undefined, false, { query }),
|
|
622
|
+
/** Send a delivery again. */
|
|
623
|
+
retryDelivery: (deliveryId) => this.request("POST", this.platformURL(`/blackbox/deliveries/${deliveryId}/retry`), undefined, false, { idempotent: true }),
|
|
624
|
+
},
|
|
625
|
+
diff: (a, b) => this.request("GET", this.platformURL("/blackbox/diff"), undefined, false, { query: { a, b } }),
|
|
626
|
+
keys: () => this.request("GET", this.platformURL("/blackbox/keys")),
|
|
627
|
+
verify: (bundle) => this.request("POST", this.platformURL("/blackbox/verify"), bundle, false, { idempotent: true }),
|
|
628
|
+
/** This key's runs live (server-sent events): `ready` on each connection, then a `run` event (`data`: the run's summary)
|
|
629
|
+
* whenever one starts, changes status or grows. It connects again by itself and never ends on its own: leave the loop
|
|
630
|
+
* or abort `signal`. */
|
|
631
|
+
watch: (options = {}) => this.live(this.platformURL("/blackbox/runs/stream"), { terminal: [], signal: options.signal, maxReconnects: options.maxReconnects }),
|
|
632
|
+
};
|
|
633
|
+
/** Plans (the key's plans switch): a piece of work as Steps that waits for approval and is carried out step by step, the
|
|
634
|
+
* same engine the Workspace's Plans use. A run with `planMode` proposes one; one with `planId` carries out an approved one
|
|
635
|
+
* (runs.create). Approval and what may run are checked by the service. A key sees only the plans it made. */
|
|
636
|
+
this.plans = {
|
|
637
|
+
/** This key's plans, newest change first (without their history); `after` (a plan id, `last_id`) pages on. */
|
|
638
|
+
list: (query = {}) => this.request("GET", this.platformURL("/plans"), undefined, false, { query: { ...query } }),
|
|
639
|
+
/** A plan: mode "plan" (the default) waits for approval; "live" is running from the start. */
|
|
640
|
+
create: (params) => this.request("POST", this.platformURL("/plans"), params),
|
|
641
|
+
/** The plan, its steps and progress, and its recent history (`events`). */
|
|
642
|
+
retrieve: (id) => this.request("GET", this.platformURL(`/plans/${id}`)),
|
|
643
|
+
/** Revise the plan from `revision`, the one you saw (409 if it moved on since). Steps keeping their key keep their progress;
|
|
644
|
+
* an approved plan goes back to draft and is approved again before it runs. */
|
|
645
|
+
update: (id, params) => this.request("PATCH", this.platformURL(`/plans/${id}`), params),
|
|
646
|
+
/** Approve `revision`, the one you reviewed (409 if it was revised since). A run with `planId` then carries it out. */
|
|
647
|
+
approve: (id, params) => this.request("POST", this.platformURL(`/plans/${id}/approve`), { revision: params.revision }),
|
|
648
|
+
/** Cancel the plan: no run carries it out after this. */
|
|
649
|
+
cancel: (id) => this.request("POST", this.platformURL(`/plans/${id}/cancel`)),
|
|
650
|
+
/** Move a step by hand. A step with sub-steps follows them (move those); one can't be completed before what it depends on.
|
|
651
|
+
* Returns the plan. */
|
|
652
|
+
moveStep: (id, key, params) => this.request("POST", this.platformURL(`/plans/${id}/steps/${key}`), params),
|
|
653
|
+
/** The plan's changes after event `after`, oldest first. */
|
|
654
|
+
events: (id, query = {}) => this.request("GET", this.platformURL(`/plans/${id}/events`), undefined, false, { query }),
|
|
655
|
+
/** The plan live (server-sent events): `plan.snapshot` (the plan as it is) on each connection, then each change from `after`
|
|
656
|
+
* on (`id` the event's id, `data` the event with `plan` as it is then), and `end` once the plan is completed, cancelled or
|
|
657
|
+
* failed, after which it stops. It connects again by itself when asked to or when the connection drops, resuming after the
|
|
658
|
+
* last event (Last-Event-ID); `signal` (or leaving the loop) ends it. */
|
|
659
|
+
stream: (id, options = {}) => this.live(this.platformURL(`/plans/${id}/events`), { after: options.after ?? 0, terminal: ["end"], signal: options.signal,
|
|
660
|
+
maxReconnects: options.maxReconnects }),
|
|
661
|
+
};
|
|
258
662
|
// process.env where it exists (Node, Bun, Deno with compat). Keep the key on a server: never ship it to a browser.
|
|
259
663
|
const env = globalThis.process?.env ?? {};
|
|
260
664
|
this.apiKey = options.apiKey ?? env.DYADIC_API_KEY ?? "";
|
|
@@ -296,33 +700,70 @@ export class Dyadic {
|
|
|
296
700
|
}
|
|
297
701
|
return target.toString();
|
|
298
702
|
}
|
|
299
|
-
/** Low-level request: JSON (or multipart `options.form`) in; JSON, text, Binary (`options.raw`)
|
|
300
|
-
* relative to the base URL; an absolute URL must be on its origin. GET, HEAD, DELETE and OPTIONS
|
|
301
|
-
* failing servers; anything else only if it provably never started, unless `options.idempotent`. */
|
|
703
|
+
/** Low-level request: JSON (or multipart `options.form`, or raw `options.content`) in; JSON, text, Binary (`options.raw`)
|
|
704
|
+
* or a Stream out. `path` is relative to the base URL; an absolute URL must be on its origin. GET, HEAD, DELETE and OPTIONS
|
|
705
|
+
* are retried on busy or failing servers; anything else only if it provably never started, unless `options.idempotent`. */
|
|
302
706
|
async request(method, path, body, stream = false, options = {}) {
|
|
707
|
+
const { response, controller, release } = await this.open(method, path, body, stream, options);
|
|
708
|
+
if (stream)
|
|
709
|
+
return new Stream(response, controller, release);
|
|
710
|
+
try {
|
|
711
|
+
const kind = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
712
|
+
if (!options.raw && (kind === "application/json" || kind.endsWith("+json")))
|
|
713
|
+
return await response.json();
|
|
714
|
+
if (!options.raw && kind.startsWith("text/"))
|
|
715
|
+
return await response.text();
|
|
716
|
+
return new Binary(await response.arrayBuffer(), kind, response.headers.get("x-request-id") ?? "");
|
|
717
|
+
}
|
|
718
|
+
finally {
|
|
719
|
+
release();
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
/** The successful response to a call, after its retries; any other answer throws. A stream's timeout and abort link
|
|
723
|
+
* stay until `release`. */
|
|
724
|
+
async open(method, path, body, stream, options) {
|
|
303
725
|
const headers = { Authorization: `Bearer ${this.apiKey}`, Accept: stream ? "text/event-stream" : options.raw ? "*/*" : "application/json" };
|
|
304
|
-
if (
|
|
726
|
+
if (USER_AGENT)
|
|
727
|
+
headers["User-Agent"] = USER_AGENT;
|
|
728
|
+
Object.assign(headers, currentTraceparent()); // a call's own traceparent (options.headers) replaces it below
|
|
729
|
+
if (body !== undefined && !options.form && options.content === undefined)
|
|
305
730
|
headers["Content-Type"] = "application/json";
|
|
306
|
-
const
|
|
731
|
+
for (const [name, value] of Object.entries(options.headers ?? {})) {
|
|
732
|
+
if (name.toLowerCase() === "authorization")
|
|
733
|
+
continue;
|
|
734
|
+
for (const existing of Object.keys(headers))
|
|
735
|
+
if (existing.toLowerCase() === name.toLowerCase())
|
|
736
|
+
delete headers[existing];
|
|
737
|
+
headers[name] = value;
|
|
738
|
+
}
|
|
739
|
+
const query = queryString(options.query ?? {});
|
|
307
740
|
const target = this.url(path);
|
|
308
741
|
const url = `${target}${query ? `${target.includes("?") ? "&" : "?"}${query}` : ""}`;
|
|
309
|
-
const payload = options.form ?? (body === undefined ? undefined : JSON.stringify(body));
|
|
742
|
+
const payload = options.form ?? options.content ?? (body === undefined ? undefined : JSON.stringify(body));
|
|
310
743
|
if (this.appName)
|
|
311
744
|
headers["X-Title"] = this.appName;
|
|
312
745
|
const replayable = Boolean(options.idempotent) || SAFE_METHODS.has(method.toUpperCase());
|
|
313
746
|
if (!replayable || options.idempotencyKey)
|
|
314
747
|
headers["Idempotency-Key"] = options.idempotencyKey ?? newKey();
|
|
748
|
+
const outer = options.signal;
|
|
315
749
|
for (let attempt = 0;; attempt++) {
|
|
750
|
+
if (outer?.aborted)
|
|
751
|
+
throw aborted(outer);
|
|
316
752
|
const controller = new AbortController();
|
|
317
753
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
318
754
|
timer?.unref?.();
|
|
755
|
+
const onAbort = () => controller.abort(outer?.reason);
|
|
756
|
+
outer?.addEventListener("abort", onAbort, { once: true });
|
|
757
|
+
const release = () => { clearTimeout(timer); outer?.removeEventListener("abort", onAbort); };
|
|
319
758
|
let response;
|
|
320
759
|
try {
|
|
321
760
|
// Never follow a redirect: it would carry the key wherever it points.
|
|
322
761
|
response = await this.fetcher(url, { method, headers, body: payload, signal: controller.signal, redirect: "manual" });
|
|
323
762
|
}
|
|
324
763
|
catch (error) {
|
|
325
|
-
|
|
764
|
+
release();
|
|
765
|
+
if (outer?.aborted)
|
|
766
|
+
throw aborted(outer, error);
|
|
326
767
|
if (attempt < this.maxRetries && (replayable || sentNothing(error))) {
|
|
327
768
|
await sleep(500 * 2 ** attempt);
|
|
328
769
|
continue;
|
|
@@ -330,22 +771,14 @@ export class Dyadic {
|
|
|
330
771
|
throw new DyadicError(`Could not reach the Dyadic API: ${error.message}`);
|
|
331
772
|
}
|
|
332
773
|
if (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
|
|
333
|
-
|
|
774
|
+
release();
|
|
334
775
|
throw new DyadicError(`The API answered with a redirect (HTTP ${response.status}); not following it with your key.`, { status: response.status });
|
|
335
776
|
}
|
|
336
777
|
if (!stream)
|
|
337
778
|
clearTimeout(timer);
|
|
338
|
-
if (response.ok)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const kind = (response.headers.get("content-type") ?? "").split(";")[0].trim().toLowerCase();
|
|
342
|
-
if (!options.raw && (kind === "application/json" || kind.endsWith("+json")))
|
|
343
|
-
return await response.json();
|
|
344
|
-
if (!options.raw && kind.startsWith("text/"))
|
|
345
|
-
return await response.text();
|
|
346
|
-
return new Binary(await response.arrayBuffer(), kind, response.headers.get("x-request-id") ?? "");
|
|
347
|
-
}
|
|
348
|
-
clearTimeout(timer);
|
|
779
|
+
if (response.ok)
|
|
780
|
+
return { response, controller, release };
|
|
781
|
+
release();
|
|
349
782
|
const data = await response.json().catch(() => ({ error: { message: `HTTP ${response.status}` } }));
|
|
350
783
|
const error = data?.error ?? {};
|
|
351
784
|
const retry = replayable ? RETRY.has(response.status) : response.status === 429 && ADMISSION_CODES.has(error.code);
|
|
@@ -354,12 +787,105 @@ export class Dyadic {
|
|
|
354
787
|
await sleep(Number.isFinite(after) && after > 0 ? Math.min(after, 30) * 1000 : 500 * 2 ** attempt);
|
|
355
788
|
continue;
|
|
356
789
|
}
|
|
357
|
-
|
|
790
|
+
const Kind = error.code === "run_deleted" ? RunDeletedError : DyadicError;
|
|
791
|
+
throw new Kind(error.message ?? `HTTP ${response.status}`, { status: response.status, type: error.type, code: error.code, param: error.param,
|
|
358
792
|
requestId: response.headers.get("x-request-id") ?? "", body: data });
|
|
359
793
|
}
|
|
360
794
|
}
|
|
795
|
+
/** A Black Box live stream: server-sent events as `{ event, id, data }`, connecting again when the service asks
|
|
796
|
+
* (`reconnect`) or the connection drops (resuming after the last event when `after` is given), until a `terminal` event,
|
|
797
|
+
* the signal aborts, or the consumer stops iterating. */
|
|
798
|
+
async *live(path, options) {
|
|
799
|
+
const resume = options.after !== undefined;
|
|
800
|
+
let after = Math.max(0, Math.trunc(options.after ?? 0)), failures = 0, connected = false;
|
|
801
|
+
const maxReconnects = Math.max(0, options.maxReconnects ?? 10), signal = options.signal;
|
|
802
|
+
const backoff = (n) => pause(Math.min(500 * 2 ** n, 8000) + Math.random() * 250, signal);
|
|
803
|
+
while (!signal?.aborted) {
|
|
804
|
+
let opened;
|
|
805
|
+
try {
|
|
806
|
+
opened = await this.open("GET", path, undefined, true, { signal, query: resume ? { after } : {},
|
|
807
|
+
headers: { "Cache-Control": "no-cache", ...(resume && after ? { "Last-Event-ID": String(after) } : {}) } });
|
|
808
|
+
}
|
|
809
|
+
catch (error) {
|
|
810
|
+
if (signal?.aborted)
|
|
811
|
+
return;
|
|
812
|
+
if (connected && error instanceof RunDeletedError) {
|
|
813
|
+
yield { event: "deleted", id: null, data: { run_id: options.runId ?? "", deletion: error.deletion } };
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
const status = error instanceof DyadicError ? error.status : -1;
|
|
817
|
+
if (connected && failures < maxReconnects && (status === 0 || status >= 500 || status === 408 || status === 429)) {
|
|
818
|
+
failures++;
|
|
819
|
+
await backoff(failures - 1);
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
throw error;
|
|
823
|
+
}
|
|
824
|
+
connected = true;
|
|
825
|
+
// A stream outlives a request's timeout: the service's keep-alives (every 15 s) show it is alive instead.
|
|
826
|
+
opened.release();
|
|
827
|
+
const reader = opened.response.body.getReader();
|
|
828
|
+
let again = false, idle = null;
|
|
829
|
+
const watch = () => { clearTimeout(idle); idle = setTimeout(() => opened.controller.abort(), LIVE_IDLE_MS); idle?.unref?.(); };
|
|
830
|
+
const onAbort = () => opened.controller.abort();
|
|
831
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
832
|
+
if (signal?.aborted)
|
|
833
|
+
onAbort();
|
|
834
|
+
try {
|
|
835
|
+
watch();
|
|
836
|
+
for await (const [name, id, data] of sseFrames(reader, watch)) {
|
|
837
|
+
failures = 0;
|
|
838
|
+
let value = data;
|
|
839
|
+
try {
|
|
840
|
+
value = data ? JSON.parse(data) : null;
|
|
841
|
+
}
|
|
842
|
+
catch { /* not JSON: the text as it came */ }
|
|
843
|
+
if (name === "reconnect") {
|
|
844
|
+
if (resume && Number.isInteger(value?.after))
|
|
845
|
+
after = Math.max(after, value.after);
|
|
846
|
+
again = true;
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
if (resume && id !== null && /^\d+$/.test(id))
|
|
850
|
+
after = Number(id);
|
|
851
|
+
yield { event: name, id, data: value };
|
|
852
|
+
if (options.terminal.includes(name))
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
catch (error) {
|
|
857
|
+
if (signal?.aborted)
|
|
858
|
+
return;
|
|
859
|
+
// The connection dropped: connect again below.
|
|
860
|
+
}
|
|
861
|
+
finally {
|
|
862
|
+
clearTimeout(idle);
|
|
863
|
+
signal?.removeEventListener("abort", onAbort);
|
|
864
|
+
await reader.cancel().catch(() => { });
|
|
865
|
+
opened.controller.abort();
|
|
866
|
+
}
|
|
867
|
+
if (signal?.aborted)
|
|
868
|
+
return;
|
|
869
|
+
if (!again) {
|
|
870
|
+
failures++;
|
|
871
|
+
if (failures > maxReconnects)
|
|
872
|
+
throw new DyadicError(`The event stream dropped ${failures} times in a row; giving up.`);
|
|
873
|
+
await backoff(failures - 1);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
361
877
|
/** Dyadic's own endpoints sit beside the OpenAI-compatible base: .../api/openai/v1 -> .../api/v1. */
|
|
362
878
|
platformURL(path) { return this.baseURL.replace(/\/openai(\/v1)?$/, "/v1") + path; }
|
|
879
|
+
/** So do its other compatible APIs: .../api/openai/v1 -> .../api/anthropic/v1 or .../api/openrouter/v1. */
|
|
880
|
+
apiURL(api, path) { return this.baseURL.replace(/\/openai(\/v1)?$/, `/${api}/v1`) + path; }
|
|
881
|
+
anthropicCall(method, path, body, stream = false, options = {}) {
|
|
882
|
+
return this.request(method, this.apiURL("anthropic", path), body, stream, { ...options, headers: { "anthropic-version": ANTHROPIC_VERSION, ...options.headers } });
|
|
883
|
+
}
|
|
884
|
+
/** A run's body as the API names its fields (planMode and planId as plan_mode and plan_id). */
|
|
885
|
+
runBody(params) {
|
|
886
|
+
const { planMode, planId, ...rest } = params;
|
|
887
|
+
return { ...rest, ...(planMode !== undefined ? { plan_mode: planMode } : {}), ...(planId !== undefined ? { plan_id: planId } : {}) };
|
|
888
|
+
}
|
|
363
889
|
/** One question, one answer: the reply's text. */
|
|
364
890
|
async ask(prompt, options = {}) {
|
|
365
891
|
const { model = "iris", system, ...params } = options;
|
|
@@ -367,7 +893,12 @@ export class Dyadic {
|
|
|
367
893
|
const reply = await this.chat.completions.create({ ...params, model, messages, stream: false });
|
|
368
894
|
return reply.choices[0]?.message.content ?? "";
|
|
369
895
|
}
|
|
896
|
+
findingsList(query) {
|
|
897
|
+
return this.request("GET", this.platformURL("/blackbox/findings"), undefined, false, { query: { ...query } });
|
|
898
|
+
}
|
|
370
899
|
/** The API's OpenAPI description. */
|
|
371
900
|
openapi() { return this.request("GET", "/openapi.json"); }
|
|
372
901
|
}
|
|
373
902
|
export default Dyadic;
|
|
903
|
+
export * from "./blackbox.js";
|
|
904
|
+
export * from "./otel.js";
|