dyadic 1.0.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 +22 -0
- package/README.md +94 -16
- package/dist/index.d.ts +159 -3
- package/dist/index.js +133 -10
- package/package.json +15 -15
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,16 +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
|
|
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
|
-
|
|
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
|
+
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";
|
|
@@ -347,10 +347,124 @@ export interface Batch {
|
|
|
347
347
|
expires_at: number | null;
|
|
348
348
|
metadata: Record<string, string>;
|
|
349
349
|
}
|
|
350
|
+
/** Dyadic's built-in tools an application's run may use. */
|
|
351
|
+
export type BuiltinTool = "data.web_search" | "data.web_fetch" | "image.generate" | "vision.describe_image";
|
|
352
|
+
/** Your own agent, sent with the run: its name and instructions, optional
|
|
353
|
+
* behaviour overrides (tool_rules, safety, style, citations, research: text to
|
|
354
|
+
* replace the default, or false to turn it off) and model choices. */
|
|
355
|
+
export interface AgentConfig {
|
|
356
|
+
id: string;
|
|
357
|
+
name: string;
|
|
358
|
+
instructions?: string;
|
|
359
|
+
behaviour?: Record<string, string | false>;
|
|
360
|
+
models?: Record<string, string>;
|
|
361
|
+
tools?: Record<string, boolean>;
|
|
362
|
+
}
|
|
363
|
+
export interface RunParams {
|
|
364
|
+
agent: string | AgentConfig;
|
|
365
|
+
messages: ChatMessage[];
|
|
366
|
+
builtin_tools?: BuiltinTool[];
|
|
367
|
+
functions?: Tool[];
|
|
368
|
+
end_user?: string;
|
|
369
|
+
conversation_id?: string;
|
|
370
|
+
max_tokens?: number;
|
|
371
|
+
temperature?: number;
|
|
372
|
+
think?: boolean;
|
|
373
|
+
research?: "auto" | "deep";
|
|
374
|
+
stream?: boolean;
|
|
375
|
+
/** Your own cap on what this run may spend, in USD (for example what your user has left). */
|
|
376
|
+
max_cost_usd?: number;
|
|
377
|
+
/** Ask for up to four suggested follow-up questions (`followups` on the completed run). */
|
|
378
|
+
followups?: boolean;
|
|
379
|
+
}
|
|
380
|
+
export interface Run {
|
|
381
|
+
object: "run";
|
|
382
|
+
type: "run.completed";
|
|
383
|
+
run_id: string;
|
|
384
|
+
status: "completed" | "requires_action";
|
|
385
|
+
agent: string;
|
|
386
|
+
output_text: string;
|
|
387
|
+
function_calls: ToolCall[];
|
|
388
|
+
messages: ChatMessage[];
|
|
389
|
+
images: {
|
|
390
|
+
url: string;
|
|
391
|
+
prompt?: string;
|
|
392
|
+
quality?: string;
|
|
393
|
+
}[];
|
|
394
|
+
sources: {
|
|
395
|
+
title?: string;
|
|
396
|
+
url: string;
|
|
397
|
+
}[];
|
|
398
|
+
tools: string[];
|
|
399
|
+
model: string;
|
|
400
|
+
models: string[];
|
|
401
|
+
usage: Usage;
|
|
402
|
+
credits: number;
|
|
403
|
+
cost_usd: number;
|
|
404
|
+
estimated: boolean;
|
|
405
|
+
model_calls: number;
|
|
406
|
+
operation_id: string;
|
|
407
|
+
seconds: number;
|
|
408
|
+
log_id?: string;
|
|
409
|
+
followups?: string[];
|
|
410
|
+
}
|
|
411
|
+
export interface RunEvent {
|
|
412
|
+
type: string;
|
|
413
|
+
[key: string]: unknown;
|
|
414
|
+
}
|
|
415
|
+
/** A function your application runs: its schema and the handler called with the parsed arguments. */
|
|
416
|
+
export interface FunctionHandler {
|
|
417
|
+
description?: string;
|
|
418
|
+
parameters?: Record<string, unknown>;
|
|
419
|
+
handler: (args: any) => unknown | Promise<unknown>;
|
|
420
|
+
}
|
|
421
|
+
/** Connected apps: whether the person with a linked identity approved this application. */
|
|
422
|
+
export interface Connection {
|
|
423
|
+
linked: boolean;
|
|
424
|
+
authorized: boolean;
|
|
425
|
+
scopes?: string[];
|
|
426
|
+
authorize_url?: string;
|
|
427
|
+
link_url?: string;
|
|
428
|
+
}
|
|
429
|
+
export interface SaveFile {
|
|
430
|
+
name?: string;
|
|
431
|
+
media_type?: string;
|
|
432
|
+
data: string;
|
|
433
|
+
}
|
|
434
|
+
export interface SaveMessage {
|
|
435
|
+
role: "user" | "assistant";
|
|
436
|
+
name?: string;
|
|
437
|
+
text: string;
|
|
438
|
+
url?: string;
|
|
439
|
+
}
|
|
440
|
+
export interface WorkspaceSaveParams {
|
|
441
|
+
discord_id: string | number;
|
|
442
|
+
kind: "message" | "conversation" | "image" | "file";
|
|
443
|
+
title?: string;
|
|
444
|
+
text?: string;
|
|
445
|
+
messages?: SaveMessage[];
|
|
446
|
+
as_chat?: boolean;
|
|
447
|
+
files?: SaveFile[];
|
|
448
|
+
url?: string;
|
|
449
|
+
source?: Record<string, unknown>;
|
|
450
|
+
}
|
|
451
|
+
export interface WorkspaceSave {
|
|
452
|
+
saved: {
|
|
453
|
+
type: "reference" | "chat" | "file";
|
|
454
|
+
id: string;
|
|
455
|
+
}[];
|
|
456
|
+
url: string;
|
|
457
|
+
}
|
|
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. */
|
|
350
461
|
export interface RequestOptions {
|
|
351
462
|
form?: FormData;
|
|
352
463
|
query?: Record<string, string | number | undefined>;
|
|
353
464
|
raw?: boolean;
|
|
465
|
+
executes?: boolean;
|
|
466
|
+
idempotent?: boolean;
|
|
467
|
+
idempotencyKey?: string;
|
|
354
468
|
}
|
|
355
469
|
/** Bytes from the API (speech audio, file downloads). */
|
|
356
470
|
export declare class Binary {
|
|
@@ -397,6 +511,8 @@ export declare class Stream<T> implements AsyncIterable<T> {
|
|
|
397
511
|
usage: Usage | undefined;
|
|
398
512
|
/** Responses streams: the final Response once response.completed arrives. */
|
|
399
513
|
response: ResponseObject | undefined;
|
|
514
|
+
/** Run streams: the completed run once run.completed arrives. */
|
|
515
|
+
run: Run | undefined;
|
|
400
516
|
readonly requestId: string;
|
|
401
517
|
constructor(http: Response, controller: AbortController, onDone?: () => void);
|
|
402
518
|
[Symbol.asyncIterator](): AsyncIterator<T>;
|
|
@@ -410,11 +526,39 @@ export declare class Dyadic {
|
|
|
410
526
|
readonly timeoutMs: number;
|
|
411
527
|
readonly maxRetries: number;
|
|
412
528
|
readonly appName?: string;
|
|
529
|
+
/** The one origin this client ever sends its key to. */
|
|
530
|
+
readonly origin: string;
|
|
413
531
|
private fetcher;
|
|
414
532
|
constructor(options?: ClientOptions);
|
|
415
|
-
/**
|
|
416
|
-
* with
|
|
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`. */
|
|
417
540
|
request<T>(method: string, path: string, body?: unknown, stream?: boolean, options?: RequestOptions): Promise<T>;
|
|
541
|
+
/** Dyadic's own endpoints sit beside the OpenAI-compatible base: .../api/openai/v1 -> .../api/v1. */
|
|
542
|
+
platformURL(path: string): string;
|
|
543
|
+
/** An agent answers a conversation: one of the account's agents by id ("iris") or your own agent inline,
|
|
544
|
+
* with Dyadic's built-in tools and your own functions. A run that calls your functions ends with status
|
|
545
|
+
* "requires_action": run `function_calls`, append `messages` and your results, and run again (or use `run`). */
|
|
546
|
+
readonly runs: {
|
|
547
|
+
create: {
|
|
548
|
+
(params: RunParams & {
|
|
549
|
+
stream: true;
|
|
550
|
+
}): Promise<Stream<RunEvent>>;
|
|
551
|
+
(params: RunParams & {
|
|
552
|
+
stream?: false;
|
|
553
|
+
}): Promise<Run>;
|
|
554
|
+
};
|
|
555
|
+
/** Run to completion, calling your functions whenever the agent asks. The result's `messages` hold
|
|
556
|
+
* everything the run added, ready to append to your conversation. */
|
|
557
|
+
run: (params: Omit<RunParams, "functions" | "stream"> & {
|
|
558
|
+
functions?: Record<string, FunctionHandler>;
|
|
559
|
+
maxRounds?: number;
|
|
560
|
+
}) => Promise<Run>;
|
|
561
|
+
};
|
|
418
562
|
readonly models: {
|
|
419
563
|
list: () => Promise<{
|
|
420
564
|
object: "list";
|
|
@@ -529,6 +673,18 @@ export declare class Dyadic {
|
|
|
529
673
|
retrieve: (id: string) => Promise<Batch>;
|
|
530
674
|
cancel: (id: string) => Promise<Batch>;
|
|
531
675
|
};
|
|
676
|
+
/** Connected apps. Show the person `authorize_url` when `authorized` is false. */
|
|
677
|
+
readonly connections: {
|
|
678
|
+
discord: (discordId: string | number) => Promise<Connection>;
|
|
679
|
+
};
|
|
680
|
+
/** Save into the Workspace of the person with this Discord account, once they approved this application.
|
|
681
|
+
* Not approved yet throws DyadicError with code "authorization_required" (`error.body.authorize_url`),
|
|
682
|
+
* whether or not they have a Dyadic account yet. */
|
|
683
|
+
readonly workspace: {
|
|
684
|
+
saves: {
|
|
685
|
+
create: ({ discord_id, ...fields }: WorkspaceSaveParams) => Promise<WorkspaceSave>;
|
|
686
|
+
};
|
|
687
|
+
};
|
|
532
688
|
/** One question, one answer: the reply's text. */
|
|
533
689
|
ask(prompt: string, options?: Omit<ChatCompletionParams, "messages" | "model" | "stream"> & {
|
|
534
690
|
model?: Model;
|
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 {
|
|
@@ -90,12 +90,18 @@ export class Stream {
|
|
|
90
90
|
}
|
|
91
91
|
if (chunk.usage)
|
|
92
92
|
this.usage = chunk.usage;
|
|
93
|
+
if (chunk.type === "run.completed") {
|
|
94
|
+
this.run = chunk;
|
|
95
|
+
yield chunk;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
93
98
|
if ((chunk.type === "response.completed" || chunk.type === "response.incomplete") && chunk.response) {
|
|
94
99
|
this.response = chunk.response;
|
|
95
100
|
this.usage = chunk.response.usage ?? undefined;
|
|
96
101
|
}
|
|
97
102
|
const choice = chunk.choices?.[0];
|
|
98
|
-
this.text += chunk.type === "response.output_text.delta" ? chunk.delta ?? "" :
|
|
103
|
+
this.text += chunk.type === "response.output_text.delta" ? chunk.delta ?? "" : chunk.type === "delta" ? chunk.text ?? ""
|
|
104
|
+
: choice?.delta?.content ?? choice?.text ?? "";
|
|
99
105
|
yield chunk;
|
|
100
106
|
}
|
|
101
107
|
}
|
|
@@ -109,10 +115,72 @@ export class Stream {
|
|
|
109
115
|
async finalText() { for await (const _ of this) { /* drain */ } return this.text; }
|
|
110
116
|
abort() { this.controller.abort(); }
|
|
111
117
|
}
|
|
112
|
-
|
|
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+.-]*:/;
|
|
113
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
|
+
};
|
|
114
147
|
export class Dyadic {
|
|
115
148
|
constructor(options = {}) {
|
|
149
|
+
/** An agent answers a conversation: one of the account's agents by id ("iris") or your own agent inline,
|
|
150
|
+
* 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`). */
|
|
152
|
+
this.runs = {
|
|
153
|
+
create: ((params) => this.request("POST", this.platformURL("/runs"), params, Boolean(params.stream), { executes: true })),
|
|
154
|
+
/** Run to completion, calling your functions whenever the agent asks. The result's `messages` hold
|
|
155
|
+
* everything the run added, ready to append to your conversation. */
|
|
156
|
+
run: async (params) => {
|
|
157
|
+
const { functions = {}, maxRounds = 8, ...rest } = params;
|
|
158
|
+
const schemas = Object.entries(functions).map(([name, f]) => ({ type: "function", function: { name, description: f.description ?? "", parameters: f.parameters ?? { type: "object", properties: {} } } }));
|
|
159
|
+
const conversation = [...rest.messages];
|
|
160
|
+
const added = [];
|
|
161
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
162
|
+
const result = await this.runs.create({ ...rest, messages: conversation, functions: schemas.length ? schemas : undefined, stream: false });
|
|
163
|
+
if (result.status !== "requires_action")
|
|
164
|
+
return { ...result, messages: [...added, { role: "assistant", content: result.output_text }] };
|
|
165
|
+
const step = [...result.messages];
|
|
166
|
+
for (const call of result.function_calls) {
|
|
167
|
+
let args = {};
|
|
168
|
+
try {
|
|
169
|
+
args = JSON.parse(call.function.arguments || "{}");
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
args = {};
|
|
173
|
+
}
|
|
174
|
+
const fn = functions[call.function.name];
|
|
175
|
+
const output = fn ? await fn.handler(args) : { error: "unknown function" };
|
|
176
|
+
step.push({ role: "tool", tool_call_id: call.id, content: typeof output === "string" ? output : JSON.stringify(output) });
|
|
177
|
+
}
|
|
178
|
+
conversation.push(...step);
|
|
179
|
+
added.push(...step);
|
|
180
|
+
}
|
|
181
|
+
throw new DyadicError(`The run still wanted functions after ${maxRounds} rounds.`);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
116
184
|
this.models = {
|
|
117
185
|
list: () => this.request("GET", "/models"),
|
|
118
186
|
retrieve: (model) => this.request("GET", `/models/${model}`),
|
|
@@ -171,44 +239,96 @@ export class Dyadic {
|
|
|
171
239
|
retrieve: (id) => this.request("GET", `/batches/${id}`),
|
|
172
240
|
cancel: (id) => this.request("POST", `/batches/${id}/cancel`),
|
|
173
241
|
};
|
|
174
|
-
|
|
242
|
+
/** Connected apps. Show the person `authorize_url` when `authorized` is false. */
|
|
243
|
+
this.connections = {
|
|
244
|
+
discord: (discordId) => this.request("GET", this.platformURL(`/connections/discord/${discordId}`)),
|
|
245
|
+
};
|
|
246
|
+
/** Save into the Workspace of the person with this Discord account, once they approved this application.
|
|
247
|
+
* Not approved yet throws DyadicError with code "authorization_required" (`error.body.authorize_url`),
|
|
248
|
+
* whether or not they have a Dyadic account yet. */
|
|
249
|
+
this.workspace = {
|
|
250
|
+
saves: {
|
|
251
|
+
create: ({ discord_id, ...fields }) => this.request("POST", this.platformURL("/workspace/saves"), { user: { discord_id: String(discord_id) }, ...fields }, false, { executes: true }),
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
// process.env where it exists (Node, Bun, Deno with compat). Keep the key on a server: never ship it to a browser.
|
|
175
255
|
const env = globalThis.process?.env ?? {};
|
|
176
256
|
this.apiKey = options.apiKey ?? env.DYADIC_API_KEY ?? "";
|
|
177
257
|
if (!this.apiKey)
|
|
178
258
|
throw new DyadicError("No API key. Pass apiKey or set DYADIC_API_KEY. Create one at https://dyadic.app/workspace/?view=api.");
|
|
179
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;
|
|
180
271
|
this.timeoutMs = options.timeoutMs ?? 600000;
|
|
181
272
|
this.maxRetries = options.maxRetries ?? 2;
|
|
182
273
|
this.appName = options.appName;
|
|
183
274
|
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
184
275
|
}
|
|
185
|
-
/**
|
|
186
|
-
* with
|
|
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`. */
|
|
187
298
|
async request(method, path, body, stream = false, options = {}) {
|
|
188
299
|
const headers = { Authorization: `Bearer ${this.apiKey}`, Accept: stream ? "text/event-stream" : options.raw ? "*/*" : "application/json" };
|
|
189
300
|
if (body !== undefined && !options.form)
|
|
190
301
|
headers["Content-Type"] = "application/json";
|
|
191
302
|
const query = Object.entries(options.query ?? {}).filter(([, v]) => v !== undefined).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`).join("&");
|
|
192
|
-
const
|
|
303
|
+
const target = this.url(path);
|
|
304
|
+
const url = `${target}${query ? `${target.includes("?") ? "&" : "?"}${query}` : ""}`;
|
|
193
305
|
const payload = options.form ?? (body === undefined ? undefined : JSON.stringify(body));
|
|
194
306
|
if (this.appName)
|
|
195
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();
|
|
196
311
|
for (let attempt = 0;; attempt++) {
|
|
197
312
|
const controller = new AbortController();
|
|
198
313
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
199
314
|
timer?.unref?.();
|
|
200
315
|
let response;
|
|
201
316
|
try {
|
|
202
|
-
|
|
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" });
|
|
203
319
|
}
|
|
204
320
|
catch (error) {
|
|
205
321
|
clearTimeout(timer);
|
|
206
|
-
if (attempt < this.maxRetries) {
|
|
322
|
+
if (attempt < this.maxRetries && (replayable || sentNothing(error))) {
|
|
207
323
|
await sleep(500 * 2 ** attempt);
|
|
208
324
|
continue;
|
|
209
325
|
}
|
|
210
326
|
throw new DyadicError(`Could not reach the Dyadic API: ${error.message}`);
|
|
211
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
|
+
}
|
|
212
332
|
if (!stream)
|
|
213
333
|
clearTimeout(timer);
|
|
214
334
|
if (response.ok) {
|
|
@@ -224,7 +344,8 @@ export class Dyadic {
|
|
|
224
344
|
clearTimeout(timer);
|
|
225
345
|
const data = await response.json().catch(() => ({ error: { message: `HTTP ${response.status}` } }));
|
|
226
346
|
const error = data?.error ?? {};
|
|
227
|
-
|
|
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") {
|
|
228
349
|
const after = Number(response.headers.get("retry-after"));
|
|
229
350
|
await sleep(Number.isFinite(after) && after > 0 ? Math.min(after, 30) * 1000 : 500 * 2 ** attempt);
|
|
230
351
|
continue;
|
|
@@ -233,6 +354,8 @@ export class Dyadic {
|
|
|
233
354
|
requestId: response.headers.get("x-request-id") ?? "", body: data });
|
|
234
355
|
}
|
|
235
356
|
}
|
|
357
|
+
/** Dyadic's own endpoints sit beside the OpenAI-compatible base: .../api/openai/v1 -> .../api/v1. */
|
|
358
|
+
platformURL(path) { return this.baseURL.replace(/\/openai(\/v1)?$/, "/v1") + path; }
|
|
236
359
|
/** One question, one answer: the reply's text. */
|
|
237
360
|
async ask(prompt, options = {}) {
|
|
238
361
|
const { model = "iris", system, ...params } = options;
|
package/package.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dyadic",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "The official TypeScript and JavaScript SDK for the Dyadic 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
|
+
}
|