@pyai/sdk 0.2.3 → 0.4.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 +65 -19
- package/dist/index.d.ts +292 -60
- package/dist/index.js +189 -81
- package/package.json +18 -3
- package/src/index.ts +447 -121
package/src/index.ts
CHANGED
|
@@ -34,13 +34,35 @@ export class PyAIError extends Error {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export interface Voice {
|
|
37
|
+
/** Canonical catalog identifier returned by GET /v1/voices. */
|
|
38
|
+
voice_id: string;
|
|
39
|
+
/** SDK compatibility alias, always equal to voice_id. */
|
|
37
40
|
id: string;
|
|
38
41
|
name?: string;
|
|
39
42
|
gender?: string;
|
|
40
43
|
region?: string;
|
|
44
|
+
language?: string;
|
|
45
|
+
/** Customer-facing quality tier. */
|
|
46
|
+
tier?: "standard" | "natural";
|
|
47
|
+
/** Permanent convenience inputs accepted on the advertised surfaces. */
|
|
48
|
+
aliases?: string[];
|
|
49
|
+
/** Product surfaces on which this stock voice can be selected. */
|
|
50
|
+
available_on?: Array<"speak" | "omni">;
|
|
51
|
+
/** Accepted Speak delivery modes. Empty means the voice has no Speak surface. */
|
|
52
|
+
synthesis_modes?: Array<"streaming" | "async">;
|
|
53
|
+
/** Voice-specific amount on top of the selected product's base rate. */
|
|
54
|
+
pricing?: {
|
|
55
|
+
included_in_base_price: boolean;
|
|
56
|
+
additional_price_usd_per_minute: number;
|
|
57
|
+
};
|
|
41
58
|
[k: string]: unknown;
|
|
42
59
|
}
|
|
43
60
|
|
|
61
|
+
function normalizeVoice(value: Record<string, unknown>): Voice {
|
|
62
|
+
const voiceId = String(value.voice_id ?? value.id ?? "");
|
|
63
|
+
return { ...value, voice_id: voiceId, id: voiceId } as Voice;
|
|
64
|
+
}
|
|
65
|
+
|
|
44
66
|
export interface ListResponse<T> {
|
|
45
67
|
object: "list";
|
|
46
68
|
data: T[];
|
|
@@ -62,13 +84,13 @@ export interface TranscriptionJob {
|
|
|
62
84
|
* Output container/codec for `audio.speech`. This is the **exact** set the
|
|
63
85
|
* server accepts on `POST /v1/audio/speech`, any other value is rejected with
|
|
64
86
|
* `400 unsupported_format`. The default (when `response_format` is omitted) is
|
|
65
|
-
* `
|
|
87
|
+
* `wav`. Omit `sample_rate` for the engine's native 24 kHz (`g711_*` is always
|
|
66
88
|
* 8 kHz).
|
|
67
89
|
*
|
|
68
90
|
* | format | rates (Hz) | Content-Type |
|
|
69
91
|
* |---|---|---|
|
|
70
|
-
* | `
|
|
71
|
-
* | `
|
|
92
|
+
* | `wav` (default) | 8000/16000/24000/48000 | `audio/wav` |
|
|
93
|
+
* | `mp3` | 8000/16000/24000/48000 | `audio/mpeg` |
|
|
72
94
|
* | `opus` | 8000/16000/24000/48000 | `audio/ogg` |
|
|
73
95
|
* | `aac` | 8000/16000/24000/48000 | `audio/aac` |
|
|
74
96
|
* | `flac` | 8000/16000/24000/48000 | `audio/flac` |
|
|
@@ -89,14 +111,24 @@ export const SPEECH_FORMATS = ["wav", "mp3", "opus", "aac", "flac", "pcm", "g711
|
|
|
89
111
|
export const SPEECH_SAMPLE_RATES = [8000, 16000, 24000, 48000] as const;
|
|
90
112
|
export type SpeechSampleRate = (typeof SPEECH_SAMPLE_RATES)[number];
|
|
91
113
|
|
|
114
|
+
/** Canonical Speak model plus the intentional OpenAI drop-in aliases. */
|
|
115
|
+
export type SpeakModel = "pyai-speak" | "tts-1" | "tts-1-hd";
|
|
116
|
+
|
|
92
117
|
export interface SpeechParams {
|
|
93
118
|
input: string;
|
|
94
119
|
voice?: string;
|
|
95
|
-
model?:
|
|
120
|
+
model?: SpeakModel;
|
|
121
|
+
/**
|
|
122
|
+
* Delivery mode. The API defaults to true and every catalog voice accepts
|
|
123
|
+
* both values. Set false when you want one complete buffered body with a
|
|
124
|
+
* `Content-Length`. A voice whose serving fleet has no streaming lane is
|
|
125
|
+
* rendered buffered either way and says so with `x-pyai-stream: buffered`.
|
|
126
|
+
*/
|
|
127
|
+
stream?: boolean;
|
|
96
128
|
/**
|
|
97
129
|
* Output container/codec, resampled+encoded server-side. One of
|
|
98
130
|
* {@link SpeechFormat}, anything else is a `400 unsupported_format`. Omit for
|
|
99
|
-
* the default of `
|
|
131
|
+
* the default of `wav`.
|
|
100
132
|
*
|
|
101
133
|
* `g711_ulaw`/`g711_alaw` return raw 8 kHz mono G.711, the bytes Twilio/SIP
|
|
102
134
|
* media streams expect, so you can hand the response straight to a telephony
|
|
@@ -113,32 +145,91 @@ export interface SpeechParams {
|
|
|
113
145
|
* telephony pipelines, most often with `response_format: "pcm"`.
|
|
114
146
|
*/
|
|
115
147
|
sample_rate?: SpeechSampleRate;
|
|
148
|
+
/** Reserved; currently returns `400 unsupported_parameter` when provided. */
|
|
116
149
|
speed?: number;
|
|
117
150
|
/**
|
|
118
|
-
*
|
|
119
|
-
* honored once the engine supports it (otherwise ignored server-side), so it's
|
|
120
|
-
* always safe to send.
|
|
151
|
+
* Reserved; currently returns `400 unsupported_parameter` when provided.
|
|
121
152
|
*/
|
|
122
153
|
seed?: number;
|
|
123
154
|
/**
|
|
124
|
-
*
|
|
155
|
+
* Reserved; currently returns `400 unsupported_parameter` when provided.
|
|
125
156
|
*/
|
|
126
157
|
temperature?: number;
|
|
127
158
|
}
|
|
128
159
|
|
|
160
|
+
function assertActiveSpeechParams(params: SpeechParams): void {
|
|
161
|
+
for (const field of ["speed", "seed", "temperature"] as const) {
|
|
162
|
+
if (params[field] !== undefined) {
|
|
163
|
+
throw new Error(`${field} is reserved but not active on Speak`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
129
168
|
export interface CreateJobParams {
|
|
130
169
|
audio_url: string;
|
|
131
170
|
model?: string;
|
|
132
171
|
diarize?: boolean;
|
|
133
172
|
channel?: boolean;
|
|
134
173
|
numerals?: boolean;
|
|
174
|
+
smart_format?: boolean;
|
|
175
|
+
dictation?: boolean;
|
|
176
|
+
drop_fillers?: boolean;
|
|
177
|
+
/**
|
|
178
|
+
* Per-job names, brands, products, or distinctive terms. PyAI keeps up to
|
|
179
|
+
* five valid entries after trimming, deduplication, and common-word
|
|
180
|
+
* filtering.
|
|
181
|
+
*/
|
|
182
|
+
vocabulary?: string[];
|
|
135
183
|
output_formats?: Array<"json" | "srt" | "vtt">;
|
|
136
184
|
webhook_url?: string;
|
|
185
|
+
call_id?: string;
|
|
186
|
+
pack_id?: string;
|
|
187
|
+
call_direction?: "inbound" | "outbound";
|
|
188
|
+
customer_name?: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export type HearVocabularyProfile = "batch" | "hear_stream";
|
|
192
|
+
|
|
193
|
+
export interface HearVocabularyInput {
|
|
194
|
+
/** Organization-owned names, brands, products, or distinctive terms. */
|
|
195
|
+
terms: string[];
|
|
196
|
+
/** Stored terms remain inert unless a use case is selected here. */
|
|
197
|
+
enabledFor: HearVocabularyProfile[];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface HearVocabulary {
|
|
201
|
+
object: "hear.vocabulary";
|
|
202
|
+
/** Sanitized stored list, at most five terms. */
|
|
203
|
+
terms: string[];
|
|
204
|
+
/** Use cases that may add stored suggestions. */
|
|
205
|
+
enabled_for: HearVocabularyProfile[];
|
|
206
|
+
/** Unix milliseconds, or null before the first save. */
|
|
207
|
+
updated_at: number | null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface AgentConfig {
|
|
211
|
+
name?: string;
|
|
212
|
+
persona_system_prompt?: string | null;
|
|
213
|
+
greeting?: string | null;
|
|
214
|
+
voice_id?: string | null;
|
|
215
|
+
language?: "en" | "fr" | "es" | "de" | "hi" | null;
|
|
216
|
+
/**
|
|
217
|
+
* Opt-in speech-recognition vocabulary. PyAI sanitizes and keeps at most
|
|
218
|
+
* five terms. An empty list or null turns it off.
|
|
219
|
+
*/
|
|
220
|
+
vocabulary?: string[] | null;
|
|
221
|
+
[key: string]: unknown;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface Agent {
|
|
225
|
+
object: "agent";
|
|
226
|
+
agent_id: string;
|
|
227
|
+
name: string;
|
|
228
|
+
vocabulary: string[];
|
|
229
|
+
[key: string]: unknown;
|
|
137
230
|
}
|
|
138
231
|
|
|
139
232
|
export interface RealtimeOptions {
|
|
140
|
-
/** "omni" (agentic voice) or "flow" (legacy voice duplex). Default "omni". */
|
|
141
|
-
product?: "omni" | "flow";
|
|
142
233
|
/**
|
|
143
234
|
* Optional opaque tag echoed to your `kb_endpoint` and recorded on the call.
|
|
144
235
|
* Omni is zero-state: the session is authorized by the key's org, so there is
|
|
@@ -146,12 +237,10 @@ export interface RealtimeOptions {
|
|
|
146
237
|
*/
|
|
147
238
|
sessionLabel?: string;
|
|
148
239
|
/**
|
|
149
|
-
*
|
|
150
|
-
* `
|
|
151
|
-
*
|
|
240
|
+
* Extra canonical query params (`format`, `rate`, or the intentional
|
|
241
|
+
* server-side `api_key` auth option). Retired connect aliases and model/token
|
|
242
|
+
* selectors are rejected.
|
|
152
243
|
*/
|
|
153
|
-
agentId?: string;
|
|
154
|
-
/** Extra query params (e.g. format, rate). */
|
|
155
244
|
query?: Record<string, string>;
|
|
156
245
|
}
|
|
157
246
|
|
|
@@ -195,6 +284,8 @@ export interface OmniSession {
|
|
|
195
284
|
|
|
196
285
|
/** Frame `type`s emitted by the Hear streaming-STT WebSocket. */
|
|
197
286
|
export const HearFrameType = {
|
|
287
|
+
/** Applied endpointing configuration and validation warnings. */
|
|
288
|
+
ConfigAck: "config_ack",
|
|
198
289
|
/** Eager live hypothesis for the current utterance. */
|
|
199
290
|
Partial: "partial",
|
|
200
291
|
/** Partial whose prefix has stabilized (won't be revised). */
|
|
@@ -210,10 +301,16 @@ export const HearFrameType = {
|
|
|
210
301
|
} as const;
|
|
211
302
|
export type HearFrameType = (typeof HearFrameType)[keyof typeof HearFrameType];
|
|
212
303
|
|
|
213
|
-
/**
|
|
304
|
+
/**
|
|
305
|
+
* WebSocket close codes used across the PyAI realtime/streaming surfaces.
|
|
306
|
+
* Browser callers may initiate only Normal or values in 3000-4999; the
|
|
307
|
+
* standards-reserved values below are server close observations.
|
|
308
|
+
*/
|
|
214
309
|
export const WSCloseCode = {
|
|
215
310
|
/** Normal closure. */
|
|
216
311
|
Normal: 1000,
|
|
312
|
+
/** Browser-safe private application code for a malformed peer protocol frame. */
|
|
313
|
+
ProtocolViolation: 4002,
|
|
217
314
|
/** Auth/policy: bad key, missing scope, or revoked token. */
|
|
218
315
|
PolicyViolation: 1008,
|
|
219
316
|
/** Engine/internal error. */
|
|
@@ -232,7 +329,7 @@ export const ErrorCode = {
|
|
|
232
329
|
Unauthorized: "unauthorized",
|
|
233
330
|
Forbidden: "forbidden",
|
|
234
331
|
OriginNotAllowed: "origin_not_allowed",
|
|
235
|
-
|
|
332
|
+
InvalidSessionLabel: "invalid_session_label",
|
|
236
333
|
CreditExhausted: "credit_exhausted",
|
|
237
334
|
KeyBudgetExceeded: "key_budget_exceeded",
|
|
238
335
|
InsufficientQuota: "insufficient_quota",
|
|
@@ -242,6 +339,7 @@ export const ErrorCode = {
|
|
|
242
339
|
IdempotencyConflict: "idempotency_conflict",
|
|
243
340
|
NotFound: "not_found",
|
|
244
341
|
NumberInUse: "number_in_use",
|
|
342
|
+
UnsupportedToolTransport: "unsupported_tool_transport",
|
|
245
343
|
} as const;
|
|
246
344
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
247
345
|
|
|
@@ -255,6 +353,24 @@ export interface HearGroundingPassage {
|
|
|
255
353
|
score: number;
|
|
256
354
|
}
|
|
257
355
|
|
|
356
|
+
/** One endpointing validation result echoed in a `config_ack` frame. */
|
|
357
|
+
export interface HearConfigWarning {
|
|
358
|
+
field: string;
|
|
359
|
+
value: unknown;
|
|
360
|
+
effective?: number;
|
|
361
|
+
reason: "clamped_to_range" | "not_a_number" | "unknown_config_field" | (string & {});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Applied endpointing settings after connect-time or mid-session config. */
|
|
365
|
+
export interface HearConfigAckFrame {
|
|
366
|
+
type: "config_ack";
|
|
367
|
+
endpointing_ms: number;
|
|
368
|
+
effective_floor_ms: number;
|
|
369
|
+
effective_ceiling_ms: number;
|
|
370
|
+
score_interval_ms: number;
|
|
371
|
+
warnings: HearConfigWarning[];
|
|
372
|
+
}
|
|
373
|
+
|
|
258
374
|
/** Live hypothesis frame (`partial` / `partial_stable`). */
|
|
259
375
|
export interface HearPartialFrame {
|
|
260
376
|
type: "partial" | "partial_stable";
|
|
@@ -269,6 +385,12 @@ export interface HearPartialFrame {
|
|
|
269
385
|
}
|
|
270
386
|
|
|
271
387
|
/** Finalized-utterance frame (`speech_final` / `final`). */
|
|
388
|
+
export type HearEndpointReason =
|
|
389
|
+
| "peak_te_early"
|
|
390
|
+
| "silence_backstop"
|
|
391
|
+
| "commit"
|
|
392
|
+
| (string & {});
|
|
393
|
+
|
|
272
394
|
export interface HearFinalFrame {
|
|
273
395
|
type: "speech_final" | "final";
|
|
274
396
|
text: string;
|
|
@@ -276,6 +398,8 @@ export interface HearFinalFrame {
|
|
|
276
398
|
t_ms: number;
|
|
277
399
|
/** Active-speech length of the utterance (the billed signal), ms. */
|
|
278
400
|
audio_ms: number;
|
|
401
|
+
/** Why the utterance ended. Log this when tuning automatic endpointing. */
|
|
402
|
+
endpoint_reason: HearEndpointReason;
|
|
279
403
|
/** Present only with Cue grounding enabled (top KB passages). */
|
|
280
404
|
grounding?: HearGroundingPassage[];
|
|
281
405
|
}
|
|
@@ -288,7 +412,7 @@ export interface HearUsageFrame {
|
|
|
288
412
|
type: "usage";
|
|
289
413
|
/** `hear` for plain streaming, `cue` when grounding was enabled. */
|
|
290
414
|
product: "hear" | "cue";
|
|
291
|
-
/** The billed meter (`hear.
|
|
415
|
+
/** The billed meter (`hear.minutes` or `cue.minutes`). */
|
|
292
416
|
meter: string;
|
|
293
417
|
/** Summed active-speech audio billed for the session, in seconds. */
|
|
294
418
|
audio_seconds: number;
|
|
@@ -303,7 +427,12 @@ export interface HearErrorFrame {
|
|
|
303
427
|
message: string;
|
|
304
428
|
}
|
|
305
429
|
|
|
306
|
-
export type HearFrame =
|
|
430
|
+
export type HearFrame =
|
|
431
|
+
| HearConfigAckFrame
|
|
432
|
+
| HearPartialFrame
|
|
433
|
+
| HearFinalFrame
|
|
434
|
+
| HearUsageFrame
|
|
435
|
+
| HearErrorFrame;
|
|
307
436
|
|
|
308
437
|
/**
|
|
309
438
|
* Minimal structural WebSocket, matches both the browser/Node global
|
|
@@ -319,36 +448,70 @@ export interface WebSocketLike {
|
|
|
319
448
|
onclose: ((ev: { code: number; reason: string }) => void) | null;
|
|
320
449
|
}
|
|
321
450
|
|
|
451
|
+
/**
|
|
452
|
+
* The non-secret marker every PyAI realtime client offers ALONGSIDE its
|
|
453
|
+
* `pyai-key.<KEY>` credential token.
|
|
454
|
+
*
|
|
455
|
+
* A browser cannot set `Authorization` on a WebSocket, so the key rides in
|
|
456
|
+
* `Sec-WebSocket-Protocol`. RFC 6455 makes the server echo the subprotocol it
|
|
457
|
+
* SELECTS, so a server that selects the credential publishes the credential —
|
|
458
|
+
* which api.pyai.com did until 2026-09-07. The edge now echoes only this
|
|
459
|
+
* marker. Offering it is not optional for Node clients: `ws` throws
|
|
460
|
+
* "Server sent no subprotocol" when it offered subprotocols and the 101
|
|
461
|
+
* selected none, and RFC 6455 lets the server select only a value the client
|
|
462
|
+
* actually offered.
|
|
463
|
+
*/
|
|
464
|
+
export const REALTIME_SUBPROTOCOL_MARKER = "pyai.v1";
|
|
465
|
+
|
|
322
466
|
export type WebSocketCtor = new (url: string, protocols?: string | string[]) => WebSocketLike;
|
|
323
467
|
|
|
324
468
|
export interface HearStreamOptions {
|
|
325
469
|
/** Streaming STT model. Server default `pyai-hear`. */
|
|
326
470
|
model?: string;
|
|
327
|
-
/**
|
|
328
|
-
language?:
|
|
471
|
+
/** Omit or use auto for automatic detection; an explicit code pins recognition. */
|
|
472
|
+
language?: "auto" | "en" | "es" | "fr" | "de" | "hi" | "it" | "pt" | "nl";
|
|
329
473
|
/** Input PCM sample rate in Hz. Default 16000 server-side. */
|
|
330
474
|
sampleRate?: number;
|
|
331
475
|
/** Audio frame encoding. Default "pcm16". */
|
|
332
|
-
encoding?: "pcm16"
|
|
476
|
+
encoding?: "pcm16";
|
|
333
477
|
/** Emit eager partial hypotheses. Default true server-side. */
|
|
334
478
|
interimResults?: boolean;
|
|
335
479
|
/**
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
480
|
+
* Tri-state number formatting on **final** transcripts. `true` forces digits,
|
|
481
|
+
* `false` keeps spoken form, omitted keeps the live engine default (ITN on).
|
|
482
|
+
* Never applied to interim partials. Independent of `smartFormat`.
|
|
339
483
|
*/
|
|
340
484
|
numerals?: boolean;
|
|
341
485
|
/**
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
|
|
345
|
-
|
|
486
|
+
* Opt-in English punctuation and sentence capitalization on **final**
|
|
487
|
+
* transcripts only. Interim partials are never formatted. Default false.
|
|
488
|
+
*/
|
|
489
|
+
smartFormat?: boolean;
|
|
490
|
+
/**
|
|
491
|
+
* Spoken punctuation commands (`period`, `comma`, `new paragraph`,
|
|
492
|
+
* `question mark`) on finals only. Separate from `smartFormat`. Off by default.
|
|
493
|
+
*/
|
|
494
|
+
dictation?: boolean;
|
|
495
|
+
/**
|
|
496
|
+
* Strip `um` / `uh` / `umm` / `uhh` / `er` on finals. Off by default.
|
|
497
|
+
* Do not enable on legal or compliance audio by default.
|
|
498
|
+
*/
|
|
499
|
+
dropFillers?: boolean;
|
|
500
|
+
/**
|
|
501
|
+
* Per-session names, brands, products, or other distinctive terms. PyAI
|
|
502
|
+
* sanitizes up to five effective terms. Request terms come first, then stored
|
|
503
|
+
* `hear_stream` suggestions fill remaining slots. The list is fixed at open.
|
|
504
|
+
*/
|
|
505
|
+
vocabulary?: string[];
|
|
506
|
+
/**
|
|
507
|
+
* Minimum trailing-pause length before an utterance may end (50-5000 ms).
|
|
508
|
+
* Turn detection may wait longer, bounded at `max(endpointingMs, 1500)`.
|
|
509
|
+
* The server confirms the applied value through `onConfigAck`.
|
|
346
510
|
*/
|
|
347
511
|
endpointingMs?: number;
|
|
348
512
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
* array. Bills a single `cue.minutes` line instead of the Hear rate.
|
|
513
|
+
* Reserved Cue grounding configuration. Grounding is not active on the
|
|
514
|
+
* serving Hear stream; do not rely on grounding frames or Cue metering yet.
|
|
352
515
|
*/
|
|
353
516
|
grounding?: boolean;
|
|
354
517
|
/** Cue: number of KB passages to retrieve per turn (1-20, default 3). */
|
|
@@ -360,8 +523,11 @@ export interface HearStreamOptions {
|
|
|
360
523
|
groundingTimeoutMs?: number;
|
|
361
524
|
/** Extra query params merged onto the connect URL. */
|
|
362
525
|
query?: Record<string, string>;
|
|
363
|
-
/** Fired once the socket opens
|
|
526
|
+
/** Fired once the socket opens. */
|
|
364
527
|
onOpen?: () => void;
|
|
528
|
+
/** Fired after connect-time or mid-session endpointing config. Assert that
|
|
529
|
+
* `warnings` is empty before relying on the requested floor. */
|
|
530
|
+
onConfigAck?: (frame: HearConfigAckFrame) => void;
|
|
365
531
|
/** Fired on `partial` / `partial_stable`. */
|
|
366
532
|
onPartial?: (frame: HearPartialFrame) => void;
|
|
367
533
|
/** Fired on `speech_final` / `final`. */
|
|
@@ -379,8 +545,10 @@ export interface HearStreamOptions {
|
|
|
379
545
|
/**
|
|
380
546
|
* A live Hear streaming-STT session. Hides the frame protocol: stream audio
|
|
381
547
|
* with {@link HearStream.sendAudio}, get `onPartial`/`onFinal`/`onError`
|
|
382
|
-
* callbacks,
|
|
383
|
-
*
|
|
548
|
+
* callbacks, update the silence floor with
|
|
549
|
+
* {@link HearStream.configureEndpointing}, force-finalize with
|
|
550
|
+
* {@link HearStream.commit}, and flush+close with {@link HearStream.close}.
|
|
551
|
+
* Construct via `pyai.audio.transcriptions.stream()`.
|
|
384
552
|
*/
|
|
385
553
|
export class HearStream {
|
|
386
554
|
private readonly ws: WebSocketLike;
|
|
@@ -389,25 +557,19 @@ export class HearStream {
|
|
|
389
557
|
|
|
390
558
|
constructor(url: string, subprotocol: string, opts: HearStreamOptions) {
|
|
391
559
|
this.opts = opts;
|
|
560
|
+
if (opts.grounding) {
|
|
561
|
+
throw new Error(
|
|
562
|
+
"Cue grounding is not active on the serving Hear stream; omit grounding until the API reference marks it active",
|
|
563
|
+
);
|
|
564
|
+
}
|
|
392
565
|
const WS = opts.webSocket ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket;
|
|
393
566
|
if (!WS) {
|
|
394
567
|
throw new Error(
|
|
395
568
|
"No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to transcriptions.stream()",
|
|
396
569
|
);
|
|
397
570
|
}
|
|
398
|
-
this.ws = new WS(url, [subprotocol]);
|
|
571
|
+
this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
|
|
399
572
|
this.ws.onopen = () => {
|
|
400
|
-
if (opts.grounding) {
|
|
401
|
-
try {
|
|
402
|
-
const cfg: Record<string, unknown> = { type: "config", grounding: true };
|
|
403
|
-
if (opts.groundingK != null) cfg.grounding_k = opts.groundingK;
|
|
404
|
-
if (opts.groundingMinScore != null) cfg.grounding_min_score = opts.groundingMinScore;
|
|
405
|
-
if (opts.groundingTimeoutMs != null) cfg.grounding_timeout_ms = opts.groundingTimeoutMs;
|
|
406
|
-
this.ws.send(JSON.stringify(cfg));
|
|
407
|
-
} catch {
|
|
408
|
-
/* surfaced via onerror */
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
573
|
opts.onOpen?.();
|
|
412
574
|
};
|
|
413
575
|
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
@@ -429,6 +591,9 @@ export class HearStream {
|
|
|
429
591
|
return;
|
|
430
592
|
}
|
|
431
593
|
switch (frame.type) {
|
|
594
|
+
case HearFrameType.ConfigAck:
|
|
595
|
+
this.opts.onConfigAck?.(frame);
|
|
596
|
+
break;
|
|
432
597
|
case HearFrameType.Partial:
|
|
433
598
|
case HearFrameType.PartialStable:
|
|
434
599
|
this.opts.onPartial?.(frame);
|
|
@@ -454,6 +619,11 @@ export class HearStream {
|
|
|
454
619
|
this.ws.send(chunk);
|
|
455
620
|
}
|
|
456
621
|
|
|
622
|
+
/** Change the minimum trailing-pause floor without reconnecting. */
|
|
623
|
+
configureEndpointing(endpointingMs: number): void {
|
|
624
|
+
this.ws.send(JSON.stringify({ type: "config", endpointing_ms: endpointingMs }));
|
|
625
|
+
}
|
|
626
|
+
|
|
457
627
|
/** Force-finalize the current utterance (e.g. on VAD end-of-turn). */
|
|
458
628
|
commit(): void {
|
|
459
629
|
this.ws.send(JSON.stringify({ type: "commit" }));
|
|
@@ -497,7 +667,7 @@ export class AmdStream {
|
|
|
497
667
|
"No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to amd.stream()",
|
|
498
668
|
);
|
|
499
669
|
}
|
|
500
|
-
this.ws = new WS(url, [subprotocol]);
|
|
670
|
+
this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
|
|
501
671
|
this.ws.onopen = () => opts.onOpen?.();
|
|
502
672
|
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
503
673
|
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
@@ -573,6 +743,8 @@ export const OmniEvent = {
|
|
|
573
743
|
Flush: "flush",
|
|
574
744
|
/** Engine requests a client-loop tool invocation. */
|
|
575
745
|
ToolCall: "tool_call",
|
|
746
|
+
/** A write tool is waiting for the caller to confirm; it has not run. */
|
|
747
|
+
ToolConfirmationRequired: "tool_confirmation_required",
|
|
576
748
|
/** Session is closing; see close code. */
|
|
577
749
|
SessionEnd: "session_end",
|
|
578
750
|
/** Server fault frame. */
|
|
@@ -586,25 +758,23 @@ export interface OmniServerFrame {
|
|
|
586
758
|
[k: string]: unknown;
|
|
587
759
|
}
|
|
588
760
|
|
|
589
|
-
/** Canonical, sanitized transcript delivered by the native Omni demux.
|
|
761
|
+
/** Canonical, sanitized transcript delivered by the native Omni demux.
|
|
762
|
+
* The live wire sends caller text deltas; the JSON fields are SDK-owned
|
|
763
|
+
* normalization so applications don't have to special-case the byte payload. */
|
|
590
764
|
export interface OmniTranscriptFrame extends OmniServerFrame {
|
|
591
765
|
event: "transcript";
|
|
592
766
|
role: "user" | "assistant";
|
|
593
767
|
text: string;
|
|
594
768
|
final: boolean;
|
|
769
|
+
/** Live text frames append; legacy JSON frames replace unless they use `delta`. */
|
|
595
770
|
mode: "delta" | "replace";
|
|
771
|
+
/** Optional ordering hint on legacy JSON frames. */
|
|
596
772
|
sequence?: number;
|
|
597
773
|
}
|
|
598
774
|
|
|
599
775
|
const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
|
|
600
776
|
const OMNI_TRANSCRIPT_MAX_CHARS = 4_000;
|
|
601
777
|
|
|
602
|
-
function omniTranscriptRole(value: unknown): "user" | "assistant" | null {
|
|
603
|
-
if (value === "user" || value === "caller" || value === "human") return "user";
|
|
604
|
-
if (value === "assistant" || value === "agent") return "assistant";
|
|
605
|
-
return null;
|
|
606
|
-
}
|
|
607
|
-
|
|
608
778
|
function omniTranscriptText(value: unknown): string | null {
|
|
609
779
|
return typeof value === "string"
|
|
610
780
|
&& value.length > 0
|
|
@@ -614,7 +784,13 @@ function omniTranscriptText(value: unknown): string | null {
|
|
|
614
784
|
: null;
|
|
615
785
|
}
|
|
616
786
|
|
|
617
|
-
|
|
787
|
+
function omniTranscriptRole(value: unknown): "user" | "assistant" | null {
|
|
788
|
+
if (value === "user" || value === "caller" || value === "human") return "user";
|
|
789
|
+
if (value === "assistant" || value === "agent") return "assistant";
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** Normalize the live UTF-8 `0x02` text body and bounded legacy JSON bodies. */
|
|
618
794
|
export function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFrame | null {
|
|
619
795
|
if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES) return null;
|
|
620
796
|
let decoded: string;
|
|
@@ -623,12 +799,16 @@ export function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFr
|
|
|
623
799
|
} catch {
|
|
624
800
|
return null;
|
|
625
801
|
}
|
|
802
|
+
// The serving engine's canonical payload is plain UTF-8 caller text, not
|
|
803
|
+
// JSON. Each frame is a delta for the current caller turn.
|
|
626
804
|
if (!decoded.trimStart().startsWith("{")) {
|
|
627
805
|
const text = omniTranscriptText(decoded);
|
|
628
806
|
return text
|
|
629
807
|
? { event: "transcript", role: "user", text, final: false, mode: "delta" }
|
|
630
808
|
: null;
|
|
631
809
|
}
|
|
810
|
+
|
|
811
|
+
// Keep accepting a bounded direct object for older bridges and recordings.
|
|
632
812
|
let value: unknown;
|
|
633
813
|
try {
|
|
634
814
|
value = JSON.parse(decoded);
|
|
@@ -637,6 +817,8 @@ export function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFr
|
|
|
637
817
|
}
|
|
638
818
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
639
819
|
const payload = value as Record<string, unknown>;
|
|
820
|
+
if (payload.type !== undefined
|
|
821
|
+
|| (payload.event !== undefined && payload.event !== "transcript")) return null;
|
|
640
822
|
const role = omniTranscriptRole(payload.role ?? payload.speaker);
|
|
641
823
|
const mode = typeof payload.delta === "string" ? "delta" : "replace";
|
|
642
824
|
const text = omniTranscriptText(
|
|
@@ -696,12 +878,10 @@ export interface OmniToolDef {
|
|
|
696
878
|
name: string;
|
|
697
879
|
description?: string;
|
|
698
880
|
parameters?: Record<string, unknown>;
|
|
699
|
-
/** When set, engine-POST mode. Omit for client-loop (default). */
|
|
700
|
-
endpoint?: string;
|
|
701
881
|
}
|
|
702
882
|
|
|
703
883
|
export interface OmniToolCallFrame {
|
|
704
|
-
|
|
884
|
+
event: "tool_call";
|
|
705
885
|
call_id: string;
|
|
706
886
|
name: string;
|
|
707
887
|
arguments?: Record<string, unknown>;
|
|
@@ -727,7 +907,7 @@ export interface OmniConfigure {
|
|
|
727
907
|
/**
|
|
728
908
|
* Session language, end to end (recognition, reasoning, voice). Also
|
|
729
909
|
* settable on the agent profile (`language` on `POST /v1/agents`), which
|
|
730
|
-
* applies automatically when
|
|
910
|
+
* applies automatically when `session_label` is the saved profile id;
|
|
731
911
|
* an inline value here wins for the session. Default `en`. Fail-safe: an
|
|
732
912
|
* unknown/not-yet-enabled language falls back to `en` (the `configured`
|
|
733
913
|
* ack carries `language_active` + `language_fallback: true`), the call
|
|
@@ -735,7 +915,9 @@ export interface OmniConfigure {
|
|
|
735
915
|
* staged, see the Language support reference.
|
|
736
916
|
*/
|
|
737
917
|
language?: "en" | "fr" | "es" | "de" | "hi";
|
|
738
|
-
/** Function calling
|
|
918
|
+
/** Function calling: hosted catalog names, client-loop schemas, or names of
|
|
919
|
+
* server tools already registered with POST /v1/tools. An inline `endpoint`
|
|
920
|
+
* is rejected with `unsupported_tool_transport`. */
|
|
739
921
|
tools?: OmniToolDef[];
|
|
740
922
|
/** Forward-compatible: any other key the engine honors. */
|
|
741
923
|
[k: string]: unknown;
|
|
@@ -750,7 +932,11 @@ export interface OmniConnectOptions {
|
|
|
750
932
|
* so a page never holds a secret key.
|
|
751
933
|
*/
|
|
752
934
|
token?: string;
|
|
753
|
-
/**
|
|
935
|
+
/**
|
|
936
|
+
* Caller-input sample rate. `24000` and `16000` sessions receive agent audio
|
|
937
|
+
* at 24 kHz; `8000` sessions receive 8 kHz. Read `hello.audio_out` rather than
|
|
938
|
+
* assuming output matches this value.
|
|
939
|
+
*/
|
|
754
940
|
rate?: 24000 | 16000 | 8000;
|
|
755
941
|
/** Connect-URL audio format. Default `pcm16`. */
|
|
756
942
|
format?: "pcm16";
|
|
@@ -773,7 +959,7 @@ export interface OmniConnectOptions {
|
|
|
773
959
|
onSessionStarted?: (frame: OmniServerFrame) => void;
|
|
774
960
|
/** Fired on `turn` boundaries. */
|
|
775
961
|
onTurn?: (frame: OmniServerFrame) => void;
|
|
776
|
-
/** Fired
|
|
962
|
+
/** Fired for each normalized caller-transcript delta (`0x02` plain UTF-8 live). */
|
|
777
963
|
onTranscript?: (frame: OmniServerFrame) => void;
|
|
778
964
|
/** Fired on `barge_in` / `flush` (user interrupted). */
|
|
779
965
|
onBargeIn?: (frame: OmniServerFrame) => void;
|
|
@@ -824,7 +1010,7 @@ export class OmniConnection {
|
|
|
824
1010
|
"No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to omni.connect()",
|
|
825
1011
|
);
|
|
826
1012
|
}
|
|
827
|
-
this.ws = new WS(url, [subprotocol]);
|
|
1013
|
+
this.ws = new WS(url, [REALTIME_SUBPROTOCOL_MARKER, subprotocol]);
|
|
828
1014
|
this.ws.onopen = () => {
|
|
829
1015
|
if (opts.configure) {
|
|
830
1016
|
try {
|
|
@@ -878,7 +1064,11 @@ export class OmniConnection {
|
|
|
878
1064
|
}
|
|
879
1065
|
if (tag === 0x03) {
|
|
880
1066
|
try {
|
|
881
|
-
const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1))) as OmniServerFrame;
|
|
1067
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(1))) as OmniServerFrame;
|
|
1068
|
+
if (parsed?.event === OmniEvent.Transcript) {
|
|
1069
|
+
this.opts.onError?.(new Error("Omni transcript events must use a binary 0x02 frame"));
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
882
1072
|
this.dispatchFrame(parsed);
|
|
883
1073
|
} catch {
|
|
884
1074
|
this.opts.onError?.(new Error("Unparseable Omni binary frame"));
|
|
@@ -889,23 +1079,16 @@ export class OmniConnection {
|
|
|
889
1079
|
this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
|
|
890
1080
|
return;
|
|
891
1081
|
}
|
|
892
|
-
|
|
893
|
-
try {
|
|
894
|
-
this.dispatchFrame(JSON.parse(data) as OmniServerFrame);
|
|
895
|
-
} catch {
|
|
896
|
-
this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
|
|
897
|
-
}
|
|
1082
|
+
this.opts.onError?.(new Error("Unexpected Omni text frame; server frames must use binary 0x01/0x02/0x03 tags"));
|
|
898
1083
|
}
|
|
899
1084
|
|
|
900
1085
|
private dispatchFrame(frame: OmniServerFrame): void {
|
|
901
|
-
const
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
: "";
|
|
908
|
-
this.opts.onEvent?.({ ...frame, event: eventName });
|
|
1086
|
+
const eventName = (frame as { event?: unknown }).event;
|
|
1087
|
+
if (typeof eventName !== "string" || !eventName) {
|
|
1088
|
+
this.opts.onError?.(new Error("Omni server control frame is missing its event key"));
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
this.opts.onEvent?.(frame);
|
|
909
1092
|
switch (eventName) {
|
|
910
1093
|
case OmniEvent.Hello:
|
|
911
1094
|
this.opts.onHello?.(frame);
|
|
@@ -947,7 +1130,10 @@ export class OmniConnection {
|
|
|
947
1130
|
* `{"event":"configure"}` is acked but silently dropped by the engine.)
|
|
948
1131
|
*/
|
|
949
1132
|
configure(cfg: OmniConfigure): void {
|
|
950
|
-
|
|
1133
|
+
const payload: Record<string, unknown> = { ...cfg };
|
|
1134
|
+
delete payload.type;
|
|
1135
|
+
delete payload.event;
|
|
1136
|
+
this.ws.send(omniControlFrame({ type: "configure", ...payload }));
|
|
951
1137
|
}
|
|
952
1138
|
|
|
953
1139
|
/**
|
|
@@ -987,7 +1173,12 @@ export class OmniConnection {
|
|
|
987
1173
|
* never `event`.
|
|
988
1174
|
*/
|
|
989
1175
|
send(frame: Record<string, unknown>): void {
|
|
990
|
-
|
|
1176
|
+
if (typeof frame.type !== "string" || !frame.type) {
|
|
1177
|
+
throw new TypeError("Omni client control frames must have a non-empty type key");
|
|
1178
|
+
}
|
|
1179
|
+
const payload = { ...frame };
|
|
1180
|
+
delete payload.event;
|
|
1181
|
+
this.ws.send(omniControlFrame(payload));
|
|
991
1182
|
}
|
|
992
1183
|
|
|
993
1184
|
/** Close the session. */
|
|
@@ -1284,8 +1475,41 @@ export interface RecapCallSummary {
|
|
|
1284
1475
|
completed_at?: number | null;
|
|
1285
1476
|
}
|
|
1286
1477
|
|
|
1478
|
+
export interface RecapActionItem {
|
|
1479
|
+
owner?: string | null;
|
|
1480
|
+
task: string;
|
|
1481
|
+
due?: string | null;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
export interface RecapTalkRatio {
|
|
1485
|
+
agent: number;
|
|
1486
|
+
customer: number;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
export interface RecapSignal {
|
|
1490
|
+
kind: string;
|
|
1491
|
+
text: string;
|
|
1492
|
+
at_s?: number | null;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
export interface RecapRecord {
|
|
1496
|
+
format: "recap.record.v1";
|
|
1497
|
+
tldr: string | null;
|
|
1498
|
+
summary: string | null;
|
|
1499
|
+
action_items: RecapActionItem[];
|
|
1500
|
+
disposition: string | null;
|
|
1501
|
+
next_steps: string | null;
|
|
1502
|
+
talk_ratio: RecapTalkRatio | null;
|
|
1503
|
+
signals: RecapSignal[];
|
|
1504
|
+
fields: Record<string, unknown>;
|
|
1505
|
+
rep?: Record<string, unknown>;
|
|
1506
|
+
manager?: Record<string, unknown>;
|
|
1507
|
+
ops?: Record<string, unknown>;
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1287
1510
|
export interface RecapCall extends RecapCallSummary {
|
|
1288
|
-
record?:
|
|
1511
|
+
record?: RecapRecord;
|
|
1512
|
+
transcript?: { format: "utterances.v1"; utterances: Array<{ speaker_role: "agent" | "customer"; text: string; offset_s: number; duration_s: number }> };
|
|
1289
1513
|
error?: string | null;
|
|
1290
1514
|
crm_write_status?: string | null;
|
|
1291
1515
|
}
|
|
@@ -1301,19 +1525,23 @@ export interface RecapCallTriggerInput {
|
|
|
1301
1525
|
|
|
1302
1526
|
// --- AMD (answering-machine detection) -----------------------------------
|
|
1303
1527
|
|
|
1304
|
-
/**
|
|
1528
|
+
/**
|
|
1529
|
+
* The answered-by vocabulary on stored call records and the
|
|
1530
|
+
* `amd.call.completed` webhook: the routing classes plus the machine subtypes.
|
|
1531
|
+
*/
|
|
1305
1532
|
export type AmdAnsweredBy =
|
|
1306
1533
|
| "human"
|
|
1534
|
+
| "machine"
|
|
1307
1535
|
| "voicemail"
|
|
1308
|
-
| "live_voicemail"
|
|
1309
1536
|
| "screening" // iPhone / Google Call Screen
|
|
1310
1537
|
| "ivr"
|
|
1311
|
-
| "
|
|
1538
|
+
| "music" // hold music
|
|
1312
1539
|
| "sit_invalid" // dead / disconnected number
|
|
1313
|
-
| "fax"
|
|
1314
|
-
| "silence"
|
|
1315
1540
|
| "unknown";
|
|
1316
1541
|
|
|
1542
|
+
/** The routing classes pushed on the mid-call wire event (`event: "amd"`). */
|
|
1543
|
+
export type AmdWireAnsweredBy = "human" | "machine" | "sit_invalid" | "unknown";
|
|
1544
|
+
|
|
1317
1545
|
/** Twilio's `AnsweredBy` enum, echoed for drop-in migration parity. */
|
|
1318
1546
|
export type AmdTwilioAnsweredBy =
|
|
1319
1547
|
| "human"
|
|
@@ -1363,11 +1591,16 @@ export interface AmdCall extends AmdCallSummary {
|
|
|
1363
1591
|
error?: string | null;
|
|
1364
1592
|
}
|
|
1365
1593
|
|
|
1366
|
-
/**
|
|
1594
|
+
/**
|
|
1595
|
+
* A mid-call AMD decision event pushed on the stream (and to the per-call
|
|
1596
|
+
* TwiML `webhook`). Carries the coarse routing class; the machine subtype
|
|
1597
|
+
* (`voicemail`/`ivr`/`screening`/`music`) is on the stored call record
|
|
1598
|
+
* (`AmdCall`) and the `amd.call.completed` webhook instead.
|
|
1599
|
+
*/
|
|
1367
1600
|
export interface AmdDecisionEvent {
|
|
1368
1601
|
event?: "amd";
|
|
1369
1602
|
call_id?: string;
|
|
1370
|
-
answered_by?:
|
|
1603
|
+
answered_by?: AmdWireAnsweredBy;
|
|
1371
1604
|
answered_by_twilio?: string | null;
|
|
1372
1605
|
confidence?: number | null;
|
|
1373
1606
|
decision_ms?: number | null;
|
|
@@ -1490,14 +1723,71 @@ export class PyAI {
|
|
|
1490
1723
|
// --- voices -------------------------------------------------------------
|
|
1491
1724
|
|
|
1492
1725
|
voices = {
|
|
1493
|
-
list: (params: {
|
|
1726
|
+
list: async (params: {
|
|
1727
|
+
gender?: string;
|
|
1728
|
+
region?: string;
|
|
1729
|
+
language?: string;
|
|
1730
|
+
tier?: "standard" | "natural";
|
|
1731
|
+
q?: string;
|
|
1732
|
+
} = {}): Promise<ListResponse<Voice>> => {
|
|
1494
1733
|
const q = new URLSearchParams();
|
|
1495
1734
|
if (params.gender) q.set("gender", params.gender);
|
|
1496
1735
|
if (params.region) q.set("region", params.region);
|
|
1736
|
+
if (params.language) q.set("language", params.language);
|
|
1737
|
+
if (params.tier) q.set("tier", params.tier);
|
|
1738
|
+
if (params.q) q.set("q", params.q);
|
|
1497
1739
|
const qs = q.toString();
|
|
1498
|
-
|
|
1740
|
+
const page = await this.getJson<ListResponse<Record<string, unknown>>>(
|
|
1741
|
+
`/v1/voices${qs ? `?${qs}` : ""}`,
|
|
1742
|
+
);
|
|
1743
|
+
return { ...page, data: page.data.map(normalizeVoice) };
|
|
1744
|
+
},
|
|
1745
|
+
get: async (id: string): Promise<Voice> =>
|
|
1746
|
+
normalizeVoice(
|
|
1747
|
+
await this.getJson<Record<string, unknown>>(
|
|
1748
|
+
`/v1/voices/${encodeURIComponent(id)}`,
|
|
1749
|
+
),
|
|
1750
|
+
),
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
// --- Hear organization settings ----------------------------------------
|
|
1754
|
+
|
|
1755
|
+
hear = {
|
|
1756
|
+
vocabulary: {
|
|
1757
|
+
/** Read organization-owned vocabulary. Scope `hear:configure`. */
|
|
1758
|
+
get: (): Promise<HearVocabulary> =>
|
|
1759
|
+
this.getJson("/v1/hear/vocabulary"),
|
|
1760
|
+
/** Replace vocabulary and activation profiles. Scope `hear:configure`. */
|
|
1761
|
+
set: (input: HearVocabularyInput): Promise<HearVocabulary> =>
|
|
1762
|
+
this.putJson("/v1/hear/vocabulary", {
|
|
1763
|
+
terms: input.terms,
|
|
1764
|
+
enabled_for: input.enabledFor,
|
|
1765
|
+
}),
|
|
1766
|
+
},
|
|
1767
|
+
};
|
|
1768
|
+
|
|
1769
|
+
// --- managed Agents ----------------------------------------------------
|
|
1770
|
+
|
|
1771
|
+
agents = {
|
|
1772
|
+
list: (): Promise<ListResponse<Agent>> => this.getJson("/v1/agents"),
|
|
1773
|
+
get: (agentId: string): Promise<Agent> =>
|
|
1774
|
+
this.getJson(`/v1/agents/${encodeURIComponent(agentId)}`),
|
|
1775
|
+
create: (input: AgentConfig & { name: string }): Promise<Agent> =>
|
|
1776
|
+
this.postJson("/v1/agents", input),
|
|
1777
|
+
update: (agentId: string, patch: AgentConfig): Promise<Agent> =>
|
|
1778
|
+
this.postJson(`/v1/agents/${encodeURIComponent(agentId)}`, patch),
|
|
1779
|
+
delete: async (
|
|
1780
|
+
agentId: string,
|
|
1781
|
+
): Promise<{ object: "agent.deleted"; agent_id: string; deleted: boolean }> => {
|
|
1782
|
+
const response = await this.deleteReq(
|
|
1783
|
+
`/v1/agents/${encodeURIComponent(agentId)}`,
|
|
1784
|
+
);
|
|
1785
|
+
return (await response.json()) as {
|
|
1786
|
+
object: "agent.deleted";
|
|
1787
|
+
agent_id: string;
|
|
1788
|
+
deleted: boolean;
|
|
1789
|
+
};
|
|
1499
1790
|
},
|
|
1500
|
-
get: (id: string): Promise<Voice> => this.getJson(`/v1/voices/${encodeURIComponent(id)}`),
|
|
1501
1791
|
};
|
|
1502
1792
|
|
|
1503
1793
|
// --- audio --------------------------------------------------------------
|
|
@@ -1505,26 +1795,28 @@ export class PyAI {
|
|
|
1505
1795
|
audio = {
|
|
1506
1796
|
/** Text-to-speech. Returns the raw audio bytes (default WAV). */
|
|
1507
1797
|
speech: async (params: SpeechParams): Promise<ArrayBuffer> => {
|
|
1798
|
+
assertActiveSpeechParams(params);
|
|
1508
1799
|
const res = await this.request("/v1/audio/speech", {
|
|
1509
1800
|
method: "POST",
|
|
1510
1801
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
1511
|
-
body: JSON.stringify({ model: "pyai-
|
|
1802
|
+
body: JSON.stringify({ model: "pyai-speak", ...params }),
|
|
1512
1803
|
});
|
|
1513
1804
|
return res.arrayBuffer();
|
|
1514
1805
|
},
|
|
1515
1806
|
/**
|
|
1516
1807
|
* Text-to-speech, streamed. Resolves as soon as the response headers arrive
|
|
1517
1808
|
* with the body as a `ReadableStream` of audio bytes, so you can start
|
|
1518
|
-
* playback or forward the audio at the first chunk
|
|
1519
|
-
*
|
|
1520
|
-
*
|
|
1521
|
-
*
|
|
1809
|
+
* playback or forward the audio at the first chunk. Use `pcm`, `wav`,
|
|
1810
|
+
* or G.711 for streaming; `mp3` and `opus` are buffered server-side.
|
|
1811
|
+
* Consume the stream immediately and cancel its reader when stopping early.
|
|
1812
|
+
* HTTP connection reuse and protocol negotiation are controlled by fetch.
|
|
1522
1813
|
*/
|
|
1523
1814
|
speechStream: async (params: SpeechParams): Promise<ReadableStream<Uint8Array>> => {
|
|
1815
|
+
assertActiveSpeechParams(params);
|
|
1524
1816
|
const res = await this.request("/v1/audio/speech", {
|
|
1525
1817
|
method: "POST",
|
|
1526
1818
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
1527
|
-
body: JSON.stringify({ model: "pyai-
|
|
1819
|
+
body: JSON.stringify({ model: "pyai-speak", ...params, stream: true }),
|
|
1528
1820
|
});
|
|
1529
1821
|
if (!res.body) throw new PyAIError(res.status, "Response had no body to stream");
|
|
1530
1822
|
return res.body as ReadableStream<Uint8Array>;
|
|
@@ -1535,7 +1827,13 @@ export class PyAI {
|
|
|
1535
1827
|
file: Blob;
|
|
1536
1828
|
filename?: string;
|
|
1537
1829
|
model?: string;
|
|
1538
|
-
language
|
|
1830
|
+
/** Optional language hint. Omission enables automatic detection. */
|
|
1831
|
+
language?: "en" | "es" | "fr" | "de" | "hi" | "it" | "pt" | "nl";
|
|
1832
|
+
numerals?: boolean;
|
|
1833
|
+
smart_format?: boolean;
|
|
1834
|
+
dictation?: boolean;
|
|
1835
|
+
drop_fillers?: boolean;
|
|
1836
|
+
vocabulary?: string[];
|
|
1539
1837
|
response_format?: "json" | "text" | "verbose_json";
|
|
1540
1838
|
/**
|
|
1541
1839
|
* Deterministic seed for reproducible eval runs. Forward-compatible:
|
|
@@ -1549,6 +1847,11 @@ export class PyAI {
|
|
|
1549
1847
|
form.set("file", params.file, params.filename ?? "audio.wav");
|
|
1550
1848
|
form.set("model", params.model ?? "pyai-hear");
|
|
1551
1849
|
if (params.language) form.set("language", params.language);
|
|
1850
|
+
if (params.numerals !== undefined) form.set("numerals", String(params.numerals));
|
|
1851
|
+
if (params.smart_format !== undefined) form.set("smart_format", String(params.smart_format));
|
|
1852
|
+
if (params.dictation !== undefined) form.set("dictation", String(params.dictation));
|
|
1853
|
+
if (params.drop_fillers !== undefined) form.set("drop_fillers", String(params.drop_fillers));
|
|
1854
|
+
if (params.vocabulary?.length) form.set("vocabulary", params.vocabulary.join(","));
|
|
1552
1855
|
if (params.response_format) form.set("response_format", params.response_format);
|
|
1553
1856
|
if (params.seed !== undefined) form.set("seed", String(params.seed));
|
|
1554
1857
|
if (params.temperature !== undefined) form.set("temperature", String(params.temperature));
|
|
@@ -1607,7 +1910,7 @@ export class PyAI {
|
|
|
1607
1910
|
clones = {
|
|
1608
1911
|
/** List the org's cloned voices. */
|
|
1609
1912
|
list: (): Promise<ListResponse<Voice>> => this.getJson("/v1/voice/clones"),
|
|
1610
|
-
/** Enroll a custom voice from reference audio (>= ~10s). Scope `
|
|
1913
|
+
/** Enroll a custom voice from reference audio (>= ~10s). Scope `speak:clone`. */
|
|
1611
1914
|
create: async (params: { name: string; file: Blob; filename?: string }): Promise<Voice> => {
|
|
1612
1915
|
const form = new FormData();
|
|
1613
1916
|
form.set("name", params.name);
|
|
@@ -1626,7 +1929,7 @@ export class PyAI {
|
|
|
1626
1929
|
if (!match) throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
|
|
1627
1930
|
return match;
|
|
1628
1931
|
},
|
|
1629
|
-
/** Delete a cloned voice (tenant-isolated). Scope `
|
|
1932
|
+
/** Delete a cloned voice (tenant-isolated). Scope `speak:clone`. */
|
|
1630
1933
|
delete: async (id: string): Promise<void> => {
|
|
1631
1934
|
await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
|
|
1632
1935
|
},
|
|
@@ -1823,7 +2126,7 @@ export class PyAI {
|
|
|
1823
2126
|
* realtime. **Call this from your server** with a secret key holding
|
|
1824
2127
|
* `omni:session`; never ship the secret key to a page. Hand the returned
|
|
1825
2128
|
* `token` to the browser, which connects with
|
|
1826
|
-
* `new WebSocket(session.url, ["pyai-key." + session.token])`. The token
|
|
2129
|
+
* `new WebSocket(session.url, ["pyai.v1", "pyai-key." + session.token])`. The token
|
|
1827
2130
|
* expires after `ttlSeconds` (default 60s) and only works from
|
|
1828
2131
|
* `allowedOrigins`. Scope `omni:session`.
|
|
1829
2132
|
*/
|
|
@@ -1847,7 +2150,7 @@ export class PyAI {
|
|
|
1847
2150
|
const query: Record<string, string> = { ...(opts.query ?? {}) };
|
|
1848
2151
|
if (opts.format) query.format = opts.format;
|
|
1849
2152
|
if (opts.rate) query.rate = String(opts.rate);
|
|
1850
|
-
const url = this.realtimeURL({
|
|
2153
|
+
const url = this.realtimeURL({ sessionLabel: opts.sessionLabel, query });
|
|
1851
2154
|
const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
|
|
1852
2155
|
return new OmniConnection(url, sub, opts);
|
|
1853
2156
|
},
|
|
@@ -1855,41 +2158,64 @@ export class PyAI {
|
|
|
1855
2158
|
|
|
1856
2159
|
// --- realtime (WebSocket) ----------------------------------------------
|
|
1857
2160
|
|
|
1858
|
-
/** Build the
|
|
2161
|
+
/** Build the canonical Omni WebSocket URL. */
|
|
1859
2162
|
realtimeURL(opts: RealtimeOptions = {}): string {
|
|
1860
2163
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
1861
2164
|
const q = new URLSearchParams(opts.query ?? {});
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
if (!q.has("rate")) q.set("rate", "24000");
|
|
1871
|
-
const qs = q.toString();
|
|
1872
|
-
return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
|
|
2165
|
+
// format/rate are load-bearing on the connect URL, so default to
|
|
2166
|
+
// browser-grade PCM16/24kHz.
|
|
2167
|
+
for (const key of ["agent", "agent_id", "agentId", "model", "access_token"]) {
|
|
2168
|
+
if (q.has(key)) {
|
|
2169
|
+
throw new Error(
|
|
2170
|
+
`Omni query parameter "${key}" is not supported; use sessionLabel, format/rate, or api_key`,
|
|
2171
|
+
);
|
|
2172
|
+
}
|
|
1873
2173
|
}
|
|
1874
|
-
q.set("
|
|
1875
|
-
|
|
2174
|
+
if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
|
|
2175
|
+
if (!q.has("format")) q.set("format", "pcm16");
|
|
2176
|
+
if (!q.has("rate")) q.set("rate", "24000");
|
|
2177
|
+
const qs = q.toString();
|
|
2178
|
+
return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
|
|
1876
2179
|
}
|
|
1877
2180
|
|
|
1878
|
-
/** The subprotocol that carries the key on a WS upgrade
|
|
2181
|
+
/** The subprotocol token that carries the key on a WS upgrade. */
|
|
1879
2182
|
realtimeSubprotocol(): string {
|
|
1880
2183
|
return `pyai-key.${this.apiKey}`;
|
|
1881
2184
|
}
|
|
1882
2185
|
|
|
2186
|
+
/**
|
|
2187
|
+
* The full subprotocol list to open a PyAI WebSocket with:
|
|
2188
|
+
* `[marker, credential]`. Always pass BOTH — see
|
|
2189
|
+
* {@link REALTIME_SUBPROTOCOL_MARKER}. Pass `token` to use an ephemeral
|
|
2190
|
+
* session token (from `omni.createSession`) instead of the secret key.
|
|
2191
|
+
*/
|
|
2192
|
+
realtimeSubprotocols(token?: string): string[] {
|
|
2193
|
+
return [
|
|
2194
|
+
REALTIME_SUBPROTOCOL_MARKER,
|
|
2195
|
+
token ? `pyai-key.${token}` : this.realtimeSubprotocol(),
|
|
2196
|
+
];
|
|
2197
|
+
}
|
|
2198
|
+
|
|
1883
2199
|
/** Build the Hear streaming-STT WebSocket URL (`/v1/audio/transcriptions/stream`). */
|
|
1884
2200
|
hearStreamURL(opts: HearStreamOptions = {}): string {
|
|
1885
2201
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
1886
2202
|
const q = new URLSearchParams(opts.query ?? {});
|
|
2203
|
+
// `context` is private to PyAI's adapter-to-server hop. Never expose or
|
|
2204
|
+
// forward it from the public SDK escape hatch.
|
|
2205
|
+
q.delete("context");
|
|
2206
|
+
q.set("protocol", "pyai-hear-v1");
|
|
1887
2207
|
if (opts.model) q.set("model", opts.model);
|
|
1888
2208
|
if (opts.language) q.set("language", opts.language);
|
|
1889
2209
|
if (opts.sampleRate !== undefined) q.set("sample_rate", String(opts.sampleRate));
|
|
1890
2210
|
if (opts.encoding) q.set("encoding", opts.encoding);
|
|
1891
2211
|
if (opts.interimResults !== undefined) q.set("interim_results", String(opts.interimResults));
|
|
1892
2212
|
if (opts.numerals !== undefined) q.set("numerals", String(opts.numerals));
|
|
2213
|
+
if (opts.smartFormat !== undefined) q.set("smart_format", String(opts.smartFormat));
|
|
2214
|
+
if (opts.dictation !== undefined) q.set("dictation", String(opts.dictation));
|
|
2215
|
+
if (opts.dropFillers !== undefined) q.set("drop_fillers", String(opts.dropFillers));
|
|
2216
|
+
if (opts.vocabulary?.length) {
|
|
2217
|
+
q.set("vocabulary", JSON.stringify(opts.vocabulary));
|
|
2218
|
+
}
|
|
1893
2219
|
if (opts.endpointingMs !== undefined) q.set("endpointing_ms", String(opts.endpointingMs));
|
|
1894
2220
|
const qs = q.toString();
|
|
1895
2221
|
return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
|
|
@@ -1912,8 +2238,8 @@ export class PyAI {
|
|
|
1912
2238
|
*/
|
|
1913
2239
|
connectRealtime(opts: RealtimeOptions = {}): WebSocket {
|
|
1914
2240
|
const WS = (globalThis as { WebSocket?: typeof WebSocket }).WebSocket;
|
|
1915
|
-
if (!WS) throw new Error("No global WebSocket; use realtimeURL()/
|
|
1916
|
-
return new WS(this.realtimeURL(opts),
|
|
2241
|
+
if (!WS) throw new Error("No global WebSocket; use realtimeURL()/realtimeSubprotocols() with a WS library");
|
|
2242
|
+
return new WS(this.realtimeURL(opts), this.realtimeSubprotocols());
|
|
1917
2243
|
}
|
|
1918
2244
|
}
|
|
1919
2245
|
|