dyadic 1.1.0 → 1.3.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 +27 -0
- package/README.md +56 -1
- package/dist/index.d.ts +81 -6
- package/dist/index.js +83 -13
- package/package.json +3 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.3.0 (2026-10-10)
|
|
4
|
+
|
|
5
|
+
- `decisions.create(input, questions, model?)`: typed answers (a predicate's probability, a
|
|
6
|
+
choice or a score) from `POST /v1/decisions`.
|
|
7
|
+
|
|
8
|
+
## 1.2.0 (2026-10-07)
|
|
9
|
+
|
|
10
|
+
Security release.
|
|
11
|
+
|
|
12
|
+
- The API key is only ever sent to the configured API origin. `request()` with an
|
|
13
|
+
absolute URL on another host, port or scheme, with credentials in it, or
|
|
14
|
+
protocol-relative, now raises instead of sending the key; redirects are not
|
|
15
|
+
followed. Plain `http://` base URLs are accepted only for local or
|
|
16
|
+
private-network APIs.
|
|
17
|
+
- Retries no longer repeat work that may already have run. GET, HEAD, DELETE and
|
|
18
|
+
OPTIONS retry on busy or failing servers; other requests retry only when they
|
|
19
|
+
never started (no connection, or Dyadic's `rate_limit_exceeded` /
|
|
20
|
+
`concurrency_limit` refusal). `409` is no longer retried.
|
|
21
|
+
- Every attempt of one call sends the same `Idempotency-Key` (choose it with
|
|
22
|
+
`{ idempotencyKey }`, or mark a call safe to replay with `{ idempotent: true }`).
|
|
23
|
+
- 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.
|
|
24
|
+
|
|
25
|
+
## 1.1.0
|
|
26
|
+
|
|
27
|
+
- Runs with your own agents and functions, and connected apps (Workspace saves).
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
npm install dyadic
|
|
5
5
|
```
|
|
6
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
|
|
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
8
|
|
|
9
9
|
```ts
|
|
10
10
|
import { Dyadic } from "dyadic";
|
|
@@ -13,6 +13,41 @@ const client = new Dyadic({ apiKey: process.env.DYADIC_API_KEY });
|
|
|
13
13
|
console.log(await client.ask("Hello!", { model: "iris" }));
|
|
14
14
|
```
|
|
15
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
|
+
|
|
16
51
|
## Runs: your own agent, Dyadic's tools, your functions
|
|
17
52
|
|
|
18
53
|
```ts
|
|
@@ -34,6 +69,26 @@ console.log(run.output_text);
|
|
|
34
69
|
|
|
35
70
|
`client.runs.create({ ..., stream: true })` streams the run's events.
|
|
36
71
|
|
|
72
|
+
## Decisions: typed answers, fast and cheap
|
|
73
|
+
|
|
74
|
+
Ask one or more questions about some text (or text and images) and get typed answers
|
|
75
|
+
back: a probability (`predicate`), one of your values (`choice`) or a rubric level
|
|
76
|
+
(`score`). A fraction of a second and a fraction of a cent; billed at cost.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const decision = await client.decisions.create({
|
|
80
|
+
input: "I can't log in after resetting my password.",
|
|
81
|
+
questions: [
|
|
82
|
+
{ type: "predicate", name: "urgent", instructions: "The customer is blocked from using the product." },
|
|
83
|
+
{ type: "choice", name: "team", instructions: "Which team should handle this.", choices: [
|
|
84
|
+
{ value: "account", description: "Sign-in and account access" },
|
|
85
|
+
{ value: "billing", description: "Payments and invoices" },
|
|
86
|
+
{ value: "other", description: "Anything else" },
|
|
87
|
+
] },
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
37
92
|
## Connected apps: saving into a person's Workspace
|
|
38
93
|
|
|
39
94
|
With the person's approval, an application can save into their Dyadic
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const VERSION = "1.
|
|
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";
|
|
@@ -310,6 +310,59 @@ export interface Moderation {
|
|
|
310
310
|
category_applied_input_types: Record<string, string[]>;
|
|
311
311
|
}[];
|
|
312
312
|
}
|
|
313
|
+
export type DecisionQuestion = {
|
|
314
|
+
type: "predicate";
|
|
315
|
+
name: string;
|
|
316
|
+
instructions: string;
|
|
317
|
+
} | {
|
|
318
|
+
type: "choice";
|
|
319
|
+
name: string;
|
|
320
|
+
instructions: string;
|
|
321
|
+
choices: {
|
|
322
|
+
value: string;
|
|
323
|
+
description: string;
|
|
324
|
+
}[];
|
|
325
|
+
} | {
|
|
326
|
+
type: "score";
|
|
327
|
+
name: string;
|
|
328
|
+
instructions: string;
|
|
329
|
+
levels: {
|
|
330
|
+
label: string;
|
|
331
|
+
description: string;
|
|
332
|
+
}[];
|
|
333
|
+
};
|
|
334
|
+
export type DecisionAnswer = {
|
|
335
|
+
type: "predicate";
|
|
336
|
+
name: string;
|
|
337
|
+
probability: number;
|
|
338
|
+
} | {
|
|
339
|
+
type: "choice";
|
|
340
|
+
name: string;
|
|
341
|
+
choice: string;
|
|
342
|
+
probabilities: {
|
|
343
|
+
value: string;
|
|
344
|
+
probability: number;
|
|
345
|
+
}[];
|
|
346
|
+
confidence: number;
|
|
347
|
+
} | {
|
|
348
|
+
type: "score";
|
|
349
|
+
name: string;
|
|
350
|
+
score: number;
|
|
351
|
+
probabilities: {
|
|
352
|
+
value: number;
|
|
353
|
+
label: string;
|
|
354
|
+
probability: number;
|
|
355
|
+
}[];
|
|
356
|
+
confidence: number;
|
|
357
|
+
} | {
|
|
358
|
+
type: "refusal";
|
|
359
|
+
name: string;
|
|
360
|
+
};
|
|
361
|
+
export interface Decision {
|
|
362
|
+
id: string | null;
|
|
363
|
+
model: string;
|
|
364
|
+
answers: DecisionAnswer[];
|
|
365
|
+
}
|
|
313
366
|
export interface FileObject {
|
|
314
367
|
id: string;
|
|
315
368
|
object: "file";
|
|
@@ -455,12 +508,16 @@ export interface WorkspaceSave {
|
|
|
455
508
|
}[];
|
|
456
509
|
url: string;
|
|
457
510
|
}
|
|
458
|
-
/** `
|
|
511
|
+
/** `idempotent`: the call is safe to send twice, so it is retried like a GET. `idempotencyKey`: the Idempotency-Key every
|
|
512
|
+
* attempt of this call carries (one is made for anything but GET, HEAD, DELETE and OPTIONS). `executes` is accepted for
|
|
513
|
+
* compatibility; it is now the rule for every POST. */
|
|
459
514
|
export interface RequestOptions {
|
|
460
515
|
form?: FormData;
|
|
461
516
|
query?: Record<string, string | number | undefined>;
|
|
462
517
|
raw?: boolean;
|
|
463
518
|
executes?: boolean;
|
|
519
|
+
idempotent?: boolean;
|
|
520
|
+
idempotencyKey?: string;
|
|
464
521
|
}
|
|
465
522
|
/** Bytes from the API (speech audio, file downloads). */
|
|
466
523
|
export declare class Binary {
|
|
@@ -522,10 +579,17 @@ export declare class Dyadic {
|
|
|
522
579
|
readonly timeoutMs: number;
|
|
523
580
|
readonly maxRetries: number;
|
|
524
581
|
readonly appName?: string;
|
|
582
|
+
/** The one origin this client ever sends its key to. */
|
|
583
|
+
readonly origin: string;
|
|
525
584
|
private fetcher;
|
|
526
585
|
constructor(options?: ClientOptions);
|
|
527
|
-
/**
|
|
528
|
-
* with
|
|
586
|
+
/** The full URL for `path`: a relative path joins the base URL; an absolute URL is allowed only on the base URL's own
|
|
587
|
+
* origin. The key goes with every request, so it must never reach another host, a plain-http downgrade, or a URL with
|
|
588
|
+
* credentials in it. */
|
|
589
|
+
private url;
|
|
590
|
+
/** Low-level request: JSON (or multipart `options.form`) in; JSON, text, Binary (`options.raw`) or a Stream out. `path` is
|
|
591
|
+
* relative to the base URL; an absolute URL must be on its origin. GET, HEAD, DELETE and OPTIONS are retried on busy or
|
|
592
|
+
* failing servers; anything else only if it provably never started, unless `options.idempotent`. */
|
|
529
593
|
request<T>(method: string, path: string, body?: unknown, stream?: boolean, options?: RequestOptions): Promise<T>;
|
|
530
594
|
/** Dyadic's own endpoints sit beside the OpenAI-compatible base: .../api/openai/v1 -> .../api/v1. */
|
|
531
595
|
platformURL(path: string): string;
|
|
@@ -620,6 +684,17 @@ export declare class Dyadic {
|
|
|
620
684
|
model?: string;
|
|
621
685
|
}) => Promise<Moderation>;
|
|
622
686
|
};
|
|
687
|
+
/** Typed answers: each question a predicate (a probability), a choice or a score, about the input. */
|
|
688
|
+
readonly decisions: {
|
|
689
|
+
create: (params: {
|
|
690
|
+
input: string | {
|
|
691
|
+
role: "user";
|
|
692
|
+
content: unknown[];
|
|
693
|
+
}[];
|
|
694
|
+
questions: DecisionQuestion[];
|
|
695
|
+
model?: string;
|
|
696
|
+
}) => Promise<Decision>;
|
|
697
|
+
};
|
|
623
698
|
readonly images: {
|
|
624
699
|
generate: (params: ImageParams) => Promise<ImageResponse>;
|
|
625
700
|
edit: ({ image, ...params }: Omit<ImageParams, "model"> & {
|
|
@@ -667,8 +742,8 @@ export declare class Dyadic {
|
|
|
667
742
|
discord: (discordId: string | number) => Promise<Connection>;
|
|
668
743
|
};
|
|
669
744
|
/** 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
|
-
*
|
|
745
|
+
* Not approved yet throws DyadicError with code "authorization_required" (`error.body.authorize_url`),
|
|
746
|
+
* whether or not they have a Dyadic account yet. */
|
|
672
747
|
readonly workspace: {
|
|
673
748
|
saves: {
|
|
674
749
|
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.
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
const
|
|
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,
|
|
@@ -196,6 +221,10 @@ export class Dyadic {
|
|
|
196
221
|
this.moderations = {
|
|
197
222
|
create: (params) => this.request("POST", "/moderations", params),
|
|
198
223
|
};
|
|
224
|
+
/** Typed answers: each question a predicate (a probability), a choice or a score, about the input. */
|
|
225
|
+
this.decisions = {
|
|
226
|
+
create: (params) => this.request("POST", "/decisions", params),
|
|
227
|
+
};
|
|
199
228
|
this.images = {
|
|
200
229
|
generate: (params) => this.request("POST", "/images/generations", params),
|
|
201
230
|
edit: ({ image, ...params }) => this.request("POST", "/images/edits", undefined, false, { form: form(params, { image: [image, "image.png"] }) }),
|
|
@@ -219,51 +248,91 @@ export class Dyadic {
|
|
|
219
248
|
discord: (discordId) => this.request("GET", this.platformURL(`/connections/discord/${discordId}`)),
|
|
220
249
|
};
|
|
221
250
|
/** 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
|
-
*
|
|
251
|
+
* Not approved yet throws DyadicError with code "authorization_required" (`error.body.authorize_url`),
|
|
252
|
+
* whether or not they have a Dyadic account yet. */
|
|
224
253
|
this.workspace = {
|
|
225
254
|
saves: {
|
|
226
255
|
create: ({ discord_id, ...fields }) => this.request("POST", this.platformURL("/workspace/saves"), { user: { discord_id: String(discord_id) }, ...fields }, false, { executes: true }),
|
|
227
256
|
},
|
|
228
257
|
};
|
|
229
|
-
// process.env where it exists (Node, Bun, Deno with compat)
|
|
258
|
+
// process.env where it exists (Node, Bun, Deno with compat). Keep the key on a server: never ship it to a browser.
|
|
230
259
|
const env = globalThis.process?.env ?? {};
|
|
231
260
|
this.apiKey = options.apiKey ?? env.DYADIC_API_KEY ?? "";
|
|
232
261
|
if (!this.apiKey)
|
|
233
262
|
throw new DyadicError("No API key. Pass apiKey or set DYADIC_API_KEY. Create one at https://dyadic.app/workspace/?view=api.");
|
|
234
263
|
this.baseURL = (options.baseURL ?? env.DYADIC_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
264
|
+
let base;
|
|
265
|
+
try {
|
|
266
|
+
base = new URL(this.baseURL);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
throw new DyadicError(`baseURL isn't a valid URL: ${this.baseURL}`);
|
|
270
|
+
}
|
|
271
|
+
if (!(base.protocol === "https:" || (base.protocol === "http:" && privateHost(base.hostname))) || base.username || base.password) {
|
|
272
|
+
throw new DyadicError("baseURL must be an https:// URL (http:// only for a local or private-network API), without a user name or password.");
|
|
273
|
+
}
|
|
274
|
+
this.origin = base.origin;
|
|
235
275
|
this.timeoutMs = options.timeoutMs ?? 600000;
|
|
236
276
|
this.maxRetries = options.maxRetries ?? 2;
|
|
237
277
|
this.appName = options.appName;
|
|
238
278
|
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
239
279
|
}
|
|
240
|
-
/**
|
|
241
|
-
* with
|
|
280
|
+
/** The full URL for `path`: a relative path joins the base URL; an absolute URL is allowed only on the base URL's own
|
|
281
|
+
* origin. The key goes with every request, so it must never reach another host, a plain-http downgrade, or a URL with
|
|
282
|
+
* credentials in it. */
|
|
283
|
+
url(path) {
|
|
284
|
+
if (path.startsWith("//") || path.startsWith("\\\\"))
|
|
285
|
+
throw new DyadicError("Protocol-relative URLs aren't allowed; pass a path like /models.");
|
|
286
|
+
const candidate = SCHEME.test(path) ? path : this.baseURL + (path.startsWith("/") ? path : `/${path}`);
|
|
287
|
+
let target;
|
|
288
|
+
try {
|
|
289
|
+
target = new URL(candidate);
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
throw new DyadicError(`Not a valid URL: ${path}`);
|
|
293
|
+
}
|
|
294
|
+
if (target.username || target.password || target.origin !== this.origin) {
|
|
295
|
+
throw new DyadicError(`Refusing to send the API key to ${target.protocol}//${target.host}: this client only calls ${this.origin}.`);
|
|
296
|
+
}
|
|
297
|
+
return target.toString();
|
|
298
|
+
}
|
|
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`. */
|
|
242
302
|
async request(method, path, body, stream = false, options = {}) {
|
|
243
303
|
const headers = { Authorization: `Bearer ${this.apiKey}`, Accept: stream ? "text/event-stream" : options.raw ? "*/*" : "application/json" };
|
|
244
304
|
if (body !== undefined && !options.form)
|
|
245
305
|
headers["Content-Type"] = "application/json";
|
|
246
306
|
const query = Object.entries(options.query ?? {}).filter(([, v]) => v !== undefined).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
|
|
247
|
-
const
|
|
307
|
+
const target = this.url(path);
|
|
308
|
+
const url = `${target}${query ? `${target.includes("?") ? "&" : "?"}${query}` : ""}`;
|
|
248
309
|
const payload = options.form ?? (body === undefined ? undefined : JSON.stringify(body));
|
|
249
310
|
if (this.appName)
|
|
250
311
|
headers["X-Title"] = this.appName;
|
|
312
|
+
const replayable = Boolean(options.idempotent) || SAFE_METHODS.has(method.toUpperCase());
|
|
313
|
+
if (!replayable || options.idempotencyKey)
|
|
314
|
+
headers["Idempotency-Key"] = options.idempotencyKey ?? newKey();
|
|
251
315
|
for (let attempt = 0;; attempt++) {
|
|
252
316
|
const controller = new AbortController();
|
|
253
317
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
254
318
|
timer?.unref?.();
|
|
255
319
|
let response;
|
|
256
320
|
try {
|
|
257
|
-
|
|
321
|
+
// Never follow a redirect: it would carry the key wherever it points.
|
|
322
|
+
response = await this.fetcher(url, { method, headers, body: payload, signal: controller.signal, redirect: "manual" });
|
|
258
323
|
}
|
|
259
324
|
catch (error) {
|
|
260
325
|
clearTimeout(timer);
|
|
261
|
-
if (attempt < this.maxRetries &&
|
|
326
|
+
if (attempt < this.maxRetries && (replayable || sentNothing(error))) {
|
|
262
327
|
await sleep(500 * 2 ** attempt);
|
|
263
328
|
continue;
|
|
264
329
|
}
|
|
265
330
|
throw new DyadicError(`Could not reach the Dyadic API: ${error.message}`);
|
|
266
331
|
}
|
|
332
|
+
if (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
|
|
333
|
+
clearTimeout(timer);
|
|
334
|
+
throw new DyadicError(`The API answered with a redirect (HTTP ${response.status}); not following it with your key.`, { status: response.status });
|
|
335
|
+
}
|
|
267
336
|
if (!stream)
|
|
268
337
|
clearTimeout(timer);
|
|
269
338
|
if (response.ok) {
|
|
@@ -279,7 +348,8 @@ export class Dyadic {
|
|
|
279
348
|
clearTimeout(timer);
|
|
280
349
|
const data = await response.json().catch(() => ({ error: { message: `HTTP ${response.status}` } }));
|
|
281
350
|
const error = data?.error ?? {};
|
|
282
|
-
|
|
351
|
+
const retry = replayable ? RETRY.has(response.status) : response.status === 429 && ADMISSION_CODES.has(error.code);
|
|
352
|
+
if (retry && attempt < this.maxRetries && error.type !== "insufficient_quota") {
|
|
283
353
|
const after = Number(response.headers.get("retry-after"));
|
|
284
354
|
await sleep(Number.isFinite(after) && after > 0 ? Math.min(after, 30) * 1000 : 500 * 2 ** attempt);
|
|
285
355
|
continue;
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dyadic",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
7
7
|
"types": "./dist/index.d.ts",
|
|
8
8
|
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
|
9
|
-
"files": ["dist", "README.md"],
|
|
9
|
+
"files": ["dist", "README.md", "CHANGELOG.md"],
|
|
10
10
|
"engines": { "node": ">=18" },
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"homepage": "https://dyadic.app/developers/",
|
|
13
|
-
"scripts": { "build": "tsc", "test": "node --test test/client.test.js" },
|
|
13
|
+
"scripts": { "build": "tsc", "pretest": "tsc", "test": "node --test test/client.test.js test/transport.test.js", "prepack": "tsc" },
|
|
14
14
|
"devDependencies": { "typescript": "5.6.3" }
|
|
15
15
|
}
|