@pyai/sdk 0.2.2 → 0.3.1
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 +35 -19
- package/dist/index.d.ts +108 -43
- package/dist/index.js +179 -70
- package/package.json +18 -3
- package/src/index.ts +292 -102
package/src/index.ts
CHANGED
|
@@ -34,6 +34,9 @@ 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;
|
|
@@ -41,6 +44,11 @@ export interface Voice {
|
|
|
41
44
|
[k: string]: unknown;
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
function normalizeVoice(value: Record<string, unknown>): Voice {
|
|
48
|
+
const voiceId = String(value.voice_id ?? value.id ?? "");
|
|
49
|
+
return { ...value, voice_id: voiceId, id: voiceId } as Voice;
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
export interface ListResponse<T> {
|
|
45
53
|
object: "list";
|
|
46
54
|
data: T[];
|
|
@@ -62,13 +70,13 @@ export interface TranscriptionJob {
|
|
|
62
70
|
* Output container/codec for `audio.speech`. This is the **exact** set the
|
|
63
71
|
* server accepts on `POST /v1/audio/speech`, any other value is rejected with
|
|
64
72
|
* `400 unsupported_format`. The default (when `response_format` is omitted) is
|
|
65
|
-
* `
|
|
73
|
+
* `wav`. Omit `sample_rate` for the engine's native 24 kHz (`g711_*` is always
|
|
66
74
|
* 8 kHz).
|
|
67
75
|
*
|
|
68
76
|
* | format | rates (Hz) | Content-Type |
|
|
69
77
|
* |---|---|---|
|
|
70
|
-
* | `
|
|
71
|
-
* | `
|
|
78
|
+
* | `wav` (default) | 8000/16000/24000/48000 | `audio/wav` |
|
|
79
|
+
* | `mp3` | 8000/16000/24000/48000 | `audio/mpeg` |
|
|
72
80
|
* | `opus` | 8000/16000/24000/48000 | `audio/ogg` |
|
|
73
81
|
* | `aac` | 8000/16000/24000/48000 | `audio/aac` |
|
|
74
82
|
* | `flac` | 8000/16000/24000/48000 | `audio/flac` |
|
|
@@ -89,14 +97,17 @@ export const SPEECH_FORMATS = ["wav", "mp3", "opus", "aac", "flac", "pcm", "g711
|
|
|
89
97
|
export const SPEECH_SAMPLE_RATES = [8000, 16000, 24000, 48000] as const;
|
|
90
98
|
export type SpeechSampleRate = (typeof SPEECH_SAMPLE_RATES)[number];
|
|
91
99
|
|
|
100
|
+
/** Canonical Speak model plus the intentional OpenAI drop-in aliases. */
|
|
101
|
+
export type SpeakModel = "pyai-speak" | "tts-1" | "tts-1-hd";
|
|
102
|
+
|
|
92
103
|
export interface SpeechParams {
|
|
93
104
|
input: string;
|
|
94
105
|
voice?: string;
|
|
95
|
-
model?:
|
|
106
|
+
model?: SpeakModel;
|
|
96
107
|
/**
|
|
97
108
|
* Output container/codec, resampled+encoded server-side. One of
|
|
98
109
|
* {@link SpeechFormat}, anything else is a `400 unsupported_format`. Omit for
|
|
99
|
-
* the default of `
|
|
110
|
+
* the default of `wav`.
|
|
100
111
|
*
|
|
101
112
|
* `g711_ulaw`/`g711_alaw` return raw 8 kHz mono G.711, the bytes Twilio/SIP
|
|
102
113
|
* media streams expect, so you can hand the response straight to a telephony
|
|
@@ -113,19 +124,26 @@ export interface SpeechParams {
|
|
|
113
124
|
* telephony pipelines, most often with `response_format: "pcm"`.
|
|
114
125
|
*/
|
|
115
126
|
sample_rate?: SpeechSampleRate;
|
|
127
|
+
/** Reserved; currently returns `400 unsupported_parameter` when provided. */
|
|
116
128
|
speed?: number;
|
|
117
129
|
/**
|
|
118
|
-
*
|
|
119
|
-
* honored once the engine supports it (otherwise ignored server-side), so it's
|
|
120
|
-
* always safe to send.
|
|
130
|
+
* Reserved; currently returns `400 unsupported_parameter` when provided.
|
|
121
131
|
*/
|
|
122
132
|
seed?: number;
|
|
123
133
|
/**
|
|
124
|
-
*
|
|
134
|
+
* Reserved; currently returns `400 unsupported_parameter` when provided.
|
|
125
135
|
*/
|
|
126
136
|
temperature?: number;
|
|
127
137
|
}
|
|
128
138
|
|
|
139
|
+
function assertActiveSpeechParams(params: SpeechParams): void {
|
|
140
|
+
for (const field of ["speed", "seed", "temperature"] as const) {
|
|
141
|
+
if (params[field] !== undefined) {
|
|
142
|
+
throw new Error(`${field} is reserved but not active on Speak`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
129
147
|
export interface CreateJobParams {
|
|
130
148
|
audio_url: string;
|
|
131
149
|
model?: string;
|
|
@@ -137,8 +155,6 @@ export interface CreateJobParams {
|
|
|
137
155
|
}
|
|
138
156
|
|
|
139
157
|
export interface RealtimeOptions {
|
|
140
|
-
/** "omni" (agentic voice) or "flow" (legacy voice duplex). Default "omni". */
|
|
141
|
-
product?: "omni" | "flow";
|
|
142
158
|
/**
|
|
143
159
|
* Optional opaque tag echoed to your `kb_endpoint` and recorded on the call.
|
|
144
160
|
* Omni is zero-state: the session is authorized by the key's org, so there is
|
|
@@ -146,12 +162,10 @@ export interface RealtimeOptions {
|
|
|
146
162
|
*/
|
|
147
163
|
sessionLabel?: string;
|
|
148
164
|
/**
|
|
149
|
-
*
|
|
150
|
-
* `
|
|
151
|
-
*
|
|
165
|
+
* Extra canonical query params (`format`, `rate`, or the intentional
|
|
166
|
+
* server-side `api_key` auth option). Retired connect aliases and model/token
|
|
167
|
+
* selectors are rejected.
|
|
152
168
|
*/
|
|
153
|
-
agentId?: string;
|
|
154
|
-
/** Extra query params (e.g. format, rate). */
|
|
155
169
|
query?: Record<string, string>;
|
|
156
170
|
}
|
|
157
171
|
|
|
@@ -195,6 +209,8 @@ export interface OmniSession {
|
|
|
195
209
|
|
|
196
210
|
/** Frame `type`s emitted by the Hear streaming-STT WebSocket. */
|
|
197
211
|
export const HearFrameType = {
|
|
212
|
+
/** Applied endpointing configuration and validation warnings. */
|
|
213
|
+
ConfigAck: "config_ack",
|
|
198
214
|
/** Eager live hypothesis for the current utterance. */
|
|
199
215
|
Partial: "partial",
|
|
200
216
|
/** Partial whose prefix has stabilized (won't be revised). */
|
|
@@ -232,7 +248,7 @@ export const ErrorCode = {
|
|
|
232
248
|
Unauthorized: "unauthorized",
|
|
233
249
|
Forbidden: "forbidden",
|
|
234
250
|
OriginNotAllowed: "origin_not_allowed",
|
|
235
|
-
|
|
251
|
+
InvalidSessionLabel: "invalid_session_label",
|
|
236
252
|
CreditExhausted: "credit_exhausted",
|
|
237
253
|
KeyBudgetExceeded: "key_budget_exceeded",
|
|
238
254
|
InsufficientQuota: "insufficient_quota",
|
|
@@ -255,6 +271,24 @@ export interface HearGroundingPassage {
|
|
|
255
271
|
score: number;
|
|
256
272
|
}
|
|
257
273
|
|
|
274
|
+
/** One endpointing validation result echoed in a `config_ack` frame. */
|
|
275
|
+
export interface HearConfigWarning {
|
|
276
|
+
field: string;
|
|
277
|
+
value: unknown;
|
|
278
|
+
effective?: number;
|
|
279
|
+
reason: "clamped_to_range" | "not_a_number" | "unknown_config_field" | (string & {});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Applied endpointing settings after connect-time or mid-session config. */
|
|
283
|
+
export interface HearConfigAckFrame {
|
|
284
|
+
type: "config_ack";
|
|
285
|
+
endpointing_ms: number;
|
|
286
|
+
effective_floor_ms: number;
|
|
287
|
+
effective_ceiling_ms: number;
|
|
288
|
+
score_interval_ms: number;
|
|
289
|
+
warnings: HearConfigWarning[];
|
|
290
|
+
}
|
|
291
|
+
|
|
258
292
|
/** Live hypothesis frame (`partial` / `partial_stable`). */
|
|
259
293
|
export interface HearPartialFrame {
|
|
260
294
|
type: "partial" | "partial_stable";
|
|
@@ -269,6 +303,12 @@ export interface HearPartialFrame {
|
|
|
269
303
|
}
|
|
270
304
|
|
|
271
305
|
/** Finalized-utterance frame (`speech_final` / `final`). */
|
|
306
|
+
export type HearEndpointReason =
|
|
307
|
+
| "peak_te_early"
|
|
308
|
+
| "silence_backstop"
|
|
309
|
+
| "commit"
|
|
310
|
+
| (string & {});
|
|
311
|
+
|
|
272
312
|
export interface HearFinalFrame {
|
|
273
313
|
type: "speech_final" | "final";
|
|
274
314
|
text: string;
|
|
@@ -276,6 +316,8 @@ export interface HearFinalFrame {
|
|
|
276
316
|
t_ms: number;
|
|
277
317
|
/** Active-speech length of the utterance (the billed signal), ms. */
|
|
278
318
|
audio_ms: number;
|
|
319
|
+
/** Why the utterance ended. Log this when tuning automatic endpointing. */
|
|
320
|
+
endpoint_reason: HearEndpointReason;
|
|
279
321
|
/** Present only with Cue grounding enabled (top KB passages). */
|
|
280
322
|
grounding?: HearGroundingPassage[];
|
|
281
323
|
}
|
|
@@ -288,7 +330,7 @@ export interface HearUsageFrame {
|
|
|
288
330
|
type: "usage";
|
|
289
331
|
/** `hear` for plain streaming, `cue` when grounding was enabled. */
|
|
290
332
|
product: "hear" | "cue";
|
|
291
|
-
/** The billed meter (`hear.
|
|
333
|
+
/** The billed meter (`hear.minutes` or `cue.minutes`). */
|
|
292
334
|
meter: string;
|
|
293
335
|
/** Summed active-speech audio billed for the session, in seconds. */
|
|
294
336
|
audio_seconds: number;
|
|
@@ -303,7 +345,12 @@ export interface HearErrorFrame {
|
|
|
303
345
|
message: string;
|
|
304
346
|
}
|
|
305
347
|
|
|
306
|
-
export type HearFrame =
|
|
348
|
+
export type HearFrame =
|
|
349
|
+
| HearConfigAckFrame
|
|
350
|
+
| HearPartialFrame
|
|
351
|
+
| HearFinalFrame
|
|
352
|
+
| HearUsageFrame
|
|
353
|
+
| HearErrorFrame;
|
|
307
354
|
|
|
308
355
|
/**
|
|
309
356
|
* Minimal structural WebSocket, matches both the browser/Node global
|
|
@@ -324,8 +371,12 @@ export type WebSocketCtor = new (url: string, protocols?: string | string[]) =>
|
|
|
324
371
|
export interface HearStreamOptions {
|
|
325
372
|
/** Streaming STT model. Server default `pyai-hear`. */
|
|
326
373
|
model?: string;
|
|
327
|
-
/**
|
|
328
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Hear is English-only. Set `"en"` explicitly; omission also means English
|
|
376
|
+
* and does not enable language detection. Other values receive
|
|
377
|
+
* `400 unsupported_language`.
|
|
378
|
+
*/
|
|
379
|
+
language?: "en";
|
|
329
380
|
/** Input PCM sample rate in Hz. Default 16000 server-side. */
|
|
330
381
|
sampleRate?: number;
|
|
331
382
|
/** Audio frame encoding. Default "pcm16". */
|
|
@@ -339,16 +390,14 @@ export interface HearStreamOptions {
|
|
|
339
390
|
*/
|
|
340
391
|
numerals?: boolean;
|
|
341
392
|
/**
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* {@link HearStream.commit} for full control today.
|
|
393
|
+
* Minimum trailing-pause length before an utterance may end (50-5000 ms).
|
|
394
|
+
* Turn detection may wait longer, bounded at `max(endpointingMs, 1500)`.
|
|
395
|
+
* The server confirms the applied value through `onConfigAck`.
|
|
346
396
|
*/
|
|
347
397
|
endpointingMs?: number;
|
|
348
398
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
* array. Bills a single `cue.minutes` line instead of the Hear rate.
|
|
399
|
+
* Reserved Cue grounding configuration. Grounding is not active on the
|
|
400
|
+
* serving Hear stream; do not rely on grounding frames or Cue metering yet.
|
|
352
401
|
*/
|
|
353
402
|
grounding?: boolean;
|
|
354
403
|
/** Cue: number of KB passages to retrieve per turn (1-20, default 3). */
|
|
@@ -360,8 +409,11 @@ export interface HearStreamOptions {
|
|
|
360
409
|
groundingTimeoutMs?: number;
|
|
361
410
|
/** Extra query params merged onto the connect URL. */
|
|
362
411
|
query?: Record<string, string>;
|
|
363
|
-
/** Fired once the socket opens
|
|
412
|
+
/** Fired once the socket opens. */
|
|
364
413
|
onOpen?: () => void;
|
|
414
|
+
/** Fired after connect-time or mid-session endpointing config. Assert that
|
|
415
|
+
* `warnings` is empty before relying on the requested floor. */
|
|
416
|
+
onConfigAck?: (frame: HearConfigAckFrame) => void;
|
|
365
417
|
/** Fired on `partial` / `partial_stable`. */
|
|
366
418
|
onPartial?: (frame: HearPartialFrame) => void;
|
|
367
419
|
/** Fired on `speech_final` / `final`. */
|
|
@@ -379,8 +431,10 @@ export interface HearStreamOptions {
|
|
|
379
431
|
/**
|
|
380
432
|
* A live Hear streaming-STT session. Hides the frame protocol: stream audio
|
|
381
433
|
* with {@link HearStream.sendAudio}, get `onPartial`/`onFinal`/`onError`
|
|
382
|
-
* callbacks,
|
|
383
|
-
*
|
|
434
|
+
* callbacks, update the silence floor with
|
|
435
|
+
* {@link HearStream.configureEndpointing}, force-finalize with
|
|
436
|
+
* {@link HearStream.commit}, and flush+close with {@link HearStream.close}.
|
|
437
|
+
* Construct via `pyai.audio.transcriptions.stream()`.
|
|
384
438
|
*/
|
|
385
439
|
export class HearStream {
|
|
386
440
|
private readonly ws: WebSocketLike;
|
|
@@ -389,6 +443,11 @@ export class HearStream {
|
|
|
389
443
|
|
|
390
444
|
constructor(url: string, subprotocol: string, opts: HearStreamOptions) {
|
|
391
445
|
this.opts = opts;
|
|
446
|
+
if (opts.grounding) {
|
|
447
|
+
throw new Error(
|
|
448
|
+
"Cue grounding is not active on the serving Hear stream; omit grounding until the API reference marks it active",
|
|
449
|
+
);
|
|
450
|
+
}
|
|
392
451
|
const WS = opts.webSocket ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket;
|
|
393
452
|
if (!WS) {
|
|
394
453
|
throw new Error(
|
|
@@ -397,17 +456,6 @@ export class HearStream {
|
|
|
397
456
|
}
|
|
398
457
|
this.ws = new WS(url, [subprotocol]);
|
|
399
458
|
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
459
|
opts.onOpen?.();
|
|
412
460
|
};
|
|
413
461
|
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
@@ -429,6 +477,9 @@ export class HearStream {
|
|
|
429
477
|
return;
|
|
430
478
|
}
|
|
431
479
|
switch (frame.type) {
|
|
480
|
+
case HearFrameType.ConfigAck:
|
|
481
|
+
this.opts.onConfigAck?.(frame);
|
|
482
|
+
break;
|
|
432
483
|
case HearFrameType.Partial:
|
|
433
484
|
case HearFrameType.PartialStable:
|
|
434
485
|
this.opts.onPartial?.(frame);
|
|
@@ -454,6 +505,11 @@ export class HearStream {
|
|
|
454
505
|
this.ws.send(chunk);
|
|
455
506
|
}
|
|
456
507
|
|
|
508
|
+
/** Change the minimum trailing-pause floor without reconnecting. */
|
|
509
|
+
configureEndpointing(endpointingMs: number): void {
|
|
510
|
+
this.ws.send(JSON.stringify({ type: "config", endpointing_ms: endpointingMs }));
|
|
511
|
+
}
|
|
512
|
+
|
|
457
513
|
/** Force-finalize the current utterance (e.g. on VAD end-of-turn). */
|
|
458
514
|
commit(): void {
|
|
459
515
|
this.ws.send(JSON.stringify({ type: "commit" }));
|
|
@@ -586,6 +642,88 @@ export interface OmniServerFrame {
|
|
|
586
642
|
[k: string]: unknown;
|
|
587
643
|
}
|
|
588
644
|
|
|
645
|
+
/** Canonical, sanitized transcript delivered by the native Omni demux.
|
|
646
|
+
* The live wire sends caller text deltas; the JSON fields are SDK-owned
|
|
647
|
+
* normalization so applications don't have to special-case the byte payload. */
|
|
648
|
+
export interface OmniTranscriptFrame extends OmniServerFrame {
|
|
649
|
+
event: "transcript";
|
|
650
|
+
role: "user" | "assistant";
|
|
651
|
+
text: string;
|
|
652
|
+
final: boolean;
|
|
653
|
+
/** Live text frames append; legacy JSON frames replace unless they use `delta`. */
|
|
654
|
+
mode: "delta" | "replace";
|
|
655
|
+
/** Optional ordering hint on legacy JSON frames. */
|
|
656
|
+
sequence?: number;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
|
|
660
|
+
const OMNI_TRANSCRIPT_MAX_CHARS = 4_000;
|
|
661
|
+
|
|
662
|
+
function omniTranscriptText(value: unknown): string | null {
|
|
663
|
+
return typeof value === "string"
|
|
664
|
+
&& value.length > 0
|
|
665
|
+
&& value.length <= OMNI_TRANSCRIPT_MAX_CHARS
|
|
666
|
+
&& !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
|
|
667
|
+
? value
|
|
668
|
+
: null;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function omniTranscriptRole(value: unknown): "user" | "assistant" | null {
|
|
672
|
+
if (value === "user" || value === "caller" || value === "human") return "user";
|
|
673
|
+
if (value === "assistant" || value === "agent") return "assistant";
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Normalize the live UTF-8 `0x02` text body and bounded legacy JSON bodies. */
|
|
678
|
+
export function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFrame | null {
|
|
679
|
+
if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES) return null;
|
|
680
|
+
let decoded: string;
|
|
681
|
+
try {
|
|
682
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
683
|
+
} catch {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
// The serving engine's canonical payload is plain UTF-8 caller text, not
|
|
687
|
+
// JSON. Each frame is a delta for the current caller turn.
|
|
688
|
+
if (!decoded.trimStart().startsWith("{")) {
|
|
689
|
+
const text = omniTranscriptText(decoded);
|
|
690
|
+
return text
|
|
691
|
+
? { event: "transcript", role: "user", text, final: false, mode: "delta" }
|
|
692
|
+
: null;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Keep accepting a bounded direct object for older bridges and recordings.
|
|
696
|
+
let value: unknown;
|
|
697
|
+
try {
|
|
698
|
+
value = JSON.parse(decoded);
|
|
699
|
+
} catch {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
703
|
+
const payload = value as Record<string, unknown>;
|
|
704
|
+
if (payload.type !== undefined
|
|
705
|
+
|| (payload.event !== undefined && payload.event !== "transcript")) return null;
|
|
706
|
+
const role = omniTranscriptRole(payload.role ?? payload.speaker);
|
|
707
|
+
const mode = typeof payload.delta === "string" ? "delta" : "replace";
|
|
708
|
+
const text = omniTranscriptText(
|
|
709
|
+
mode === "delta" ? payload.delta : typeof payload.text === "string" ? payload.text : payload.transcript,
|
|
710
|
+
);
|
|
711
|
+
if (!role || !text) return null;
|
|
712
|
+
if (payload.final !== undefined && typeof payload.final !== "boolean") return null;
|
|
713
|
+
if (payload.sequence !== undefined
|
|
714
|
+
&& (typeof payload.sequence !== "number"
|
|
715
|
+
|| !Number.isSafeInteger(payload.sequence)
|
|
716
|
+
|| payload.sequence < 0)) return null;
|
|
717
|
+
return {
|
|
718
|
+
event: "transcript",
|
|
719
|
+
role,
|
|
720
|
+
text,
|
|
721
|
+
final: payload.final === true,
|
|
722
|
+
mode,
|
|
723
|
+
...(payload.sequence === undefined ? {} : { sequence: payload.sequence as number }),
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
589
727
|
/** A binary agent-audio chunk delivered to {@link OmniConnectOptions.onAudio}. */
|
|
590
728
|
export type OmniAudioChunk = ArrayBuffer | ArrayBufferView | Blob;
|
|
591
729
|
|
|
@@ -629,7 +767,7 @@ export interface OmniToolDef {
|
|
|
629
767
|
}
|
|
630
768
|
|
|
631
769
|
export interface OmniToolCallFrame {
|
|
632
|
-
|
|
770
|
+
event: "tool_call";
|
|
633
771
|
call_id: string;
|
|
634
772
|
name: string;
|
|
635
773
|
arguments?: Record<string, unknown>;
|
|
@@ -655,7 +793,7 @@ export interface OmniConfigure {
|
|
|
655
793
|
/**
|
|
656
794
|
* Session language, end to end (recognition, reasoning, voice). Also
|
|
657
795
|
* settable on the agent profile (`language` on `POST /v1/agents`), which
|
|
658
|
-
* applies automatically when
|
|
796
|
+
* applies automatically when `session_label` is the saved profile id;
|
|
659
797
|
* an inline value here wins for the session. Default `en`. Fail-safe: an
|
|
660
798
|
* unknown/not-yet-enabled language falls back to `en` (the `configured`
|
|
661
799
|
* ack carries `language_active` + `language_fallback: true`), the call
|
|
@@ -678,7 +816,11 @@ export interface OmniConnectOptions {
|
|
|
678
816
|
* so a page never holds a secret key.
|
|
679
817
|
*/
|
|
680
818
|
token?: string;
|
|
681
|
-
/**
|
|
819
|
+
/**
|
|
820
|
+
* Caller-input sample rate. `24000` and `16000` sessions receive agent audio
|
|
821
|
+
* at 24 kHz; `8000` sessions receive 8 kHz. Read `hello.audio_out` rather than
|
|
822
|
+
* assuming output matches this value.
|
|
823
|
+
*/
|
|
682
824
|
rate?: 24000 | 16000 | 8000;
|
|
683
825
|
/** Connect-URL audio format. Default `pcm16`. */
|
|
684
826
|
format?: "pcm16";
|
|
@@ -701,7 +843,7 @@ export interface OmniConnectOptions {
|
|
|
701
843
|
onSessionStarted?: (frame: OmniServerFrame) => void;
|
|
702
844
|
/** Fired on `turn` boundaries. */
|
|
703
845
|
onTurn?: (frame: OmniServerFrame) => void;
|
|
704
|
-
/** Fired
|
|
846
|
+
/** Fired for each normalized caller-transcript delta (`0x02` plain UTF-8 live). */
|
|
705
847
|
onTranscript?: (frame: OmniServerFrame) => void;
|
|
706
848
|
/** Fired on `barge_in` / `flush` (user interrupted). */
|
|
707
849
|
onBargeIn?: (frame: OmniServerFrame) => void;
|
|
@@ -741,6 +883,8 @@ export class OmniConnection {
|
|
|
741
883
|
private closed = false;
|
|
742
884
|
/** Serializes async Blob reads in {@link sendAudio} so frames stay ordered. */
|
|
743
885
|
private blobTail: Promise<void> = Promise.resolve();
|
|
886
|
+
/** Serializes inbound Blob decoding so browser frames stay ordered. */
|
|
887
|
+
private inboundTail: Promise<void> = Promise.resolve();
|
|
744
888
|
|
|
745
889
|
constructor(url: string, subprotocol: string, opts: OmniConnectOptions) {
|
|
746
890
|
this.opts = opts;
|
|
@@ -761,7 +905,16 @@ export class OmniConnection {
|
|
|
761
905
|
}
|
|
762
906
|
opts.onOpen?.();
|
|
763
907
|
};
|
|
764
|
-
this.ws.onmessage = (ev) =>
|
|
908
|
+
this.ws.onmessage = (ev) => {
|
|
909
|
+
const reportDecodeError = (error: unknown) => {
|
|
910
|
+
opts.onError?.(error instanceof Error ? error : new Error("Could not decode Omni frame"));
|
|
911
|
+
};
|
|
912
|
+
if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
|
|
913
|
+
this.inboundTail = this.inboundTail.then(() => this.handleMessage(ev.data)).catch(reportDecodeError);
|
|
914
|
+
} else {
|
|
915
|
+
void this.handleMessage(ev.data).catch(reportDecodeError);
|
|
916
|
+
}
|
|
917
|
+
};
|
|
765
918
|
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
766
919
|
this.ws.onclose = (ev) => {
|
|
767
920
|
this.closed = true;
|
|
@@ -769,15 +922,17 @@ export class OmniConnection {
|
|
|
769
922
|
};
|
|
770
923
|
}
|
|
771
924
|
|
|
772
|
-
private handleMessage(data: unknown): void {
|
|
925
|
+
private async handleMessage(data: unknown): Promise<void> {
|
|
773
926
|
// Server → client binary frames are TYPE-TAGGED by their first byte:
|
|
774
|
-
// 0x01 = agent audio (PCM16) · 0x02 = transcript
|
|
927
|
+
// 0x01 = agent audio (PCM16) · 0x02 = transcript UTF-8 · 0x03 = control JSON.
|
|
775
928
|
// (Treating every binary frame as audio, the old behavior, plays the
|
|
776
929
|
// 0x03/0x02 frames as a glitch and drops every event/transcript.)
|
|
777
930
|
if (typeof data !== "string") {
|
|
778
|
-
const bytes =
|
|
931
|
+
const bytes = typeof Blob !== "undefined" && data instanceof Blob
|
|
932
|
+
? new Uint8Array(await data.arrayBuffer())
|
|
933
|
+
: omniToBytes(data);
|
|
779
934
|
if (!bytes) {
|
|
780
|
-
this.opts.
|
|
935
|
+
this.opts.onError?.(new Error("Unsupported Omni binary frame"));
|
|
781
936
|
return;
|
|
782
937
|
}
|
|
783
938
|
const tag = bytes[0];
|
|
@@ -785,36 +940,39 @@ export class OmniConnection {
|
|
|
785
940
|
this.opts.onAudio?.(bytes.slice(1) as OmniAudioChunk); // copy → aligned PCM16
|
|
786
941
|
return;
|
|
787
942
|
}
|
|
788
|
-
if (tag === 0x02
|
|
943
|
+
if (tag === 0x02) {
|
|
944
|
+
const transcript = normalizeOmniTranscriptBody(bytes.subarray(1));
|
|
945
|
+
if (transcript) this.dispatchFrame(transcript);
|
|
946
|
+
else this.opts.onError?.(new Error("Unparseable Omni transcript frame"));
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if (tag === 0x03) {
|
|
789
950
|
try {
|
|
790
|
-
const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1))) as OmniServerFrame;
|
|
791
|
-
|
|
792
|
-
|
|
951
|
+
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(1))) as OmniServerFrame;
|
|
952
|
+
if (parsed?.event === OmniEvent.Transcript) {
|
|
953
|
+
this.opts.onError?.(new Error("Omni transcript events must use a binary 0x02 frame"));
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
this.dispatchFrame(parsed);
|
|
793
957
|
} catch {
|
|
794
958
|
this.opts.onError?.(new Error("Unparseable Omni binary frame"));
|
|
795
959
|
}
|
|
796
960
|
return;
|
|
797
961
|
}
|
|
798
|
-
|
|
962
|
+
const tagName = tag === undefined ? "empty" : `0x${tag.toString(16).padStart(2, "0")}`;
|
|
963
|
+
this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
|
|
799
964
|
return;
|
|
800
965
|
}
|
|
801
|
-
|
|
802
|
-
try {
|
|
803
|
-
this.dispatchFrame(JSON.parse(data) as OmniServerFrame);
|
|
804
|
-
} catch {
|
|
805
|
-
this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
|
|
806
|
-
}
|
|
966
|
+
this.opts.onError?.(new Error("Unexpected Omni text frame; server frames must use binary 0x01/0x02/0x03 tags"));
|
|
807
967
|
}
|
|
808
968
|
|
|
809
969
|
private dispatchFrame(frame: OmniServerFrame): void {
|
|
810
|
-
const
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
: "";
|
|
817
|
-
this.opts.onEvent?.({ ...frame, event: eventName });
|
|
970
|
+
const eventName = (frame as { event?: unknown }).event;
|
|
971
|
+
if (typeof eventName !== "string" || !eventName) {
|
|
972
|
+
this.opts.onError?.(new Error("Omni server control frame is missing its event key"));
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
this.opts.onEvent?.(frame);
|
|
818
976
|
switch (eventName) {
|
|
819
977
|
case OmniEvent.Hello:
|
|
820
978
|
this.opts.onHello?.(frame);
|
|
@@ -856,7 +1014,10 @@ export class OmniConnection {
|
|
|
856
1014
|
* `{"event":"configure"}` is acked but silently dropped by the engine.)
|
|
857
1015
|
*/
|
|
858
1016
|
configure(cfg: OmniConfigure): void {
|
|
859
|
-
|
|
1017
|
+
const payload: Record<string, unknown> = { ...cfg };
|
|
1018
|
+
delete payload.type;
|
|
1019
|
+
delete payload.event;
|
|
1020
|
+
this.ws.send(omniControlFrame({ type: "configure", ...payload }));
|
|
860
1021
|
}
|
|
861
1022
|
|
|
862
1023
|
/**
|
|
@@ -896,7 +1057,12 @@ export class OmniConnection {
|
|
|
896
1057
|
* never `event`.
|
|
897
1058
|
*/
|
|
898
1059
|
send(frame: Record<string, unknown>): void {
|
|
899
|
-
|
|
1060
|
+
if (typeof frame.type !== "string" || !frame.type) {
|
|
1061
|
+
throw new TypeError("Omni client control frames must have a non-empty type key");
|
|
1062
|
+
}
|
|
1063
|
+
const payload = { ...frame };
|
|
1064
|
+
delete payload.event;
|
|
1065
|
+
this.ws.send(omniControlFrame(payload));
|
|
900
1066
|
}
|
|
901
1067
|
|
|
902
1068
|
/** Close the session. */
|
|
@@ -1210,19 +1376,23 @@ export interface RecapCallTriggerInput {
|
|
|
1210
1376
|
|
|
1211
1377
|
// --- AMD (answering-machine detection) -----------------------------------
|
|
1212
1378
|
|
|
1213
|
-
/**
|
|
1379
|
+
/**
|
|
1380
|
+
* The answered-by vocabulary on stored call records and the
|
|
1381
|
+
* `amd.call.completed` webhook: the routing classes plus the machine subtypes.
|
|
1382
|
+
*/
|
|
1214
1383
|
export type AmdAnsweredBy =
|
|
1215
1384
|
| "human"
|
|
1385
|
+
| "machine"
|
|
1216
1386
|
| "voicemail"
|
|
1217
|
-
| "live_voicemail"
|
|
1218
1387
|
| "screening" // iPhone / Google Call Screen
|
|
1219
1388
|
| "ivr"
|
|
1220
|
-
| "
|
|
1389
|
+
| "music" // hold music
|
|
1221
1390
|
| "sit_invalid" // dead / disconnected number
|
|
1222
|
-
| "fax"
|
|
1223
|
-
| "silence"
|
|
1224
1391
|
| "unknown";
|
|
1225
1392
|
|
|
1393
|
+
/** The routing classes pushed on the mid-call wire event (`event: "amd"`). */
|
|
1394
|
+
export type AmdWireAnsweredBy = "human" | "machine" | "sit_invalid" | "unknown";
|
|
1395
|
+
|
|
1226
1396
|
/** Twilio's `AnsweredBy` enum, echoed for drop-in migration parity. */
|
|
1227
1397
|
export type AmdTwilioAnsweredBy =
|
|
1228
1398
|
| "human"
|
|
@@ -1272,11 +1442,16 @@ export interface AmdCall extends AmdCallSummary {
|
|
|
1272
1442
|
error?: string | null;
|
|
1273
1443
|
}
|
|
1274
1444
|
|
|
1275
|
-
/**
|
|
1445
|
+
/**
|
|
1446
|
+
* A mid-call AMD decision event pushed on the stream (and to the per-call
|
|
1447
|
+
* TwiML `webhook`). Carries the coarse routing class; the machine subtype
|
|
1448
|
+
* (`voicemail`/`ivr`/`screening`/`music`) is on the stored call record
|
|
1449
|
+
* (`AmdCall`) and the `amd.call.completed` webhook instead.
|
|
1450
|
+
*/
|
|
1276
1451
|
export interface AmdDecisionEvent {
|
|
1277
1452
|
event?: "amd";
|
|
1278
1453
|
call_id?: string;
|
|
1279
|
-
answered_by?:
|
|
1454
|
+
answered_by?: AmdWireAnsweredBy;
|
|
1280
1455
|
answered_by_twilio?: string | null;
|
|
1281
1456
|
confidence?: number | null;
|
|
1282
1457
|
decision_ms?: number | null;
|
|
@@ -1399,14 +1574,22 @@ export class PyAI {
|
|
|
1399
1574
|
// --- voices -------------------------------------------------------------
|
|
1400
1575
|
|
|
1401
1576
|
voices = {
|
|
1402
|
-
list: (params: { gender?: string; region?: string } = {}): Promise<ListResponse<Voice>> => {
|
|
1577
|
+
list: async (params: { gender?: string; region?: string } = {}): Promise<ListResponse<Voice>> => {
|
|
1403
1578
|
const q = new URLSearchParams();
|
|
1404
1579
|
if (params.gender) q.set("gender", params.gender);
|
|
1405
1580
|
if (params.region) q.set("region", params.region);
|
|
1406
1581
|
const qs = q.toString();
|
|
1407
|
-
|
|
1582
|
+
const page = await this.getJson<ListResponse<Record<string, unknown>>>(
|
|
1583
|
+
`/v1/voices${qs ? `?${qs}` : ""}`,
|
|
1584
|
+
);
|
|
1585
|
+
return { ...page, data: page.data.map(normalizeVoice) };
|
|
1408
1586
|
},
|
|
1409
|
-
get: (id: string): Promise<Voice> =>
|
|
1587
|
+
get: async (id: string): Promise<Voice> =>
|
|
1588
|
+
normalizeVoice(
|
|
1589
|
+
await this.getJson<Record<string, unknown>>(
|
|
1590
|
+
`/v1/voices/${encodeURIComponent(id)}`,
|
|
1591
|
+
),
|
|
1592
|
+
),
|
|
1410
1593
|
};
|
|
1411
1594
|
|
|
1412
1595
|
// --- audio --------------------------------------------------------------
|
|
@@ -1414,10 +1597,11 @@ export class PyAI {
|
|
|
1414
1597
|
audio = {
|
|
1415
1598
|
/** Text-to-speech. Returns the raw audio bytes (default WAV). */
|
|
1416
1599
|
speech: async (params: SpeechParams): Promise<ArrayBuffer> => {
|
|
1600
|
+
assertActiveSpeechParams(params);
|
|
1417
1601
|
const res = await this.request("/v1/audio/speech", {
|
|
1418
1602
|
method: "POST",
|
|
1419
1603
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
1420
|
-
body: JSON.stringify({ model: "pyai-
|
|
1604
|
+
body: JSON.stringify({ model: "pyai-speak", ...params }),
|
|
1421
1605
|
});
|
|
1422
1606
|
return res.arrayBuffer();
|
|
1423
1607
|
},
|
|
@@ -1430,10 +1614,11 @@ export class PyAI {
|
|
|
1430
1614
|
* iterable of Uint8Array chunks.
|
|
1431
1615
|
*/
|
|
1432
1616
|
speechStream: async (params: SpeechParams): Promise<ReadableStream<Uint8Array>> => {
|
|
1617
|
+
assertActiveSpeechParams(params);
|
|
1433
1618
|
const res = await this.request("/v1/audio/speech", {
|
|
1434
1619
|
method: "POST",
|
|
1435
1620
|
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
1436
|
-
body: JSON.stringify({ model: "pyai-
|
|
1621
|
+
body: JSON.stringify({ model: "pyai-speak", ...params, stream: true }),
|
|
1437
1622
|
});
|
|
1438
1623
|
if (!res.body) throw new PyAIError(res.status, "Response had no body to stream");
|
|
1439
1624
|
return res.body as ReadableStream<Uint8Array>;
|
|
@@ -1444,7 +1629,11 @@ export class PyAI {
|
|
|
1444
1629
|
file: Blob;
|
|
1445
1630
|
filename?: string;
|
|
1446
1631
|
model?: string;
|
|
1447
|
-
|
|
1632
|
+
/**
|
|
1633
|
+
* Hear is English-only. Omission means English, not auto-detect; other
|
|
1634
|
+
* values receive `400 unsupported_language`.
|
|
1635
|
+
*/
|
|
1636
|
+
language?: "en";
|
|
1448
1637
|
response_format?: "json" | "text" | "verbose_json";
|
|
1449
1638
|
/**
|
|
1450
1639
|
* Deterministic seed for reproducible eval runs. Forward-compatible:
|
|
@@ -1516,7 +1705,7 @@ export class PyAI {
|
|
|
1516
1705
|
clones = {
|
|
1517
1706
|
/** List the org's cloned voices. */
|
|
1518
1707
|
list: (): Promise<ListResponse<Voice>> => this.getJson("/v1/voice/clones"),
|
|
1519
|
-
/** Enroll a custom voice from reference audio (>= ~10s). Scope `
|
|
1708
|
+
/** Enroll a custom voice from reference audio (>= ~10s). Scope `speak:clone`. */
|
|
1520
1709
|
create: async (params: { name: string; file: Blob; filename?: string }): Promise<Voice> => {
|
|
1521
1710
|
const form = new FormData();
|
|
1522
1711
|
form.set("name", params.name);
|
|
@@ -1535,7 +1724,7 @@ export class PyAI {
|
|
|
1535
1724
|
if (!match) throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
|
|
1536
1725
|
return match;
|
|
1537
1726
|
},
|
|
1538
|
-
/** Delete a cloned voice (tenant-isolated). Scope `
|
|
1727
|
+
/** Delete a cloned voice (tenant-isolated). Scope `speak:clone`. */
|
|
1539
1728
|
delete: async (id: string): Promise<void> => {
|
|
1540
1729
|
await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
|
|
1541
1730
|
},
|
|
@@ -1756,7 +1945,7 @@ export class PyAI {
|
|
|
1756
1945
|
const query: Record<string, string> = { ...(opts.query ?? {}) };
|
|
1757
1946
|
if (opts.format) query.format = opts.format;
|
|
1758
1947
|
if (opts.rate) query.rate = String(opts.rate);
|
|
1759
|
-
const url = this.realtimeURL({
|
|
1948
|
+
const url = this.realtimeURL({ sessionLabel: opts.sessionLabel, query });
|
|
1760
1949
|
const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
|
|
1761
1950
|
return new OmniConnection(url, sub, opts);
|
|
1762
1951
|
},
|
|
@@ -1764,24 +1953,24 @@ export class PyAI {
|
|
|
1764
1953
|
|
|
1765
1954
|
// --- realtime (WebSocket) ----------------------------------------------
|
|
1766
1955
|
|
|
1767
|
-
/** Build the
|
|
1956
|
+
/** Build the canonical Omni WebSocket URL. */
|
|
1768
1957
|
realtimeURL(opts: RealtimeOptions = {}): string {
|
|
1769
1958
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
1770
1959
|
const q = new URLSearchParams(opts.query ?? {});
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
if (!q.has("rate")) q.set("rate", "24000");
|
|
1780
|
-
const qs = q.toString();
|
|
1781
|
-
return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
|
|
1960
|
+
// format/rate are load-bearing on the connect URL, so default to
|
|
1961
|
+
// browser-grade PCM16/24kHz.
|
|
1962
|
+
for (const key of ["agent", "agent_id", "agentId", "model", "access_token"]) {
|
|
1963
|
+
if (q.has(key)) {
|
|
1964
|
+
throw new Error(
|
|
1965
|
+
`Omni query parameter "${key}" is not supported; use sessionLabel, format/rate, or api_key`,
|
|
1966
|
+
);
|
|
1967
|
+
}
|
|
1782
1968
|
}
|
|
1783
|
-
q.set("
|
|
1784
|
-
|
|
1969
|
+
if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
|
|
1970
|
+
if (!q.has("format")) q.set("format", "pcm16");
|
|
1971
|
+
if (!q.has("rate")) q.set("rate", "24000");
|
|
1972
|
+
const qs = q.toString();
|
|
1973
|
+
return `${wsBase}/v1/omni${qs ? `?${qs}` : ""}`;
|
|
1785
1974
|
}
|
|
1786
1975
|
|
|
1787
1976
|
/** The subprotocol that carries the key on a WS upgrade (browser-safe auth). */
|
|
@@ -1793,6 +1982,7 @@ export class PyAI {
|
|
|
1793
1982
|
hearStreamURL(opts: HearStreamOptions = {}): string {
|
|
1794
1983
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
1795
1984
|
const q = new URLSearchParams(opts.query ?? {});
|
|
1985
|
+
q.set("protocol", "pyai-hear-v1");
|
|
1796
1986
|
if (opts.model) q.set("model", opts.model);
|
|
1797
1987
|
if (opts.language) q.set("language", opts.language);
|
|
1798
1988
|
if (opts.sampleRate !== undefined) q.set("sample_rate", String(opts.sampleRate));
|