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/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
- export const VERSION = "1.3.1";
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
- if (chunk.usage)
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
- : choice?.delta?.content ?? choice?.text ?? "";
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`) or a Stream out. `path` is
300
- * relative to the base URL; an absolute URL must be on its origin. GET, HEAD, DELETE and OPTIONS are retried on busy or
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 (body !== undefined && !options.form)
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 query = Object.entries(options.query ?? {}).filter(([, v]) => v !== undefined).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
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
- clearTimeout(timer);
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
- clearTimeout(timer);
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
- if (stream)
340
- return new Stream(response, controller, () => clearTimeout(timer));
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
- throw new DyadicError(error.message ?? `HTTP ${response.status}`, { status: response.status, type: error.type, code: error.code, param: error.param,
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";