@pyai/sdk 0.1.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/README.md +114 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +165 -0
- package/dist/index.d.ts +146 -0
- package/dist/index.js +199 -0
- package/package.json +39 -0
- package/src/cli.ts +177 -0
- package/src/index.ts +287 -0
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# @pyai/sdk
|
|
2
|
+
|
|
3
|
+
Official TypeScript/JavaScript SDK for the [PyAI API](https://api.pyai.com) —
|
|
4
|
+
Hear (speech-to-text), Speak (text-to-speech + cloning), Cue (turn detection +
|
|
5
|
+
KB context), and Omni (realtime voice agents). Zero dependencies; runs in the
|
|
6
|
+
browser and Node 18+.
|
|
7
|
+
|
|
8
|
+
The contract is `https://api.pyai.com/openapi.json`. This SDK wraps it
|
|
9
|
+
ergonomically with typed errors, automatic retries, and a realtime helper.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @pyai/sdk
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import PyAI from "@pyai/sdk";
|
|
21
|
+
|
|
22
|
+
const pyai = new PyAI({ apiKey: process.env.PYAI_API_KEY! });
|
|
23
|
+
|
|
24
|
+
// Text-to-speech
|
|
25
|
+
const audio = await pyai.audio.speech({ input: "Hello from PyAI.", voice: "stock_sarah_style2" });
|
|
26
|
+
await Bun.write?.("hello.wav", audio); // or fs.writeFile in Node
|
|
27
|
+
|
|
28
|
+
// Text-to-speech, streamed — start playing/forwarding at the first chunk
|
|
29
|
+
// (tens of ms) instead of waiting for the whole clip. Use mp3 for smooth
|
|
30
|
+
// progressive playback.
|
|
31
|
+
const stream = await pyai.audio.speechStream({ input: "Hello from PyAI.", voice: "stock_sarah_style2", response_format: "mp3" });
|
|
32
|
+
for await (const chunk of stream) writeToSpeakerOrResponse(chunk);
|
|
33
|
+
|
|
34
|
+
// Voices
|
|
35
|
+
const { data: voices } = await pyai.voices.list({ gender: "female" });
|
|
36
|
+
|
|
37
|
+
// Async transcription (safe retry with an idempotency key)
|
|
38
|
+
const job = await pyai.transcriptionJobs.create(
|
|
39
|
+
{ audio_url: "https://example.com/call.wav", diarize: true },
|
|
40
|
+
{ idempotencyKey: crypto.randomUUID() },
|
|
41
|
+
);
|
|
42
|
+
const done = await pyai.transcriptionJobs.get(job.job_id);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Realtime (Omni)
|
|
46
|
+
|
|
47
|
+
Keys travel as a WebSocket subprotocol so this works in the browser:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
const ws = pyai.connectRealtime({ product: "omni", agentId: "agent_123" });
|
|
51
|
+
ws.addEventListener("message", (e) => console.log(e.data));
|
|
52
|
+
|
|
53
|
+
// Or build the pieces yourself for a custom WS library:
|
|
54
|
+
const url = pyai.realtimeURL({ product: "omni", agentId: "agent_123" });
|
|
55
|
+
const proto = pyai.realtimeSubprotocol();
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
> Omni uses the native `wss://api.pyai.com/v1/omni` surface. `connectRealtime`
|
|
59
|
+
> targets it by default (`product: "omni"`); `product: "flow"` uses
|
|
60
|
+
> `/v1/realtime`. The older `/v2/omni/chat` URL is deprecated but still works.
|
|
61
|
+
|
|
62
|
+
## Errors
|
|
63
|
+
|
|
64
|
+
Failures throw `PyAIError` with a stable `code` (branch on it, not the message):
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { PyAIError } from "@pyai/sdk";
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await pyai.audio.speech({ input: "hi" });
|
|
71
|
+
} catch (err) {
|
|
72
|
+
if (err instanceof PyAIError && err.code === "credit_exhausted") {
|
|
73
|
+
// out of prepaid credit — add credit or use a sandbox key
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Common codes: `unauthorized`, `forbidden`, `credit_exhausted`,
|
|
79
|
+
`rate_limit_exceeded`, `concurrency_limit_exceeded`, `idempotency_conflict`.
|
|
80
|
+
`429`/`5xx` are retried automatically (honoring `Retry-After`); tune with
|
|
81
|
+
`new PyAI({ apiKey, maxRetries })`.
|
|
82
|
+
|
|
83
|
+
## CLI (`pyai`)
|
|
84
|
+
|
|
85
|
+
Installing the package also provides a `pyai` command — a smoke tester that
|
|
86
|
+
proves your key, the endpoint, and audio synthesis in one shot:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
export PYAI_API_KEY=pyai_test_...
|
|
90
|
+
npx pyai smoke
|
|
91
|
+
# PASS models.list — 12 models
|
|
92
|
+
# PASS voices.list — 38 voices
|
|
93
|
+
# PASS audio.speech — 45210 bytes of audio
|
|
94
|
+
# All checks passed. Your key, the endpoint, and audio synthesis work.
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Other commands:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
pyai models
|
|
101
|
+
pyai voices --gender female --region en_us
|
|
102
|
+
pyai speak --text "Hello" --voice stock_sarah_style2 --out hello.wav
|
|
103
|
+
pyai transcribe --url https://example.com/call.wav --diarize --poll
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Auth comes from `PYAI_API_KEY` / `PYAI_BASE_URL` (or `--api-key` / `--base-url`).
|
|
107
|
+
|
|
108
|
+
## Develop
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npm install
|
|
112
|
+
npm test # node --test, fetch injected (no network)
|
|
113
|
+
npm run build # emits dist/ (incl. the pyai CLI bin)
|
|
114
|
+
```
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pyai — CLI smoke tester for the PyAI API: proves your key, the endpoint,
|
|
4
|
+
* and audio synthesis in one command.
|
|
5
|
+
*
|
|
6
|
+
* Commands:
|
|
7
|
+
* pyai smoke run models+voices+speak and report PASS/FAIL
|
|
8
|
+
* pyai models list models
|
|
9
|
+
* pyai voices [--gender g --region r] list voices
|
|
10
|
+
* pyai speak --text T [--voice V] [--out f.wav]
|
|
11
|
+
* pyai transcribe --url U [--diarize] [--poll]
|
|
12
|
+
*
|
|
13
|
+
* Auth: PYAI_API_KEY env (or --api-key). Base URL: PYAI_BASE_URL (or --base-url).
|
|
14
|
+
* Zero deps — uses the bundled SDK.
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pyai — CLI smoke tester for the PyAI API: proves your key, the endpoint,
|
|
4
|
+
* and audio synthesis in one command.
|
|
5
|
+
*
|
|
6
|
+
* Commands:
|
|
7
|
+
* pyai smoke run models+voices+speak and report PASS/FAIL
|
|
8
|
+
* pyai models list models
|
|
9
|
+
* pyai voices [--gender g --region r] list voices
|
|
10
|
+
* pyai speak --text T [--voice V] [--out f.wav]
|
|
11
|
+
* pyai transcribe --url U [--diarize] [--poll]
|
|
12
|
+
*
|
|
13
|
+
* Auth: PYAI_API_KEY env (or --api-key). Base URL: PYAI_BASE_URL (or --base-url).
|
|
14
|
+
* Zero deps — uses the bundled SDK.
|
|
15
|
+
*/
|
|
16
|
+
import { writeFile } from "node:fs/promises";
|
|
17
|
+
import PyAI, { PyAIError } from "./index.js";
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const flags = { _: [] };
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const a = argv[i];
|
|
22
|
+
if (a.startsWith("--")) {
|
|
23
|
+
const key = a.slice(2);
|
|
24
|
+
const next = argv[i + 1];
|
|
25
|
+
if (next === undefined || next.startsWith("--")) {
|
|
26
|
+
flags[key] = true;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
flags[key] = next;
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
flags._.push(a);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return flags;
|
|
38
|
+
}
|
|
39
|
+
function flag(flags, key) {
|
|
40
|
+
const v = flags[key];
|
|
41
|
+
return typeof v === "string" ? v : undefined;
|
|
42
|
+
}
|
|
43
|
+
function client(flags) {
|
|
44
|
+
const apiKey = flag(flags, "api-key") ?? process.env.PYAI_API_KEY;
|
|
45
|
+
if (!apiKey) {
|
|
46
|
+
fail("No API key. Set PYAI_API_KEY or pass --api-key pyai_test_...");
|
|
47
|
+
}
|
|
48
|
+
return new PyAI({ apiKey: apiKey, baseURL: flag(flags, "base-url") ?? process.env.PYAI_BASE_URL });
|
|
49
|
+
}
|
|
50
|
+
function out(msg) {
|
|
51
|
+
process.stdout.write(`${msg}\n`);
|
|
52
|
+
}
|
|
53
|
+
function fail(msg) {
|
|
54
|
+
process.stderr.write(`pyai: ${msg}\n`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
const USAGE = `pyai — PyAI API CLI
|
|
58
|
+
|
|
59
|
+
Usage:
|
|
60
|
+
pyai smoke run a key/endpoint/audio smoke test
|
|
61
|
+
pyai models list models
|
|
62
|
+
pyai voices [--gender g] [--region r] list voices
|
|
63
|
+
pyai speak --text T [--voice V] [--out f.wav]
|
|
64
|
+
pyai transcribe --url U [--diarize] [--poll]
|
|
65
|
+
|
|
66
|
+
Auth: PYAI_API_KEY (or --api-key). Base: PYAI_BASE_URL (or --base-url).`;
|
|
67
|
+
async function cmdModels(flags) {
|
|
68
|
+
const pyai = client(flags);
|
|
69
|
+
const res = await pyai.models.list();
|
|
70
|
+
out(JSON.stringify(res, null, 2));
|
|
71
|
+
}
|
|
72
|
+
async function cmdVoices(flags) {
|
|
73
|
+
const pyai = client(flags);
|
|
74
|
+
const res = await pyai.voices.list({ gender: flag(flags, "gender"), region: flag(flags, "region") });
|
|
75
|
+
out(JSON.stringify(res, null, 2));
|
|
76
|
+
}
|
|
77
|
+
async function cmdSpeak(flags) {
|
|
78
|
+
const pyai = client(flags);
|
|
79
|
+
const text = flag(flags, "text");
|
|
80
|
+
if (!text)
|
|
81
|
+
fail("speak requires --text");
|
|
82
|
+
const audio = await pyai.audio.speech({ input: text, voice: flag(flags, "voice") });
|
|
83
|
+
const outPath = flag(flags, "out") ?? "pyai-speak.wav";
|
|
84
|
+
await writeFile(outPath, Buffer.from(audio));
|
|
85
|
+
out(`wrote ${Buffer.from(audio).byteLength} bytes -> ${outPath}`);
|
|
86
|
+
}
|
|
87
|
+
async function cmdTranscribe(flags) {
|
|
88
|
+
const pyai = client(flags);
|
|
89
|
+
const url = flag(flags, "url");
|
|
90
|
+
if (!url)
|
|
91
|
+
fail("transcribe requires --url (an https audio URL)");
|
|
92
|
+
const job = await pyai.transcriptionJobs.create({ audio_url: url, diarize: flags.diarize === true });
|
|
93
|
+
out(`job ${job.job_id} (${job.status})`);
|
|
94
|
+
if (flags.poll !== true)
|
|
95
|
+
return;
|
|
96
|
+
for (let i = 0; i < 60; i++) {
|
|
97
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
98
|
+
const j = await pyai.transcriptionJobs.get(job.job_id);
|
|
99
|
+
if (j.status === "completed" || j.status === "failed" || j.status === "cancelled") {
|
|
100
|
+
out(JSON.stringify(j, null, 2));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
out(`job ${job.job_id} still running after polling; check later.`);
|
|
105
|
+
}
|
|
106
|
+
/** The headline: prove key + endpoint + audio in one command. */
|
|
107
|
+
async function cmdSmoke(flags) {
|
|
108
|
+
const pyai = client(flags);
|
|
109
|
+
const checks = [];
|
|
110
|
+
const run = async (name, fn) => {
|
|
111
|
+
try {
|
|
112
|
+
checks.push({ name, ok: true, detail: await fn() });
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}` : err.message;
|
|
116
|
+
checks.push({ name, ok: false, detail });
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
await run("models.list", async () => {
|
|
120
|
+
const r = await pyai.models.list();
|
|
121
|
+
return `${r.data.length} models`;
|
|
122
|
+
});
|
|
123
|
+
await run("voices.list", async () => {
|
|
124
|
+
const r = await pyai.voices.list();
|
|
125
|
+
return `${r.data.length} voices`;
|
|
126
|
+
});
|
|
127
|
+
await run("audio.speech", async () => {
|
|
128
|
+
const audio = await pyai.audio.speech({ input: "PyAI smoke test." });
|
|
129
|
+
return `${Buffer.from(audio).byteLength} bytes of audio`;
|
|
130
|
+
});
|
|
131
|
+
for (const c of checks)
|
|
132
|
+
out(`${c.ok ? "PASS" : "FAIL"} ${c.name} — ${c.detail}`);
|
|
133
|
+
const allOk = checks.every((c) => c.ok);
|
|
134
|
+
out(allOk ? "\nAll checks passed. Your key, the endpoint, and audio synthesis work." : "\nSome checks failed (see above).");
|
|
135
|
+
if (!allOk)
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
async function main() {
|
|
139
|
+
const flags = parseArgs(process.argv.slice(2));
|
|
140
|
+
const cmd = flags._[0];
|
|
141
|
+
switch (cmd) {
|
|
142
|
+
case "smoke":
|
|
143
|
+
return cmdSmoke(flags);
|
|
144
|
+
case "models":
|
|
145
|
+
return cmdModels(flags);
|
|
146
|
+
case "voices":
|
|
147
|
+
return cmdVoices(flags);
|
|
148
|
+
case "speak":
|
|
149
|
+
return cmdSpeak(flags);
|
|
150
|
+
case "transcribe":
|
|
151
|
+
return cmdTranscribe(flags);
|
|
152
|
+
case "help":
|
|
153
|
+
case undefined:
|
|
154
|
+
out(USAGE);
|
|
155
|
+
return;
|
|
156
|
+
default:
|
|
157
|
+
fail(`unknown command: ${cmd}\n\n${USAGE}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
main().catch((err) => {
|
|
161
|
+
if (err instanceof PyAIError) {
|
|
162
|
+
fail(`API error ${err.status}${err.code ? ` (${err.code})` : ""}: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
fail(err.message);
|
|
165
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pyai/sdk — official TypeScript/JavaScript client for the PyAI API.
|
|
3
|
+
*
|
|
4
|
+
* Thin, dependency-free wrapper over the public OpenAI-compatible surface at
|
|
5
|
+
* https://api.pyai.com (contract: https://api.pyai.com/openapi.json). Runs in
|
|
6
|
+
* the browser and Node 22+ (uses global fetch + WebSocket). Keys are opaque —
|
|
7
|
+
* never parsed.
|
|
8
|
+
*/
|
|
9
|
+
export interface PyAIOptions {
|
|
10
|
+
/** A pyai_live_ or pyai_test_ key. */
|
|
11
|
+
apiKey: string;
|
|
12
|
+
/** Defaults to https://api.pyai.com. */
|
|
13
|
+
baseURL?: string;
|
|
14
|
+
/** Injectable for tests / custom transports. Defaults to global fetch. */
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
/** Retries on 429 + 5xx (honors Retry-After). Default 2. */
|
|
17
|
+
maxRetries?: number;
|
|
18
|
+
}
|
|
19
|
+
/** Stable, machine-readable error. Branch on `code`, not `message`. */
|
|
20
|
+
export declare class PyAIError extends Error {
|
|
21
|
+
readonly status: number;
|
|
22
|
+
readonly code: string | undefined;
|
|
23
|
+
readonly type: string | undefined;
|
|
24
|
+
readonly requestId: string | undefined;
|
|
25
|
+
constructor(status: number, message: string, code?: string, type?: string, requestId?: string);
|
|
26
|
+
}
|
|
27
|
+
export interface Voice {
|
|
28
|
+
id: string;
|
|
29
|
+
name?: string;
|
|
30
|
+
gender?: string;
|
|
31
|
+
region?: string;
|
|
32
|
+
[k: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
export interface ListResponse<T> {
|
|
35
|
+
object: "list";
|
|
36
|
+
data: T[];
|
|
37
|
+
has_more?: boolean;
|
|
38
|
+
next_cursor?: string | null;
|
|
39
|
+
}
|
|
40
|
+
export interface TranscriptionJob {
|
|
41
|
+
job_id: string;
|
|
42
|
+
status: "queued" | "running" | "completed" | "failed" | "cancelled";
|
|
43
|
+
created_at: number;
|
|
44
|
+
updated_at: number;
|
|
45
|
+
result?: unknown;
|
|
46
|
+
result_url?: string;
|
|
47
|
+
error?: string;
|
|
48
|
+
}
|
|
49
|
+
export interface SpeechParams {
|
|
50
|
+
input: string;
|
|
51
|
+
voice?: string;
|
|
52
|
+
model?: string;
|
|
53
|
+
response_format?: "wav" | "mp3" | "opus" | "aac" | "flac" | "pcm";
|
|
54
|
+
/**
|
|
55
|
+
* Output sample rate in Hz (8000-48000). Omit for the native 24 kHz. Most
|
|
56
|
+
* useful with `response_format: "pcm"` (raw 16-bit mono samples), e.g. set
|
|
57
|
+
* `8000`/`16000` for telephony pipelines.
|
|
58
|
+
*/
|
|
59
|
+
sample_rate?: number;
|
|
60
|
+
speed?: number;
|
|
61
|
+
}
|
|
62
|
+
export interface CreateJobParams {
|
|
63
|
+
audio_url: string;
|
|
64
|
+
model?: string;
|
|
65
|
+
diarize?: boolean;
|
|
66
|
+
channel?: boolean;
|
|
67
|
+
numerals?: boolean;
|
|
68
|
+
output_formats?: Array<"json" | "srt" | "vtt">;
|
|
69
|
+
webhook_url?: string;
|
|
70
|
+
}
|
|
71
|
+
export interface RealtimeOptions {
|
|
72
|
+
/** "omni" (agentic, needs agentId) or "flow" (voice duplex). Default "omni". */
|
|
73
|
+
product?: "omni" | "flow";
|
|
74
|
+
/** Required for omni: the agent to drive. */
|
|
75
|
+
agentId?: string;
|
|
76
|
+
/** Extra query params (e.g. format, rate). */
|
|
77
|
+
query?: Record<string, string>;
|
|
78
|
+
}
|
|
79
|
+
export declare class PyAI {
|
|
80
|
+
private readonly apiKey;
|
|
81
|
+
private readonly baseURL;
|
|
82
|
+
private readonly fetchImpl;
|
|
83
|
+
private readonly maxRetries;
|
|
84
|
+
constructor(opts: PyAIOptions);
|
|
85
|
+
private authHeaders;
|
|
86
|
+
private request;
|
|
87
|
+
private toError;
|
|
88
|
+
private getJson;
|
|
89
|
+
models: {
|
|
90
|
+
list: () => Promise<ListResponse<{
|
|
91
|
+
id: string;
|
|
92
|
+
}>>;
|
|
93
|
+
};
|
|
94
|
+
voices: {
|
|
95
|
+
list: (params?: {
|
|
96
|
+
gender?: string;
|
|
97
|
+
region?: string;
|
|
98
|
+
}) => Promise<ListResponse<Voice>>;
|
|
99
|
+
get: (id: string) => Promise<Voice>;
|
|
100
|
+
};
|
|
101
|
+
audio: {
|
|
102
|
+
/** Text-to-speech. Returns the raw audio bytes (default WAV). */
|
|
103
|
+
speech: (params: SpeechParams) => Promise<ArrayBuffer>;
|
|
104
|
+
/**
|
|
105
|
+
* Text-to-speech, streamed. Resolves as soon as the response headers arrive
|
|
106
|
+
* with the body as a `ReadableStream` of audio bytes, so you can start
|
|
107
|
+
* playback or forward the audio at the first chunk (the engine's
|
|
108
|
+
* time-to-first-byte is tens of ms) instead of buffering the whole clip.
|
|
109
|
+
* Use `mp3` for the smoothest progressive playback. Returns an async
|
|
110
|
+
* iterable of Uint8Array chunks.
|
|
111
|
+
*/
|
|
112
|
+
speechStream: (params: SpeechParams) => Promise<ReadableStream<Uint8Array>>;
|
|
113
|
+
/** Synchronous speech-to-text (multipart upload). */
|
|
114
|
+
transcriptions: {
|
|
115
|
+
create: (params: {
|
|
116
|
+
file: Blob;
|
|
117
|
+
filename?: string;
|
|
118
|
+
model?: string;
|
|
119
|
+
}) => Promise<{
|
|
120
|
+
text: string;
|
|
121
|
+
[k: string]: unknown;
|
|
122
|
+
}>;
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
transcriptionJobs: {
|
|
126
|
+
create: (params: CreateJobParams, opts?: {
|
|
127
|
+
idempotencyKey?: string;
|
|
128
|
+
}) => Promise<TranscriptionJob>;
|
|
129
|
+
get: (jobId: string) => Promise<TranscriptionJob>;
|
|
130
|
+
list: (params?: {
|
|
131
|
+
limit?: number;
|
|
132
|
+
cursor?: string;
|
|
133
|
+
}) => Promise<ListResponse<TranscriptionJob>>;
|
|
134
|
+
};
|
|
135
|
+
/** Build the realtime WebSocket URL for the chosen product. */
|
|
136
|
+
realtimeURL(opts?: RealtimeOptions): string;
|
|
137
|
+
/** The subprotocol that carries the key on a WS upgrade (browser-safe auth). */
|
|
138
|
+
realtimeSubprotocol(): string;
|
|
139
|
+
/**
|
|
140
|
+
* Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
|
|
141
|
+
* The key travels as a subprotocol so it works from the browser without
|
|
142
|
+
* custom headers.
|
|
143
|
+
*/
|
|
144
|
+
connectRealtime(opts?: RealtimeOptions): WebSocket;
|
|
145
|
+
}
|
|
146
|
+
export default PyAI;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pyai/sdk — official TypeScript/JavaScript client for the PyAI API.
|
|
3
|
+
*
|
|
4
|
+
* Thin, dependency-free wrapper over the public OpenAI-compatible surface at
|
|
5
|
+
* https://api.pyai.com (contract: https://api.pyai.com/openapi.json). Runs in
|
|
6
|
+
* the browser and Node 22+ (uses global fetch + WebSocket). Keys are opaque —
|
|
7
|
+
* never parsed.
|
|
8
|
+
*/
|
|
9
|
+
/** Stable, machine-readable error. Branch on `code`, not `message`. */
|
|
10
|
+
export class PyAIError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
code;
|
|
13
|
+
type;
|
|
14
|
+
requestId;
|
|
15
|
+
constructor(status, message, code, type, requestId) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "PyAIError";
|
|
18
|
+
this.status = status;
|
|
19
|
+
this.code = code;
|
|
20
|
+
this.type = type;
|
|
21
|
+
this.requestId = requestId;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
|
25
|
+
export class PyAI {
|
|
26
|
+
apiKey;
|
|
27
|
+
baseURL;
|
|
28
|
+
fetchImpl;
|
|
29
|
+
maxRetries;
|
|
30
|
+
constructor(opts) {
|
|
31
|
+
if (!opts.apiKey)
|
|
32
|
+
throw new Error("apiKey is required");
|
|
33
|
+
this.apiKey = opts.apiKey;
|
|
34
|
+
this.baseURL = (opts.baseURL ?? "https://api.pyai.com").replace(/\/$/, "");
|
|
35
|
+
this.fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
36
|
+
this.maxRetries = opts.maxRetries ?? 2;
|
|
37
|
+
if (!this.fetchImpl)
|
|
38
|
+
throw new Error("No fetch implementation available; pass opts.fetch");
|
|
39
|
+
}
|
|
40
|
+
// --- transport ----------------------------------------------------------
|
|
41
|
+
authHeaders(extra = {}) {
|
|
42
|
+
return { Authorization: `Bearer ${this.apiKey}`, ...extra };
|
|
43
|
+
}
|
|
44
|
+
async request(path, init, attempt = 0) {
|
|
45
|
+
const res = await this.fetchImpl(`${this.baseURL}${path}`, init);
|
|
46
|
+
if (res.ok)
|
|
47
|
+
return res;
|
|
48
|
+
if (RETRYABLE.has(res.status) && attempt < this.maxRetries) {
|
|
49
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
50
|
+
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 250;
|
|
51
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
52
|
+
return this.request(path, init, attempt + 1);
|
|
53
|
+
}
|
|
54
|
+
throw await this.toError(res);
|
|
55
|
+
}
|
|
56
|
+
async toError(res) {
|
|
57
|
+
const requestId = res.headers.get("x-request-id") ?? undefined;
|
|
58
|
+
let body;
|
|
59
|
+
try {
|
|
60
|
+
body = await res.json();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return new PyAIError(res.status, `HTTP ${res.status}`, undefined, undefined, requestId);
|
|
64
|
+
}
|
|
65
|
+
const b = body;
|
|
66
|
+
if (b.error) {
|
|
67
|
+
return new PyAIError(res.status, b.error.message ?? `HTTP ${res.status}`, b.error.code, b.error.type, requestId);
|
|
68
|
+
}
|
|
69
|
+
// RFC 7807 problem (control-plane): code is the last segment of `type`.
|
|
70
|
+
const code = typeof b.type === "string" ? b.type.split("/").pop() : undefined;
|
|
71
|
+
return new PyAIError(res.status, b.detail ?? b.title ?? `HTTP ${res.status}`, code, undefined, b.request_id ?? requestId);
|
|
72
|
+
}
|
|
73
|
+
async getJson(path) {
|
|
74
|
+
const res = await this.request(path, { headers: this.authHeaders() });
|
|
75
|
+
return (await res.json());
|
|
76
|
+
}
|
|
77
|
+
// --- models -------------------------------------------------------------
|
|
78
|
+
models = {
|
|
79
|
+
list: () => this.getJson("/v1/models"),
|
|
80
|
+
};
|
|
81
|
+
// --- voices -------------------------------------------------------------
|
|
82
|
+
voices = {
|
|
83
|
+
list: (params = {}) => {
|
|
84
|
+
const q = new URLSearchParams();
|
|
85
|
+
if (params.gender)
|
|
86
|
+
q.set("gender", params.gender);
|
|
87
|
+
if (params.region)
|
|
88
|
+
q.set("region", params.region);
|
|
89
|
+
const qs = q.toString();
|
|
90
|
+
return this.getJson(`/v1/voices${qs ? `?${qs}` : ""}`);
|
|
91
|
+
},
|
|
92
|
+
get: (id) => this.getJson(`/v1/voices/${encodeURIComponent(id)}`),
|
|
93
|
+
};
|
|
94
|
+
// --- audio --------------------------------------------------------------
|
|
95
|
+
audio = {
|
|
96
|
+
/** Text-to-speech. Returns the raw audio bytes (default WAV). */
|
|
97
|
+
speech: async (params) => {
|
|
98
|
+
const res = await this.request("/v1/audio/speech", {
|
|
99
|
+
method: "POST",
|
|
100
|
+
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
101
|
+
body: JSON.stringify({ model: "pyai-voice", ...params }),
|
|
102
|
+
});
|
|
103
|
+
return res.arrayBuffer();
|
|
104
|
+
},
|
|
105
|
+
/**
|
|
106
|
+
* Text-to-speech, streamed. Resolves as soon as the response headers arrive
|
|
107
|
+
* with the body as a `ReadableStream` of audio bytes, so you can start
|
|
108
|
+
* playback or forward the audio at the first chunk (the engine's
|
|
109
|
+
* time-to-first-byte is tens of ms) instead of buffering the whole clip.
|
|
110
|
+
* Use `mp3` for the smoothest progressive playback. Returns an async
|
|
111
|
+
* iterable of Uint8Array chunks.
|
|
112
|
+
*/
|
|
113
|
+
speechStream: async (params) => {
|
|
114
|
+
const res = await this.request("/v1/audio/speech", {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
117
|
+
body: JSON.stringify({ model: "pyai-voice", ...params }),
|
|
118
|
+
});
|
|
119
|
+
if (!res.body)
|
|
120
|
+
throw new PyAIError(res.status, "Response had no body to stream");
|
|
121
|
+
return res.body;
|
|
122
|
+
},
|
|
123
|
+
/** Synchronous speech-to-text (multipart upload). */
|
|
124
|
+
transcriptions: {
|
|
125
|
+
create: async (params) => {
|
|
126
|
+
const form = new FormData();
|
|
127
|
+
form.set("file", params.file, params.filename ?? "audio.wav");
|
|
128
|
+
form.set("model", params.model ?? "pyai-hear");
|
|
129
|
+
const res = await this.request("/v1/audio/transcriptions", {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: this.authHeaders(),
|
|
132
|
+
body: form,
|
|
133
|
+
});
|
|
134
|
+
return (await res.json());
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
// --- async transcription jobs ------------------------------------------
|
|
139
|
+
transcriptionJobs = {
|
|
140
|
+
create: async (params, opts = {}) => {
|
|
141
|
+
const headers = this.authHeaders({ "Content-Type": "application/json" });
|
|
142
|
+
if (opts.idempotencyKey)
|
|
143
|
+
headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
144
|
+
const res = await this.request("/v1/transcription/jobs", {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers,
|
|
147
|
+
body: JSON.stringify(params),
|
|
148
|
+
});
|
|
149
|
+
return (await res.json());
|
|
150
|
+
},
|
|
151
|
+
get: (jobId) => this.getJson(`/v1/transcription/jobs/${encodeURIComponent(jobId)}`),
|
|
152
|
+
list: (params = {}) => {
|
|
153
|
+
const q = new URLSearchParams();
|
|
154
|
+
if (params.limit !== undefined)
|
|
155
|
+
q.set("limit", String(params.limit));
|
|
156
|
+
if (params.cursor)
|
|
157
|
+
q.set("cursor", params.cursor);
|
|
158
|
+
const qs = q.toString();
|
|
159
|
+
return this.getJson(`/v1/transcription/jobs${qs ? `?${qs}` : ""}`);
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
// --- realtime (WebSocket) ----------------------------------------------
|
|
163
|
+
/** Build the realtime WebSocket URL for the chosen product. */
|
|
164
|
+
realtimeURL(opts = {}) {
|
|
165
|
+
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
166
|
+
const q = new URLSearchParams(opts.query ?? {});
|
|
167
|
+
if ((opts.product ?? "omni") === "omni") {
|
|
168
|
+
// Omni's native realtime surface is /v1/omni. agentId is an opaque label
|
|
169
|
+
// authorized by the key's org. format/rate are load-bearing on the
|
|
170
|
+
// connect URL, so default to browser-grade PCM16/24kHz.
|
|
171
|
+
if (opts.agentId)
|
|
172
|
+
q.set("agent_id", opts.agentId);
|
|
173
|
+
if (!q.has("format"))
|
|
174
|
+
q.set("format", "pcm16");
|
|
175
|
+
if (!q.has("rate"))
|
|
176
|
+
q.set("rate", "24000");
|
|
177
|
+
const qs = q.toString();
|
|
178
|
+
return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
|
|
179
|
+
}
|
|
180
|
+
q.set("model", "pyai-flow-realtime");
|
|
181
|
+
return `${wsBase}/v1/realtime?${q.toString()}`;
|
|
182
|
+
}
|
|
183
|
+
/** The subprotocol that carries the key on a WS upgrade (browser-safe auth). */
|
|
184
|
+
realtimeSubprotocol() {
|
|
185
|
+
return `pyai-key.${this.apiKey}`;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
|
|
189
|
+
* The key travels as a subprotocol so it works from the browser without
|
|
190
|
+
* custom headers.
|
|
191
|
+
*/
|
|
192
|
+
connectRealtime(opts = {}) {
|
|
193
|
+
const WS = globalThis.WebSocket;
|
|
194
|
+
if (!WS)
|
|
195
|
+
throw new Error("No global WebSocket; use realtimeURL()/realtimeSubprotocol() with a WS library");
|
|
196
|
+
return new WS(this.realtimeURL(opts), [this.realtimeSubprotocol()]);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export default PyAI;
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pyai/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Official TypeScript/JavaScript SDK for the PyAI API (Hear, Speak, Cue, Omni).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"pyai": "dist/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": ["dist", "src", "README.md"],
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/atomsai/pyai-platform-backend.git",
|
|
21
|
+
"directory": "sdk/typescript"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://pyai.com",
|
|
24
|
+
"bugs": "https://github.com/atomsai/pyai-platform-backend/issues",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsc -p tsconfig.build.json",
|
|
27
|
+
"test": "node --test test/**/*.test.ts",
|
|
28
|
+
"typecheck": "tsc --noEmit"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"keywords": ["pyai", "voice-ai", "tts", "stt", "speech", "openai-compatible"],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^22.0.0",
|
|
37
|
+
"typescript": "^5.7.0"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pyai — CLI smoke tester for the PyAI API: proves your key, the endpoint,
|
|
4
|
+
* and audio synthesis in one command.
|
|
5
|
+
*
|
|
6
|
+
* Commands:
|
|
7
|
+
* pyai smoke run models+voices+speak and report PASS/FAIL
|
|
8
|
+
* pyai models list models
|
|
9
|
+
* pyai voices [--gender g --region r] list voices
|
|
10
|
+
* pyai speak --text T [--voice V] [--out f.wav]
|
|
11
|
+
* pyai transcribe --url U [--diarize] [--poll]
|
|
12
|
+
*
|
|
13
|
+
* Auth: PYAI_API_KEY env (or --api-key). Base URL: PYAI_BASE_URL (or --base-url).
|
|
14
|
+
* Zero deps — uses the bundled SDK.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { writeFile } from "node:fs/promises";
|
|
18
|
+
import PyAI, { PyAIError } from "./index.ts";
|
|
19
|
+
|
|
20
|
+
interface Flags {
|
|
21
|
+
_: string[];
|
|
22
|
+
[k: string]: string | boolean | string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv: string[]): Flags {
|
|
26
|
+
const flags: Flags = { _: [] };
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const a = argv[i]!;
|
|
29
|
+
if (a.startsWith("--")) {
|
|
30
|
+
const key = a.slice(2);
|
|
31
|
+
const next = argv[i + 1];
|
|
32
|
+
if (next === undefined || next.startsWith("--")) {
|
|
33
|
+
flags[key] = true;
|
|
34
|
+
} else {
|
|
35
|
+
flags[key] = next;
|
|
36
|
+
i++;
|
|
37
|
+
}
|
|
38
|
+
} else {
|
|
39
|
+
(flags._ as string[]).push(a);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return flags;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function flag(flags: Flags, key: string): string | undefined {
|
|
46
|
+
const v = flags[key];
|
|
47
|
+
return typeof v === "string" ? v : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function client(flags: Flags): PyAI {
|
|
51
|
+
const apiKey = flag(flags, "api-key") ?? process.env.PYAI_API_KEY;
|
|
52
|
+
if (!apiKey) {
|
|
53
|
+
fail("No API key. Set PYAI_API_KEY or pass --api-key pyai_test_...");
|
|
54
|
+
}
|
|
55
|
+
return new PyAI({ apiKey: apiKey!, baseURL: flag(flags, "base-url") ?? process.env.PYAI_BASE_URL });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function out(msg: string): void {
|
|
59
|
+
process.stdout.write(`${msg}\n`);
|
|
60
|
+
}
|
|
61
|
+
function fail(msg: string): never {
|
|
62
|
+
process.stderr.write(`pyai: ${msg}\n`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const USAGE = `pyai — PyAI API CLI
|
|
67
|
+
|
|
68
|
+
Usage:
|
|
69
|
+
pyai smoke run a key/endpoint/audio smoke test
|
|
70
|
+
pyai models list models
|
|
71
|
+
pyai voices [--gender g] [--region r] list voices
|
|
72
|
+
pyai speak --text T [--voice V] [--out f.wav]
|
|
73
|
+
pyai transcribe --url U [--diarize] [--poll]
|
|
74
|
+
|
|
75
|
+
Auth: PYAI_API_KEY (or --api-key). Base: PYAI_BASE_URL (or --base-url).`;
|
|
76
|
+
|
|
77
|
+
async function cmdModels(flags: Flags): Promise<void> {
|
|
78
|
+
const pyai = client(flags);
|
|
79
|
+
const res = await pyai.models.list();
|
|
80
|
+
out(JSON.stringify(res, null, 2));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function cmdVoices(flags: Flags): Promise<void> {
|
|
84
|
+
const pyai = client(flags);
|
|
85
|
+
const res = await pyai.voices.list({ gender: flag(flags, "gender"), region: flag(flags, "region") });
|
|
86
|
+
out(JSON.stringify(res, null, 2));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function cmdSpeak(flags: Flags): Promise<void> {
|
|
90
|
+
const pyai = client(flags);
|
|
91
|
+
const text = flag(flags, "text");
|
|
92
|
+
if (!text) fail("speak requires --text");
|
|
93
|
+
const audio = await pyai.audio.speech({ input: text!, voice: flag(flags, "voice") });
|
|
94
|
+
const outPath = flag(flags, "out") ?? "pyai-speak.wav";
|
|
95
|
+
await writeFile(outPath, Buffer.from(audio));
|
|
96
|
+
out(`wrote ${Buffer.from(audio).byteLength} bytes -> ${outPath}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function cmdTranscribe(flags: Flags): Promise<void> {
|
|
100
|
+
const pyai = client(flags);
|
|
101
|
+
const url = flag(flags, "url");
|
|
102
|
+
if (!url) fail("transcribe requires --url (an https audio URL)");
|
|
103
|
+
const job = await pyai.transcriptionJobs.create({ audio_url: url!, diarize: flags.diarize === true });
|
|
104
|
+
out(`job ${job.job_id} (${job.status})`);
|
|
105
|
+
if (flags.poll !== true) return;
|
|
106
|
+
for (let i = 0; i < 60; i++) {
|
|
107
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
108
|
+
const j = await pyai.transcriptionJobs.get(job.job_id);
|
|
109
|
+
if (j.status === "completed" || j.status === "failed" || j.status === "cancelled") {
|
|
110
|
+
out(JSON.stringify(j, null, 2));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
out(`job ${job.job_id} still running after polling; check later.`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The headline: prove key + endpoint + audio in one command. */
|
|
118
|
+
async function cmdSmoke(flags: Flags): Promise<void> {
|
|
119
|
+
const pyai = client(flags);
|
|
120
|
+
const checks: Array<{ name: string; ok: boolean; detail: string }> = [];
|
|
121
|
+
const run = async (name: string, fn: () => Promise<string>) => {
|
|
122
|
+
try {
|
|
123
|
+
checks.push({ name, ok: true, detail: await fn() });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}` : (err as Error).message;
|
|
126
|
+
checks.push({ name, ok: false, detail });
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
await run("models.list", async () => {
|
|
131
|
+
const r = await pyai.models.list();
|
|
132
|
+
return `${r.data.length} models`;
|
|
133
|
+
});
|
|
134
|
+
await run("voices.list", async () => {
|
|
135
|
+
const r = await pyai.voices.list();
|
|
136
|
+
return `${r.data.length} voices`;
|
|
137
|
+
});
|
|
138
|
+
await run("audio.speech", async () => {
|
|
139
|
+
const audio = await pyai.audio.speech({ input: "PyAI smoke test." });
|
|
140
|
+
return `${Buffer.from(audio).byteLength} bytes of audio`;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
for (const c of checks) out(`${c.ok ? "PASS" : "FAIL"} ${c.name} — ${c.detail}`);
|
|
144
|
+
const allOk = checks.every((c) => c.ok);
|
|
145
|
+
out(allOk ? "\nAll checks passed. Your key, the endpoint, and audio synthesis work." : "\nSome checks failed (see above).");
|
|
146
|
+
if (!allOk) process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function main(): Promise<void> {
|
|
150
|
+
const flags = parseArgs(process.argv.slice(2));
|
|
151
|
+
const cmd = (flags._ as string[])[0];
|
|
152
|
+
switch (cmd) {
|
|
153
|
+
case "smoke":
|
|
154
|
+
return cmdSmoke(flags);
|
|
155
|
+
case "models":
|
|
156
|
+
return cmdModels(flags);
|
|
157
|
+
case "voices":
|
|
158
|
+
return cmdVoices(flags);
|
|
159
|
+
case "speak":
|
|
160
|
+
return cmdSpeak(flags);
|
|
161
|
+
case "transcribe":
|
|
162
|
+
return cmdTranscribe(flags);
|
|
163
|
+
case "help":
|
|
164
|
+
case undefined:
|
|
165
|
+
out(USAGE);
|
|
166
|
+
return;
|
|
167
|
+
default:
|
|
168
|
+
fail(`unknown command: ${cmd}\n\n${USAGE}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
main().catch((err) => {
|
|
173
|
+
if (err instanceof PyAIError) {
|
|
174
|
+
fail(`API error ${err.status}${err.code ? ` (${err.code})` : ""}: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
fail((err as Error).message);
|
|
177
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pyai/sdk — official TypeScript/JavaScript client for the PyAI API.
|
|
3
|
+
*
|
|
4
|
+
* Thin, dependency-free wrapper over the public OpenAI-compatible surface at
|
|
5
|
+
* https://api.pyai.com (contract: https://api.pyai.com/openapi.json). Runs in
|
|
6
|
+
* the browser and Node 22+ (uses global fetch + WebSocket). Keys are opaque —
|
|
7
|
+
* never parsed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface PyAIOptions {
|
|
11
|
+
/** A pyai_live_ or pyai_test_ key. */
|
|
12
|
+
apiKey: string;
|
|
13
|
+
/** Defaults to https://api.pyai.com. */
|
|
14
|
+
baseURL?: string;
|
|
15
|
+
/** Injectable for tests / custom transports. Defaults to global fetch. */
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
/** Retries on 429 + 5xx (honors Retry-After). Default 2. */
|
|
18
|
+
maxRetries?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Stable, machine-readable error. Branch on `code`, not `message`. */
|
|
22
|
+
export class PyAIError extends Error {
|
|
23
|
+
readonly status: number;
|
|
24
|
+
readonly code: string | undefined;
|
|
25
|
+
readonly type: string | undefined;
|
|
26
|
+
readonly requestId: string | undefined;
|
|
27
|
+
constructor(status: number, message: string, code?: string, type?: string, requestId?: string) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "PyAIError";
|
|
30
|
+
this.status = status;
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.type = type;
|
|
33
|
+
this.requestId = requestId;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Voice {
|
|
38
|
+
id: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
gender?: string;
|
|
41
|
+
region?: string;
|
|
42
|
+
[k: string]: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ListResponse<T> {
|
|
46
|
+
object: "list";
|
|
47
|
+
data: T[];
|
|
48
|
+
has_more?: boolean;
|
|
49
|
+
next_cursor?: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TranscriptionJob {
|
|
53
|
+
job_id: string;
|
|
54
|
+
status: "queued" | "running" | "completed" | "failed" | "cancelled";
|
|
55
|
+
created_at: number;
|
|
56
|
+
updated_at: number;
|
|
57
|
+
result?: unknown;
|
|
58
|
+
result_url?: string;
|
|
59
|
+
error?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SpeechParams {
|
|
63
|
+
input: string;
|
|
64
|
+
voice?: string;
|
|
65
|
+
model?: string;
|
|
66
|
+
response_format?: "wav" | "mp3" | "opus" | "aac" | "flac" | "pcm";
|
|
67
|
+
/**
|
|
68
|
+
* Output sample rate in Hz (8000-48000). Omit for the native 24 kHz. Most
|
|
69
|
+
* useful with `response_format: "pcm"` (raw 16-bit mono samples), e.g. set
|
|
70
|
+
* `8000`/`16000` for telephony pipelines.
|
|
71
|
+
*/
|
|
72
|
+
sample_rate?: number;
|
|
73
|
+
speed?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface CreateJobParams {
|
|
77
|
+
audio_url: string;
|
|
78
|
+
model?: string;
|
|
79
|
+
diarize?: boolean;
|
|
80
|
+
channel?: boolean;
|
|
81
|
+
numerals?: boolean;
|
|
82
|
+
output_formats?: Array<"json" | "srt" | "vtt">;
|
|
83
|
+
webhook_url?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface RealtimeOptions {
|
|
87
|
+
/** "omni" (agentic, needs agentId) or "flow" (voice duplex). Default "omni". */
|
|
88
|
+
product?: "omni" | "flow";
|
|
89
|
+
/** Required for omni: the agent to drive. */
|
|
90
|
+
agentId?: string;
|
|
91
|
+
/** Extra query params (e.g. format, rate). */
|
|
92
|
+
query?: Record<string, string>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
|
96
|
+
|
|
97
|
+
export class PyAI {
|
|
98
|
+
private readonly apiKey: string;
|
|
99
|
+
private readonly baseURL: string;
|
|
100
|
+
private readonly fetchImpl: typeof fetch;
|
|
101
|
+
private readonly maxRetries: number;
|
|
102
|
+
|
|
103
|
+
constructor(opts: PyAIOptions) {
|
|
104
|
+
if (!opts.apiKey) throw new Error("apiKey is required");
|
|
105
|
+
this.apiKey = opts.apiKey;
|
|
106
|
+
this.baseURL = (opts.baseURL ?? "https://api.pyai.com").replace(/\/$/, "");
|
|
107
|
+
this.fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
108
|
+
this.maxRetries = opts.maxRetries ?? 2;
|
|
109
|
+
if (!this.fetchImpl) throw new Error("No fetch implementation available; pass opts.fetch");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- transport ----------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
private authHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
|
115
|
+
return { Authorization: `Bearer ${this.apiKey}`, ...extra };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private async request(path: string, init: RequestInit, attempt = 0): Promise<Response> {
|
|
119
|
+
const res = await this.fetchImpl(`${this.baseURL}${path}`, init);
|
|
120
|
+
if (res.ok) return res;
|
|
121
|
+
if (RETRYABLE.has(res.status) && attempt < this.maxRetries) {
|
|
122
|
+
const retryAfter = Number(res.headers.get("retry-after"));
|
|
123
|
+
const delayMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 250;
|
|
124
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
125
|
+
return this.request(path, init, attempt + 1);
|
|
126
|
+
}
|
|
127
|
+
throw await this.toError(res);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private async toError(res: Response): Promise<PyAIError> {
|
|
131
|
+
const requestId = res.headers.get("x-request-id") ?? undefined;
|
|
132
|
+
let body: unknown;
|
|
133
|
+
try {
|
|
134
|
+
body = await res.json();
|
|
135
|
+
} catch {
|
|
136
|
+
return new PyAIError(res.status, `HTTP ${res.status}`, undefined, undefined, requestId);
|
|
137
|
+
}
|
|
138
|
+
const b = body as {
|
|
139
|
+
error?: { message?: string; code?: string; type?: string };
|
|
140
|
+
title?: string;
|
|
141
|
+
detail?: string;
|
|
142
|
+
type?: string;
|
|
143
|
+
request_id?: string;
|
|
144
|
+
};
|
|
145
|
+
if (b.error) {
|
|
146
|
+
return new PyAIError(res.status, b.error.message ?? `HTTP ${res.status}`, b.error.code, b.error.type, requestId);
|
|
147
|
+
}
|
|
148
|
+
// RFC 7807 problem (control-plane): code is the last segment of `type`.
|
|
149
|
+
const code = typeof b.type === "string" ? b.type.split("/").pop() : undefined;
|
|
150
|
+
return new PyAIError(res.status, b.detail ?? b.title ?? `HTTP ${res.status}`, code, undefined, b.request_id ?? requestId);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private async getJson<T>(path: string): Promise<T> {
|
|
154
|
+
const res = await this.request(path, { headers: this.authHeaders() });
|
|
155
|
+
return (await res.json()) as T;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// --- models -------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
models = {
|
|
161
|
+
list: (): Promise<ListResponse<{ id: string }>> => this.getJson("/v1/models"),
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// --- voices -------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
voices = {
|
|
167
|
+
list: (params: { gender?: string; region?: string } = {}): Promise<ListResponse<Voice>> => {
|
|
168
|
+
const q = new URLSearchParams();
|
|
169
|
+
if (params.gender) q.set("gender", params.gender);
|
|
170
|
+
if (params.region) q.set("region", params.region);
|
|
171
|
+
const qs = q.toString();
|
|
172
|
+
return this.getJson(`/v1/voices${qs ? `?${qs}` : ""}`);
|
|
173
|
+
},
|
|
174
|
+
get: (id: string): Promise<Voice> => this.getJson(`/v1/voices/${encodeURIComponent(id)}`),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// --- audio --------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
audio = {
|
|
180
|
+
/** Text-to-speech. Returns the raw audio bytes (default WAV). */
|
|
181
|
+
speech: async (params: SpeechParams): Promise<ArrayBuffer> => {
|
|
182
|
+
const res = await this.request("/v1/audio/speech", {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
185
|
+
body: JSON.stringify({ model: "pyai-voice", ...params }),
|
|
186
|
+
});
|
|
187
|
+
return res.arrayBuffer();
|
|
188
|
+
},
|
|
189
|
+
/**
|
|
190
|
+
* Text-to-speech, streamed. Resolves as soon as the response headers arrive
|
|
191
|
+
* with the body as a `ReadableStream` of audio bytes, so you can start
|
|
192
|
+
* playback or forward the audio at the first chunk (the engine's
|
|
193
|
+
* time-to-first-byte is tens of ms) instead of buffering the whole clip.
|
|
194
|
+
* Use `mp3` for the smoothest progressive playback. Returns an async
|
|
195
|
+
* iterable of Uint8Array chunks.
|
|
196
|
+
*/
|
|
197
|
+
speechStream: async (params: SpeechParams): Promise<ReadableStream<Uint8Array>> => {
|
|
198
|
+
const res = await this.request("/v1/audio/speech", {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
201
|
+
body: JSON.stringify({ model: "pyai-voice", ...params }),
|
|
202
|
+
});
|
|
203
|
+
if (!res.body) throw new PyAIError(res.status, "Response had no body to stream");
|
|
204
|
+
return res.body as ReadableStream<Uint8Array>;
|
|
205
|
+
},
|
|
206
|
+
/** Synchronous speech-to-text (multipart upload). */
|
|
207
|
+
transcriptions: {
|
|
208
|
+
create: async (params: {
|
|
209
|
+
file: Blob;
|
|
210
|
+
filename?: string;
|
|
211
|
+
model?: string;
|
|
212
|
+
}): Promise<{ text: string; [k: string]: unknown }> => {
|
|
213
|
+
const form = new FormData();
|
|
214
|
+
form.set("file", params.file, params.filename ?? "audio.wav");
|
|
215
|
+
form.set("model", params.model ?? "pyai-hear");
|
|
216
|
+
const res = await this.request("/v1/audio/transcriptions", {
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: this.authHeaders(),
|
|
219
|
+
body: form,
|
|
220
|
+
});
|
|
221
|
+
return (await res.json()) as { text: string };
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// --- async transcription jobs ------------------------------------------
|
|
227
|
+
|
|
228
|
+
transcriptionJobs = {
|
|
229
|
+
create: async (params: CreateJobParams, opts: { idempotencyKey?: string } = {}): Promise<TranscriptionJob> => {
|
|
230
|
+
const headers = this.authHeaders({ "Content-Type": "application/json" });
|
|
231
|
+
if (opts.idempotencyKey) headers["Idempotency-Key"] = opts.idempotencyKey;
|
|
232
|
+
const res = await this.request("/v1/transcription/jobs", {
|
|
233
|
+
method: "POST",
|
|
234
|
+
headers,
|
|
235
|
+
body: JSON.stringify(params),
|
|
236
|
+
});
|
|
237
|
+
return (await res.json()) as TranscriptionJob;
|
|
238
|
+
},
|
|
239
|
+
get: (jobId: string): Promise<TranscriptionJob> =>
|
|
240
|
+
this.getJson(`/v1/transcription/jobs/${encodeURIComponent(jobId)}`),
|
|
241
|
+
list: (params: { limit?: number; cursor?: string } = {}): Promise<ListResponse<TranscriptionJob>> => {
|
|
242
|
+
const q = new URLSearchParams();
|
|
243
|
+
if (params.limit !== undefined) q.set("limit", String(params.limit));
|
|
244
|
+
if (params.cursor) q.set("cursor", params.cursor);
|
|
245
|
+
const qs = q.toString();
|
|
246
|
+
return this.getJson(`/v1/transcription/jobs${qs ? `?${qs}` : ""}`);
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// --- realtime (WebSocket) ----------------------------------------------
|
|
251
|
+
|
|
252
|
+
/** Build the realtime WebSocket URL for the chosen product. */
|
|
253
|
+
realtimeURL(opts: RealtimeOptions = {}): string {
|
|
254
|
+
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
255
|
+
const q = new URLSearchParams(opts.query ?? {});
|
|
256
|
+
if ((opts.product ?? "omni") === "omni") {
|
|
257
|
+
// Omni's native realtime surface is /v1/omni. agentId is an opaque label
|
|
258
|
+
// authorized by the key's org. format/rate are load-bearing on the
|
|
259
|
+
// connect URL, so default to browser-grade PCM16/24kHz.
|
|
260
|
+
if (opts.agentId) q.set("agent_id", opts.agentId);
|
|
261
|
+
if (!q.has("format")) q.set("format", "pcm16");
|
|
262
|
+
if (!q.has("rate")) q.set("rate", "24000");
|
|
263
|
+
const qs = q.toString();
|
|
264
|
+
return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
|
|
265
|
+
}
|
|
266
|
+
q.set("model", "pyai-flow-realtime");
|
|
267
|
+
return `${wsBase}/v1/realtime?${q.toString()}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The subprotocol that carries the key on a WS upgrade (browser-safe auth). */
|
|
271
|
+
realtimeSubprotocol(): string {
|
|
272
|
+
return `pyai-key.${this.apiKey}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
|
|
277
|
+
* The key travels as a subprotocol so it works from the browser without
|
|
278
|
+
* custom headers.
|
|
279
|
+
*/
|
|
280
|
+
connectRealtime(opts: RealtimeOptions = {}): WebSocket {
|
|
281
|
+
const WS = (globalThis as { WebSocket?: typeof WebSocket }).WebSocket;
|
|
282
|
+
if (!WS) throw new Error("No global WebSocket; use realtimeURL()/realtimeSubprotocol() with a WS library");
|
|
283
|
+
return new WS(this.realtimeURL(opts), [this.realtimeSubprotocol()]);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export default PyAI;
|