dyadic 1.1.0 → 1.2.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 ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 1.2.0 (2026-10-07)
4
+
5
+ Security release.
6
+
7
+ - The API key is only ever sent to the configured API origin. `request()` with an
8
+ absolute URL on another host, port or scheme, with credentials in it, or
9
+ protocol-relative, now raises instead of sending the key; redirects are not
10
+ followed. Plain `http://` base URLs are accepted only for local or
11
+ private-network APIs.
12
+ - Retries no longer repeat work that may already have run. GET, HEAD, DELETE and
13
+ OPTIONS retry on busy or failing servers; other requests retry only when they
14
+ never started (no connection, or Dyadic's `rate_limit_exceeded` /
15
+ `concurrency_limit` refusal). `409` is no longer retried.
16
+ - Every attempt of one call sends the same `Idempotency-Key` (choose it with
17
+ `{ idempotencyKey }`, or mark a call safe to replay with `{ idempotent: true }`).
18
+ - The package documents server-side use only (a key must never ship to a browser), builds before `npm test` and `npm pack`, and has a lockfile.
19
+
20
+ ## 1.1.0
21
+
22
+ - Runs with your own agents and functions, and connected apps (Workspace saves).
package/README.md CHANGED
@@ -1,59 +1,94 @@
1
- # dyadic
2
-
3
- ```sh
4
- npm install dyadic
5
- ```
6
-
7
- The official TypeScript and JavaScript SDK for the [Dyadic API](https://dyadic.app/developers/): Iris with streaming, function calling, images and typed errors. No dependencies; Node 18+, Deno, Bun and browsers.
8
-
9
- ```ts
10
- import { Dyadic } from "dyadic";
11
-
12
- const client = new Dyadic({ apiKey: process.env.DYADIC_API_KEY });
13
- console.log(await client.ask("Hello!", { model: "iris" }));
14
- ```
15
-
16
- ## Runs: your own agent, Dyadic's tools, your functions
17
-
18
- ```ts
19
- const run = await client.runs.run({
20
- agent: { id: "helpdesk", name: "Helpdesk", instructions: "You answer customers of a small shop." },
21
- messages: [{ role: "user", content: "Where is order 1042?" }],
22
- builtin_tools: ["data.web_search"],
23
- functions: {
24
- lookup_order: {
25
- description: "Look up an order by id.",
26
- parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] },
27
- handler: async ({ order_id }) => ({ order_id, status: "shipped" }),
28
- },
29
- },
30
- end_user: "customer-17",
31
- });
32
- console.log(run.output_text);
33
- ```
34
-
35
- `client.runs.create({ ..., stream: true })` streams the run's events.
36
-
37
- ## Connected apps: saving into a person's Workspace
38
-
39
- With the person's approval, an application can save into their Dyadic
40
- Workspace, naming them by the Discord account linked to their Dyadic account.
41
-
42
- ```ts
43
- const status = await client.connections.discord(discordId);
44
- if (!status.authorized) console.log("Allow this app:", status.authorize_url); // show the person this link
45
-
46
- try {
47
- const saved = await client.workspace.saves.create({ discord_id: discordId, kind: "message", title: "From #general",
48
- text: "Worth keeping", url: "https://discord.com/channels/1/2/3" });
49
- console.log(saved.url);
50
- } catch (error) {
51
- if (error instanceof DyadicError && error.code === "authorization_required") console.log("Allow this app:", error.body.authorize_url);
52
- }
53
- ```
54
-
55
- `kind` is `message`, `conversation` (with `messages`, saved as a chat the
56
- person can continue unless `as_chat: false`), `image` or `file` (with `files`:
57
- base64, up to 10 of 8 MB).
58
-
59
- Full guide: https://dyadic.app/developers/
1
+ # dyadic
2
+
3
+ ```sh
4
+ npm install dyadic
5
+ ```
6
+
7
+ The official TypeScript and JavaScript SDK for the [Dyadic API](https://dyadic.app/developers/): Iris with streaming, function calling, images and typed errors. No dependencies; for server-side code on Node 18+, Deno and Bun (keep your key off the browser, see below).
8
+
9
+ ```ts
10
+ import { Dyadic } from "dyadic";
11
+
12
+ const client = new Dyadic({ apiKey: process.env.DYADIC_API_KEY });
13
+ console.log(await client.ask("Hello!", { model: "iris" }));
14
+ ```
15
+
16
+ ## Keep your key on the server
17
+
18
+ **A Dyadic API key is a long-lived secret that spends your credits. Never ship it
19
+ to a browser**: not in a bundle, page source, local storage or a request your
20
+ front end makes, where any visitor or extension can copy it. Use the SDK in
21
+ server-side code (Node 18+, Deno, Bun, edge functions) and have your front end
22
+ call your own server:
23
+
24
+ ```ts
25
+ // server.ts: the key stays here
26
+ import { Dyadic } from "dyadic";
27
+ const client = new Dyadic({ apiKey: process.env.DYADIC_API_KEY });
28
+
29
+ export async function POST(request: Request) {
30
+ const { question } = await request.json();
31
+ // Check your own user's session and limits here before spending credits.
32
+ return Response.json({ answer: await client.ask(String(question).slice(0, 4000), { model: "iris" }) });
33
+ }
34
+ ```
35
+
36
+ `request()` only ever sends the key to the API you configured: a relative path
37
+ joins `baseURL`, and an absolute URL on any other host, port or scheme (or with
38
+ credentials in it) throws `DyadicError` instead of being sent. Redirects are not
39
+ followed. Plain `http://` is accepted only for a local or private-network API.
40
+
41
+ ## Retries
42
+
43
+ GET, HEAD, DELETE and OPTIONS are retried on busy or failing servers. Anything
44
+ else (a completion, an upload, a run) may already have run, and been billed,
45
+ when it fails, so it is retried only if it provably never started: the
46
+ connection was never made, or Dyadic refused it with `rate_limit_exceeded` or
47
+ `concurrency_limit`. Every attempt of one call carries the same
48
+ `Idempotency-Key`; pass `{ idempotencyKey }` to choose it, or `{ idempotent: true }`
49
+ for a call that is safe to send twice.
50
+
51
+ ## Runs: your own agent, Dyadic's tools, your functions
52
+
53
+ ```ts
54
+ const run = await client.runs.run({
55
+ agent: { id: "helpdesk", name: "Helpdesk", instructions: "You answer customers of a small shop." },
56
+ messages: [{ role: "user", content: "Where is order 1042?" }],
57
+ builtin_tools: ["data.web_search"],
58
+ functions: {
59
+ lookup_order: {
60
+ description: "Look up an order by id.",
61
+ parameters: { type: "object", properties: { order_id: { type: "string" } }, required: ["order_id"] },
62
+ handler: async ({ order_id }) => ({ order_id, status: "shipped" }),
63
+ },
64
+ },
65
+ end_user: "customer-17",
66
+ });
67
+ console.log(run.output_text);
68
+ ```
69
+
70
+ `client.runs.create({ ..., stream: true })` streams the run's events.
71
+
72
+ ## Connected apps: saving into a person's Workspace
73
+
74
+ With the person's approval, an application can save into their Dyadic
75
+ Workspace, naming them by the Discord account linked to their Dyadic account.
76
+
77
+ ```ts
78
+ const status = await client.connections.discord(discordId);
79
+ if (!status.authorized) console.log("Allow this app:", status.authorize_url); // show the person this link
80
+
81
+ try {
82
+ const saved = await client.workspace.saves.create({ discord_id: discordId, kind: "message", title: "From #general",
83
+ text: "Worth keeping", url: "https://discord.com/channels/1/2/3" });
84
+ console.log(saved.url);
85
+ } catch (error) {
86
+ if (error instanceof DyadicError && error.code === "authorization_required") console.log("Allow this app:", error.body.authorize_url);
87
+ }
88
+ ```
89
+
90
+ `kind` is `message`, `conversation` (with `messages`, saved as a chat the
91
+ person can continue unless `as_chat: false`), `image` or `file` (with `files`:
92
+ base64, up to 10 of 8 MB).
93
+
94
+ Full guide: https://dyadic.app/developers/
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export declare const VERSION = "1.1.0";
1
+ export declare const VERSION = "1.2.0";
2
2
  export declare const DEFAULT_BASE_URL = "https://dyadic.app/api/openai/v1";
3
3
  export type Model = "iris" | "iris-think" | (string & {});
4
4
  export type Role = "system" | "developer" | "user" | "assistant" | "tool";
@@ -455,12 +455,16 @@ export interface WorkspaceSave {
455
455
  }[];
456
456
  url: string;
457
457
  }
458
- /** `executes`: the request's work may already be done when it fails (a run), so it is retried only when refused before starting. */
458
+ /** `idempotent`: the call is safe to send twice, so it is retried like a GET. `idempotencyKey`: the Idempotency-Key every
459
+ * attempt of this call carries (one is made for anything but GET, HEAD, DELETE and OPTIONS). `executes` is accepted for
460
+ * compatibility; it is now the rule for every POST. */
459
461
  export interface RequestOptions {
460
462
  form?: FormData;
461
463
  query?: Record<string, string | number | undefined>;
462
464
  raw?: boolean;
463
465
  executes?: boolean;
466
+ idempotent?: boolean;
467
+ idempotencyKey?: string;
464
468
  }
465
469
  /** Bytes from the API (speech audio, file downloads). */
466
470
  export declare class Binary {
@@ -522,10 +526,17 @@ export declare class Dyadic {
522
526
  readonly timeoutMs: number;
523
527
  readonly maxRetries: number;
524
528
  readonly appName?: string;
529
+ /** The one origin this client ever sends its key to. */
530
+ readonly origin: string;
525
531
  private fetcher;
526
532
  constructor(options?: ClientOptions);
527
- /** Low-level request: JSON (or multipart `options.form`) in; JSON, text, Binary (`options.raw`) or a Stream out,
528
- * with retries for busy or failing servers. */
533
+ /** The full URL for `path`: a relative path joins the base URL; an absolute URL is allowed only on the base URL's own
534
+ * origin. The key goes with every request, so it must never reach another host, a plain-http downgrade, or a URL with
535
+ * credentials in it. */
536
+ private url;
537
+ /** Low-level request: JSON (or multipart `options.form`) in; JSON, text, Binary (`options.raw`) or a Stream out. `path` is
538
+ * relative to the base URL; an absolute URL must be on its origin. GET, HEAD, DELETE and OPTIONS are retried on busy or
539
+ * failing servers; anything else only if it provably never started, unless `options.idempotent`. */
529
540
  request<T>(method: string, path: string, body?: unknown, stream?: boolean, options?: RequestOptions): Promise<T>;
530
541
  /** Dyadic's own endpoints sit beside the OpenAI-compatible base: .../api/openai/v1 -> .../api/v1. */
531
542
  platformURL(path: string): string;
@@ -667,8 +678,8 @@ export declare class Dyadic {
667
678
  discord: (discordId: string | number) => Promise<Connection>;
668
679
  };
669
680
  /** Save into the Workspace of the person with this Discord account, once they approved this application.
670
- * Not approved yet throws DyadicError with code "authorization_required" (`error.body.authorize_url`);
671
- * no Dyadic account with that Discord account: "account_not_linked". */
681
+ * Not approved yet throws DyadicError with code "authorization_required" (`error.body.authorize_url`),
682
+ * whether or not they have a Dyadic account yet. */
672
683
  readonly workspace: {
673
684
  saves: {
674
685
  create: ({ discord_id, ...fields }: WorkspaceSaveParams) => Promise<WorkspaceSave>;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // The official TypeScript SDK for the Dyadic API: Iris
2
2
  // through the OpenAI-compatible endpoints at https://dyadic.app/api/openai/v1.
3
- export const VERSION = "1.1.0";
3
+ export const VERSION = "1.2.0";
4
4
  export const DEFAULT_BASE_URL = "https://dyadic.app/api/openai/v1";
5
5
  /** Bytes from the API (speech audio, file downloads). */
6
6
  export class Binary {
@@ -115,10 +115,35 @@ export class Stream {
115
115
  async finalText() { for await (const _ of this) { /* drain */ } return this.text; }
116
116
  abort() { this.controller.abort(); }
117
117
  }
118
- const RETRY = new Set([408, 409, 429, 500, 502, 503, 504]);
119
- // A run may have executed (and been billed) by the time it fails.
120
- const RUN_RETRY = new Set([429]);
118
+ // Methods that can be sent again safely: reading, or deleting the same thing twice.
119
+ const SAFE_METHODS = new Set(["GET", "HEAD", "DELETE", "OPTIONS"]);
120
+ const RETRY = new Set([408, 429, 500, 502, 503, 504]);
121
+ // Anything else (a completion, an upload, a run) may already have run, and been billed, by the time it fails. It is sent
122
+ // again only when it provably never started: no connection was made, or Dyadic refused it at the door with these codes.
123
+ const ADMISSION_CODES = new Set(["rate_limit_exceeded", "concurrency_limit"]);
124
+ // Network errors that mean nothing was sent (Node's codes; other runtimes report no code, so nothing is assumed).
125
+ const NOT_SENT = new Set(["ECONNREFUSED", "ENOTFOUND", "EAI_AGAIN", "EHOSTUNREACH", "ENETUNREACH", "UND_ERR_CONNECT_TIMEOUT"]);
126
+ const SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
121
127
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
128
+ const sentNothing = (error) => {
129
+ const e = error;
130
+ return [e?.code, e?.cause?.code, e?.cause?.cause?.code].some((code) => code !== undefined && NOT_SENT.has(code));
131
+ };
132
+ const newKey = () => globalThis.crypto?.randomUUID?.()
133
+ ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
134
+ /** Loopback, a private address or a single-label name (a Docker or LAN service such as dyadic-api): where plain http
135
+ * can't cross the internet. */
136
+ const privateHost = (hostname) => {
137
+ const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
138
+ const v4 = host.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
139
+ if (v4) {
140
+ const [a, b] = [Number(v4[1]), Number(v4[2])];
141
+ return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254);
142
+ }
143
+ if (host.includes(":"))
144
+ return host === "::1" || /^f[cd]/.test(host) || /^fe[89ab]/.test(host);
145
+ return host === "localhost" || host.endsWith(".localhost") || !host.includes(".");
146
+ };
122
147
  export class Dyadic {
123
148
  constructor(options = {}) {
124
149
  /** An agent answers a conversation: one of the account's agents by id ("iris") or your own agent inline,
@@ -219,51 +244,91 @@ export class Dyadic {
219
244
  discord: (discordId) => this.request("GET", this.platformURL(`/connections/discord/${discordId}`)),
220
245
  };
221
246
  /** Save into the Workspace of the person with this Discord account, once they approved this application.
222
- * Not approved yet throws DyadicError with code "authorization_required" (`error.body.authorize_url`);
223
- * no Dyadic account with that Discord account: "account_not_linked". */
247
+ * Not approved yet throws DyadicError with code "authorization_required" (`error.body.authorize_url`),
248
+ * whether or not they have a Dyadic account yet. */
224
249
  this.workspace = {
225
250
  saves: {
226
251
  create: ({ discord_id, ...fields }) => this.request("POST", this.platformURL("/workspace/saves"), { user: { discord_id: String(discord_id) }, ...fields }, false, { executes: true }),
227
252
  },
228
253
  };
229
- // process.env where it exists (Node, Bun, Deno with compat); browsers pass apiKey.
254
+ // process.env where it exists (Node, Bun, Deno with compat). Keep the key on a server: never ship it to a browser.
230
255
  const env = globalThis.process?.env ?? {};
231
256
  this.apiKey = options.apiKey ?? env.DYADIC_API_KEY ?? "";
232
257
  if (!this.apiKey)
233
258
  throw new DyadicError("No API key. Pass apiKey or set DYADIC_API_KEY. Create one at https://dyadic.app/workspace/?view=api.");
234
259
  this.baseURL = (options.baseURL ?? env.DYADIC_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
260
+ let base;
261
+ try {
262
+ base = new URL(this.baseURL);
263
+ }
264
+ catch {
265
+ throw new DyadicError(`baseURL isn't a valid URL: ${this.baseURL}`);
266
+ }
267
+ if (!(base.protocol === "https:" || (base.protocol === "http:" && privateHost(base.hostname))) || base.username || base.password) {
268
+ throw new DyadicError("baseURL must be an https:// URL (http:// only for a local or private-network API), without a user name or password.");
269
+ }
270
+ this.origin = base.origin;
235
271
  this.timeoutMs = options.timeoutMs ?? 600000;
236
272
  this.maxRetries = options.maxRetries ?? 2;
237
273
  this.appName = options.appName;
238
274
  this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
239
275
  }
240
- /** Low-level request: JSON (or multipart `options.form`) in; JSON, text, Binary (`options.raw`) or a Stream out,
241
- * with retries for busy or failing servers. */
276
+ /** The full URL for `path`: a relative path joins the base URL; an absolute URL is allowed only on the base URL's own
277
+ * origin. The key goes with every request, so it must never reach another host, a plain-http downgrade, or a URL with
278
+ * credentials in it. */
279
+ url(path) {
280
+ if (path.startsWith("//") || path.startsWith("\\\\"))
281
+ throw new DyadicError("Protocol-relative URLs aren't allowed; pass a path like /models.");
282
+ const candidate = SCHEME.test(path) ? path : this.baseURL + (path.startsWith("/") ? path : `/${path}`);
283
+ let target;
284
+ try {
285
+ target = new URL(candidate);
286
+ }
287
+ catch {
288
+ throw new DyadicError(`Not a valid URL: ${path}`);
289
+ }
290
+ if (target.username || target.password || target.origin !== this.origin) {
291
+ throw new DyadicError(`Refusing to send the API key to ${target.protocol}//${target.host}: this client only calls ${this.origin}.`);
292
+ }
293
+ return target.toString();
294
+ }
295
+ /** Low-level request: JSON (or multipart `options.form`) in; JSON, text, Binary (`options.raw`) or a Stream out. `path` is
296
+ * relative to the base URL; an absolute URL must be on its origin. GET, HEAD, DELETE and OPTIONS are retried on busy or
297
+ * failing servers; anything else only if it provably never started, unless `options.idempotent`. */
242
298
  async request(method, path, body, stream = false, options = {}) {
243
299
  const headers = { Authorization: `Bearer ${this.apiKey}`, Accept: stream ? "text/event-stream" : options.raw ? "*/*" : "application/json" };
244
300
  if (body !== undefined && !options.form)
245
301
  headers["Content-Type"] = "application/json";
246
302
  const query = Object.entries(options.query ?? {}).filter(([, v]) => v !== undefined).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
247
- const url = `${path.startsWith("http") ? path : this.baseURL + path}${query ? `?${query}` : ""}`;
303
+ const target = this.url(path);
304
+ const url = `${target}${query ? `${target.includes("?") ? "&" : "?"}${query}` : ""}`;
248
305
  const payload = options.form ?? (body === undefined ? undefined : JSON.stringify(body));
249
306
  if (this.appName)
250
307
  headers["X-Title"] = this.appName;
308
+ const replayable = Boolean(options.idempotent) || SAFE_METHODS.has(method.toUpperCase());
309
+ if (!replayable || options.idempotencyKey)
310
+ headers["Idempotency-Key"] = options.idempotencyKey ?? newKey();
251
311
  for (let attempt = 0;; attempt++) {
252
312
  const controller = new AbortController();
253
313
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
254
314
  timer?.unref?.();
255
315
  let response;
256
316
  try {
257
- response = await this.fetcher(url, { method, headers, body: payload, signal: controller.signal });
317
+ // Never follow a redirect: it would carry the key wherever it points.
318
+ response = await this.fetcher(url, { method, headers, body: payload, signal: controller.signal, redirect: "manual" });
258
319
  }
259
320
  catch (error) {
260
321
  clearTimeout(timer);
261
- if (attempt < this.maxRetries && !options.executes) {
322
+ if (attempt < this.maxRetries && (replayable || sentNothing(error))) {
262
323
  await sleep(500 * 2 ** attempt);
263
324
  continue;
264
325
  }
265
326
  throw new DyadicError(`Could not reach the Dyadic API: ${error.message}`);
266
327
  }
328
+ if (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
329
+ clearTimeout(timer);
330
+ throw new DyadicError(`The API answered with a redirect (HTTP ${response.status}); not following it with your key.`, { status: response.status });
331
+ }
267
332
  if (!stream)
268
333
  clearTimeout(timer);
269
334
  if (response.ok) {
@@ -279,7 +344,8 @@ export class Dyadic {
279
344
  clearTimeout(timer);
280
345
  const data = await response.json().catch(() => ({ error: { message: `HTTP ${response.status}` } }));
281
346
  const error = data?.error ?? {};
282
- if ((options.executes ? RUN_RETRY : RETRY).has(response.status) && attempt < this.maxRetries && error.type !== "insufficient_quota") {
347
+ const retry = replayable ? RETRY.has(response.status) : response.status === 429 && ADMISSION_CODES.has(error.code);
348
+ if (retry && attempt < this.maxRetries && error.type !== "insufficient_quota") {
283
349
  const after = Number(response.headers.get("retry-after"));
284
350
  await sleep(Number.isFinite(after) && after > 0 ? Math.min(after, 30) * 1000 : 500 * 2 ** attempt);
285
351
  continue;
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
- {
2
- "name": "dyadic",
3
- "version": "1.1.0",
4
- "description": "The official TypeScript and JavaScript SDK for the Dyadic API: runs with your own agents and functions, plus the OpenAI-compatible API.",
5
- "type": "module",
6
- "main": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
8
- "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
9
- "files": ["dist", "README.md"],
10
- "engines": { "node": ">=18" },
11
- "license": "MIT",
12
- "homepage": "https://dyadic.app/developers/",
13
- "scripts": { "build": "tsc", "test": "node --test test/client.test.js" },
14
- "devDependencies": { "typescript": "5.6.3" }
15
- }
1
+ {
2
+ "name": "dyadic",
3
+ "version": "1.2.0",
4
+ "description": "The official TypeScript and JavaScript SDK for the Dyadic API: runs with your own agents and functions, plus the OpenAI-compatible API.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
9
+ "files": ["dist", "README.md", "CHANGELOG.md"],
10
+ "engines": { "node": ">=18" },
11
+ "license": "MIT",
12
+ "homepage": "https://dyadic.app/developers/",
13
+ "scripts": { "build": "tsc", "pretest": "tsc", "test": "node --test test/client.test.js test/transport.test.js", "prepack": "tsc" },
14
+ "devDependencies": { "typescript": "5.6.3" }
15
+ }