@pyai/sdk 0.1.2 → 0.2.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 +156 -17
- package/dist/cli.d.ts +3 -2
- package/dist/cli.js +107 -2
- package/dist/index.d.ts +868 -7
- package/dist/index.js +587 -5
- package/package.json +2 -1
- package/src/cli.ts +115 -2
- package/src/index.ts +1220 -11
package/src/index.ts
CHANGED
|
@@ -59,18 +59,73 @@ export interface TranscriptionJob {
|
|
|
59
59
|
error?: string;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Output container/codec for `audio.speech`. This is the **exact** set the
|
|
64
|
+
* server accepts on `POST /v1/audio/speech` — any other value is rejected with
|
|
65
|
+
* `400 unsupported_format`. The default (when `response_format` is omitted) is
|
|
66
|
+
* `mp3`. Omit `sample_rate` for the engine's native 24 kHz (`g711_*` is always
|
|
67
|
+
* 8 kHz).
|
|
68
|
+
*
|
|
69
|
+
* | format | rates (Hz) | Content-Type |
|
|
70
|
+
* |---|---|---|
|
|
71
|
+
* | `mp3` (default) | 8000/16000/24000/48000 | `audio/mpeg` |
|
|
72
|
+
* | `wav` | 8000/16000/24000/48000 | `audio/wav` |
|
|
73
|
+
* | `opus` | 8000/16000/24000/48000 | `audio/ogg` |
|
|
74
|
+
* | `aac` | 8000/16000/24000/48000 | `audio/aac` |
|
|
75
|
+
* | `flac` | 8000/16000/24000/48000 | `audio/flac` |
|
|
76
|
+
* | `pcm` | 8000/16000/24000/48000 | `audio/pcm` (raw int16 LE mono, no header) |
|
|
77
|
+
* | `g711_ulaw` | 8000 (forced) | `audio/basic` |
|
|
78
|
+
* | `g711_alaw` | 8000 (forced) | `audio/basic` |
|
|
79
|
+
*/
|
|
80
|
+
export type SpeechFormat = "wav" | "mp3" | "opus" | "aac" | "flac" | "pcm" | "g711_ulaw" | "g711_alaw";
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The runtime list of accepted `audio.speech` formats (mirrors {@link SpeechFormat}),
|
|
84
|
+
* for building dropdowns / validating input before a request. Branch on the
|
|
85
|
+
* named values; the order matches the contract doc.
|
|
86
|
+
*/
|
|
87
|
+
export const SPEECH_FORMATS = ["wav", "mp3", "opus", "aac", "flac", "pcm", "g711_ulaw", "g711_alaw"] as const;
|
|
88
|
+
|
|
89
|
+
/** Sample rates (Hz) the server accepts for `audio.speech` (`g711_*` is always 8 kHz). */
|
|
90
|
+
export const SPEECH_SAMPLE_RATES = [8000, 16000, 24000, 48000] as const;
|
|
91
|
+
export type SpeechSampleRate = (typeof SPEECH_SAMPLE_RATES)[number];
|
|
92
|
+
|
|
62
93
|
export interface SpeechParams {
|
|
63
94
|
input: string;
|
|
64
95
|
voice?: string;
|
|
65
96
|
model?: string;
|
|
66
|
-
response_format?: "wav" | "mp3" | "opus" | "aac" | "flac" | "pcm";
|
|
67
97
|
/**
|
|
68
|
-
* Output
|
|
69
|
-
*
|
|
70
|
-
*
|
|
98
|
+
* Output container/codec, resampled+encoded server-side. One of
|
|
99
|
+
* {@link SpeechFormat} — anything else is a `400 unsupported_format`. Omit for
|
|
100
|
+
* the default of `mp3`.
|
|
101
|
+
*
|
|
102
|
+
* `g711_ulaw`/`g711_alaw` return raw 8 kHz mono G.711 — the bytes Twilio/SIP
|
|
103
|
+
* media streams expect, so you can hand the response straight to a telephony
|
|
104
|
+
* frame without a client-side resampler or μ-law encoder. `sample_rate` is
|
|
105
|
+
* forced to 8000 for those (omit it, or set exactly 8000). `pcm` is raw,
|
|
106
|
+
* headerless int16 LE mono at `sample_rate`. `mp3`/`opus` are buffered (not
|
|
107
|
+
* chunk-streamed) — use them with `speech`, not `speechStream`.
|
|
71
108
|
*/
|
|
72
|
-
|
|
109
|
+
response_format?: SpeechFormat;
|
|
110
|
+
/**
|
|
111
|
+
* Output sample rate in Hz. One of {@link SpeechSampleRate}
|
|
112
|
+
* (8000/16000/24000/48000) — anything else is a `400`. Omit for the engine's
|
|
113
|
+
* native 24 kHz; `g711_*` is always 8000 (forced). Set `8000`/`16000` for
|
|
114
|
+
* telephony pipelines, most often with `response_format: "pcm"`.
|
|
115
|
+
*/
|
|
116
|
+
sample_rate?: SpeechSampleRate;
|
|
73
117
|
speed?: number;
|
|
118
|
+
/**
|
|
119
|
+
* Deterministic sampling seed for reproducible eval runs. Forward-compatible:
|
|
120
|
+
* honored once the engine supports it (otherwise ignored server-side), so it's
|
|
121
|
+
* always safe to send.
|
|
122
|
+
*/
|
|
123
|
+
seed?: number;
|
|
124
|
+
/**
|
|
125
|
+
* Sampling temperature (lower = more deterministic). Forward-compatible —
|
|
126
|
+
* honored once the engine supports it, otherwise ignored.
|
|
127
|
+
*/
|
|
128
|
+
temperature?: number;
|
|
74
129
|
}
|
|
75
130
|
|
|
76
131
|
export interface CreateJobParams {
|
|
@@ -84,14 +139,885 @@ export interface CreateJobParams {
|
|
|
84
139
|
}
|
|
85
140
|
|
|
86
141
|
export interface RealtimeOptions {
|
|
87
|
-
/** "omni" (agentic
|
|
142
|
+
/** "omni" (agentic voice) or "flow" (legacy voice duplex). Default "omni". */
|
|
88
143
|
product?: "omni" | "flow";
|
|
89
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* Optional opaque tag echoed to your `kb_endpoint` and recorded on the call.
|
|
146
|
+
* Omni is zero-state: the session is authorized by the key's org, so there is
|
|
147
|
+
* nothing to create first and this tag is never required.
|
|
148
|
+
*/
|
|
149
|
+
sessionLabel?: string;
|
|
150
|
+
/**
|
|
151
|
+
* @deprecated Use {@link sessionLabel}. Kept for back-compat — emitted as the
|
|
152
|
+
* `agent_id` query alias, which the gateway still accepts. Ignored if
|
|
153
|
+
* `sessionLabel` is set.
|
|
154
|
+
*/
|
|
90
155
|
agentId?: string;
|
|
91
156
|
/** Extra query params (e.g. format, rate). */
|
|
92
157
|
query?: Record<string, string>;
|
|
93
158
|
}
|
|
94
159
|
|
|
160
|
+
/** Parameters for minting an ephemeral browser Omni session token (server-side). */
|
|
161
|
+
export interface OmniSessionParams {
|
|
162
|
+
/**
|
|
163
|
+
* Browser origins (scheme://host[:port]) the minted token may connect from.
|
|
164
|
+
* Required and non-empty — a browser token must be origin-locked. `*` is not
|
|
165
|
+
* allowed.
|
|
166
|
+
*/
|
|
167
|
+
allowedOrigins: string[];
|
|
168
|
+
/** Token lifetime in seconds. Default 60 server-side; max 600. Keep it short. */
|
|
169
|
+
ttlSeconds?: number;
|
|
170
|
+
/** Optional opaque tag echoed back to your `kb_endpoint` and recorded on the call. */
|
|
171
|
+
sessionLabel?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The result of {@link PyAI.omni}.createSession — a short-lived credential safe
|
|
176
|
+
* to hand to the browser. Use {@link OmniSession.token} as the WebSocket
|
|
177
|
+
* subprotocol `pyai-key.<token>` against {@link OmniSession.url}.
|
|
178
|
+
*/
|
|
179
|
+
export interface OmniSession {
|
|
180
|
+
object?: "omni.session";
|
|
181
|
+
/** The ephemeral session token. Short-lived + origin-locked; browser-safe. */
|
|
182
|
+
token: string;
|
|
183
|
+
/** Token expiry, Unix epoch milliseconds. */
|
|
184
|
+
expires_at: number;
|
|
185
|
+
/** The Omni realtime WebSocket URL to connect to. */
|
|
186
|
+
url: string;
|
|
187
|
+
/** Echoed back when supplied on the request. */
|
|
188
|
+
session_label?: string;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ------------------------------------------------------------------------- *
|
|
192
|
+
* Stable enums — mirror the server so callers branch on named constants, not
|
|
193
|
+
* magic strings, and a contract change surfaces in one place. These are plain
|
|
194
|
+
* `as const` objects (not TS `enum`s) so the source still runs directly under
|
|
195
|
+
* Node's type-stripping and stays tree-shakeable.
|
|
196
|
+
* ------------------------------------------------------------------------- */
|
|
197
|
+
|
|
198
|
+
/** Frame `type`s emitted by the Hear streaming-STT WebSocket. */
|
|
199
|
+
export const HearFrameType = {
|
|
200
|
+
/** Eager live hypothesis for the current utterance. */
|
|
201
|
+
Partial: "partial",
|
|
202
|
+
/** Partial whose prefix has stabilized (won't be revised). */
|
|
203
|
+
PartialStable: "partial_stable",
|
|
204
|
+
/** Stable transcript at end-of-utterance (endpoint or commit). */
|
|
205
|
+
SpeechFinal: "speech_final",
|
|
206
|
+
/** Corrected, full-context transcript following `speech_final`. */
|
|
207
|
+
Final: "final",
|
|
208
|
+
/** Final billed-usage summary, emitted just before a graceful close. */
|
|
209
|
+
Usage: "usage",
|
|
210
|
+
/** Server-side fault frame. */
|
|
211
|
+
Error: "error",
|
|
212
|
+
} as const;
|
|
213
|
+
export type HearFrameType = (typeof HearFrameType)[keyof typeof HearFrameType];
|
|
214
|
+
|
|
215
|
+
/** WebSocket close codes used across the PyAI realtime/streaming surfaces. */
|
|
216
|
+
export const WSCloseCode = {
|
|
217
|
+
/** Normal closure. */
|
|
218
|
+
Normal: 1000,
|
|
219
|
+
/** Auth/policy: bad key, missing scope, or revoked token. */
|
|
220
|
+
PolicyViolation: 1008,
|
|
221
|
+
/** Engine/internal error. */
|
|
222
|
+
InternalError: 1011,
|
|
223
|
+
/** Over the concurrency cap (PyAI-specific; mirrors HTTP 429). */
|
|
224
|
+
OverCapacity: 4429,
|
|
225
|
+
} as const;
|
|
226
|
+
export type WSCloseCode = (typeof WSCloseCode)[keyof typeof WSCloseCode];
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Stable, machine-readable error `code`s (the documented contract). Branch on
|
|
230
|
+
* these. The set is treated as open — `PyAIError.code` stays `string` — so a
|
|
231
|
+
* new server code never breaks the build, but the known ones are named here.
|
|
232
|
+
*/
|
|
233
|
+
export const ErrorCode = {
|
|
234
|
+
Unauthorized: "unauthorized",
|
|
235
|
+
Forbidden: "forbidden",
|
|
236
|
+
OriginNotAllowed: "origin_not_allowed",
|
|
237
|
+
InvalidAgentId: "invalid_agent_id",
|
|
238
|
+
CreditExhausted: "credit_exhausted",
|
|
239
|
+
KeyBudgetExceeded: "key_budget_exceeded",
|
|
240
|
+
InsufficientQuota: "insufficient_quota",
|
|
241
|
+
RateLimitExceeded: "rate_limit_exceeded",
|
|
242
|
+
ConcurrencyLimitExceeded: "concurrency_limit_exceeded",
|
|
243
|
+
DailyCapExceeded: "daily_cap_exceeded",
|
|
244
|
+
IdempotencyConflict: "idempotency_conflict",
|
|
245
|
+
NotFound: "not_found",
|
|
246
|
+
NumberInUse: "number_in_use",
|
|
247
|
+
} as const;
|
|
248
|
+
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
249
|
+
|
|
250
|
+
/* ------------------------------------------------------------------------- *
|
|
251
|
+
* Hear streaming STT (and Cue grounding)
|
|
252
|
+
* ------------------------------------------------------------------------- */
|
|
253
|
+
|
|
254
|
+
/** A top-3 knowledge-base passage attached to Cue (grounded) finals. */
|
|
255
|
+
export interface HearGroundingPassage {
|
|
256
|
+
content: string;
|
|
257
|
+
score: number;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Live hypothesis frame (`partial` / `partial_stable`). */
|
|
261
|
+
export interface HearPartialFrame {
|
|
262
|
+
type: "partial" | "partial_stable";
|
|
263
|
+
text: string;
|
|
264
|
+
/** Prefix that has stabilized (won't change). */
|
|
265
|
+
stable_text?: string;
|
|
266
|
+
/** The still-changing tail. */
|
|
267
|
+
active_text?: string;
|
|
268
|
+
utterance_id: string;
|
|
269
|
+
/** Audio-timeline position of the hypothesis, ms. */
|
|
270
|
+
t_ms: number;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Finalized-utterance frame (`speech_final` / `final`). */
|
|
274
|
+
export interface HearFinalFrame {
|
|
275
|
+
type: "speech_final" | "final";
|
|
276
|
+
text: string;
|
|
277
|
+
utterance_id: string;
|
|
278
|
+
t_ms: number;
|
|
279
|
+
/** Active-speech length of the utterance (the billed signal), ms. */
|
|
280
|
+
audio_ms: number;
|
|
281
|
+
/** Present only with Cue grounding enabled (top KB passages). */
|
|
282
|
+
grounding?: HearGroundingPassage[];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Final billed-usage summary (`usage`), emitted just before a graceful close
|
|
286
|
+
* so realtime spend can be reconciled in-band (a realtime WS carries no
|
|
287
|
+
* `x-pyai-units` response header). Best-effort: absent if there was no billable
|
|
288
|
+
* audio or the close was abnormal. */
|
|
289
|
+
export interface HearUsageFrame {
|
|
290
|
+
type: "usage";
|
|
291
|
+
/** `hear` for plain streaming, `cue` when grounding was enabled. */
|
|
292
|
+
product: "hear" | "cue";
|
|
293
|
+
/** The billed meter (`hear.requests` or `cue.minutes`). */
|
|
294
|
+
meter: string;
|
|
295
|
+
/** Summed active-speech audio billed for the session, in seconds. */
|
|
296
|
+
audio_seconds: number;
|
|
297
|
+
/** The same quantity in minutes. */
|
|
298
|
+
minutes: number;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Server fault frame (`error`). */
|
|
302
|
+
export interface HearErrorFrame {
|
|
303
|
+
type: "error";
|
|
304
|
+
code?: string;
|
|
305
|
+
message: string;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export type HearFrame = HearPartialFrame | HearFinalFrame | HearUsageFrame | HearErrorFrame;
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Minimal structural WebSocket — matches both the browser/Node global
|
|
312
|
+
* `WebSocket` and the `ws` package, and lets tests inject a mock.
|
|
313
|
+
*/
|
|
314
|
+
export interface WebSocketLike {
|
|
315
|
+
send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
|
|
316
|
+
close(code?: number, reason?: string): void;
|
|
317
|
+
readonly readyState: number;
|
|
318
|
+
onopen: ((ev: unknown) => void) | null;
|
|
319
|
+
onmessage: ((ev: { data: unknown }) => void) | null;
|
|
320
|
+
onerror: ((ev: unknown) => void) | null;
|
|
321
|
+
onclose: ((ev: { code: number; reason: string }) => void) | null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export type WebSocketCtor = new (url: string, protocols?: string | string[]) => WebSocketLike;
|
|
325
|
+
|
|
326
|
+
export interface HearStreamOptions {
|
|
327
|
+
/** Streaming STT model. Server default `pyai-hear`. */
|
|
328
|
+
model?: string;
|
|
329
|
+
/** ISO-639-1 hint, e.g. "en". */
|
|
330
|
+
language?: string;
|
|
331
|
+
/** Input PCM sample rate in Hz. Default 16000 server-side. */
|
|
332
|
+
sampleRate?: number;
|
|
333
|
+
/** Audio frame encoding. Default "pcm16". */
|
|
334
|
+
encoding?: "pcm16" | "opus";
|
|
335
|
+
/** Emit eager partial hypotheses. Default true server-side. */
|
|
336
|
+
interimResults?: boolean;
|
|
337
|
+
/**
|
|
338
|
+
* Format spoken numbers as digits in the transcript (e.g. "one two three" →
|
|
339
|
+
* "123"). Useful for voice agents that read back phone numbers, codes, and
|
|
340
|
+
* amounts. Default false (spoken form). Forwards `?numerals=true` on the URL.
|
|
341
|
+
*/
|
|
342
|
+
numerals?: boolean;
|
|
343
|
+
/**
|
|
344
|
+
* Turn-segmentation tuning: trailing-pause (ms, 50–2000) that ends an
|
|
345
|
+
* utterance. Forwards `?endpointing_ms=` — clamped + honored once the engine
|
|
346
|
+
* supports it; a no-op when omitted. Drive end-of-turn yourself with
|
|
347
|
+
* {@link HearStream.commit} for full control today.
|
|
348
|
+
*/
|
|
349
|
+
endpointingMs?: number;
|
|
350
|
+
/**
|
|
351
|
+
* Enable Cue knowledge-base grounding: sends `{type:"config",grounding:true}`
|
|
352
|
+
* on open, after which `speech_final`/`final` frames carry a `grounding`
|
|
353
|
+
* array. Bills a single `cue.minutes` line instead of the Hear rate.
|
|
354
|
+
*/
|
|
355
|
+
grounding?: boolean;
|
|
356
|
+
/** Cue: number of KB passages to retrieve per turn (1–20, default 3). */
|
|
357
|
+
groundingK?: number;
|
|
358
|
+
/** Cue: drop passages scoring below this (0–1, default 0 = keep all). */
|
|
359
|
+
groundingMinScore?: number;
|
|
360
|
+
/** Cue: max ms to wait for retrieval at the final before failing open to
|
|
361
|
+
* `grounding: []` (50–2000, default 450). */
|
|
362
|
+
groundingTimeoutMs?: number;
|
|
363
|
+
/** Extra query params merged onto the connect URL. */
|
|
364
|
+
query?: Record<string, string>;
|
|
365
|
+
/** Fired once the socket opens (after the optional grounding config). */
|
|
366
|
+
onOpen?: () => void;
|
|
367
|
+
/** Fired on `partial` / `partial_stable`. */
|
|
368
|
+
onPartial?: (frame: HearPartialFrame) => void;
|
|
369
|
+
/** Fired on `speech_final` / `final`. */
|
|
370
|
+
onFinal?: (frame: HearFinalFrame) => void;
|
|
371
|
+
/** Fired on the final `usage` summary (in-band realtime reconciliation). */
|
|
372
|
+
onUsage?: (frame: HearUsageFrame) => void;
|
|
373
|
+
/** Fired on an `error` frame or a transport-level error. */
|
|
374
|
+
onError?: (err: HearErrorFrame | Error) => void;
|
|
375
|
+
/** Fired when the socket closes (code per `WSCloseCode`). */
|
|
376
|
+
onClose?: (code: number, reason: string) => void;
|
|
377
|
+
/** Injectable WebSocket constructor (defaults to the global). */
|
|
378
|
+
webSocket?: WebSocketCtor;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* A live Hear streaming-STT session. Hides the frame protocol: stream audio
|
|
383
|
+
* with {@link HearStream.sendAudio}, get `onPartial`/`onFinal`/`onError`
|
|
384
|
+
* callbacks, force-finalize with {@link HearStream.commit}, and flush+close
|
|
385
|
+
* with {@link HearStream.close}. Construct via `pyai.audio.transcriptions.stream()`.
|
|
386
|
+
*/
|
|
387
|
+
export class HearStream {
|
|
388
|
+
private readonly ws: WebSocketLike;
|
|
389
|
+
private readonly opts: HearStreamOptions;
|
|
390
|
+
private closed = false;
|
|
391
|
+
|
|
392
|
+
constructor(url: string, subprotocol: string, opts: HearStreamOptions) {
|
|
393
|
+
this.opts = opts;
|
|
394
|
+
const WS = opts.webSocket ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket;
|
|
395
|
+
if (!WS) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
"No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to transcriptions.stream()",
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
this.ws = new WS(url, [subprotocol]);
|
|
401
|
+
this.ws.onopen = () => {
|
|
402
|
+
if (opts.grounding) {
|
|
403
|
+
try {
|
|
404
|
+
const cfg: Record<string, unknown> = { type: "config", grounding: true };
|
|
405
|
+
if (opts.groundingK != null) cfg.grounding_k = opts.groundingK;
|
|
406
|
+
if (opts.groundingMinScore != null) cfg.grounding_min_score = opts.groundingMinScore;
|
|
407
|
+
if (opts.groundingTimeoutMs != null) cfg.grounding_timeout_ms = opts.groundingTimeoutMs;
|
|
408
|
+
this.ws.send(JSON.stringify(cfg));
|
|
409
|
+
} catch {
|
|
410
|
+
/* surfaced via onerror */
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
opts.onOpen?.();
|
|
414
|
+
};
|
|
415
|
+
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
416
|
+
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
417
|
+
this.ws.onclose = (ev) => {
|
|
418
|
+
this.closed = true;
|
|
419
|
+
opts.onClose?.(ev.code, ev.reason);
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private handleMessage(data: unknown): void {
|
|
424
|
+
// Hear emits JSON text frames; ignore any unexpected binary.
|
|
425
|
+
if (typeof data !== "string") return;
|
|
426
|
+
let frame: HearFrame;
|
|
427
|
+
try {
|
|
428
|
+
frame = JSON.parse(data) as HearFrame;
|
|
429
|
+
} catch {
|
|
430
|
+
this.opts.onError?.(new Error(`Unparseable Hear frame: ${data.slice(0, 120)}`));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
switch (frame.type) {
|
|
434
|
+
case HearFrameType.Partial:
|
|
435
|
+
case HearFrameType.PartialStable:
|
|
436
|
+
this.opts.onPartial?.(frame);
|
|
437
|
+
break;
|
|
438
|
+
case HearFrameType.SpeechFinal:
|
|
439
|
+
case HearFrameType.Final:
|
|
440
|
+
this.opts.onFinal?.(frame);
|
|
441
|
+
break;
|
|
442
|
+
case HearFrameType.Usage:
|
|
443
|
+
this.opts.onUsage?.(frame);
|
|
444
|
+
break;
|
|
445
|
+
case HearFrameType.Error:
|
|
446
|
+
this.opts.onError?.(frame);
|
|
447
|
+
break;
|
|
448
|
+
default:
|
|
449
|
+
// Unknown/forward-compatible frame — ignore.
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Send a chunk of audio (PCM16 or opus per `encoding`). */
|
|
455
|
+
sendAudio(chunk: ArrayBufferLike | ArrayBufferView | Blob): void {
|
|
456
|
+
this.ws.send(chunk);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Force-finalize the current utterance (e.g. on VAD end-of-turn). */
|
|
460
|
+
commit(): void {
|
|
461
|
+
this.ws.send(JSON.stringify({ type: "commit" }));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Close the socket; the server flushes a final for any buffered audio. */
|
|
465
|
+
close(code: number = WSCloseCode.Normal, reason = ""): void {
|
|
466
|
+
if (!this.closed) this.ws.close(code, reason);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** The underlying socket (escape hatch for advanced use). */
|
|
470
|
+
get socket(): WebSocketLike {
|
|
471
|
+
return this.ws;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** Current WebSocket readyState. */
|
|
475
|
+
get readyState(): number {
|
|
476
|
+
return this.ws.readyState;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/* ------------------------------------------------------------------------- *
|
|
481
|
+
* Omni realtime (agentic voice) — typed client over the /v1/omni WebSocket
|
|
482
|
+
* ------------------------------------------------------------------------- */
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Event names on Omni **server → client** frames. ⚠️ Note the asymmetry:
|
|
486
|
+
* inbound frames are keyed on `event`, but your **outbound** control frames
|
|
487
|
+
* (`configure`, `dtmf`, …) are keyed on `type`. {@link OmniConnection} handles
|
|
488
|
+
* both sides for you; this map is for matching frames in `onEvent`.
|
|
489
|
+
*/
|
|
490
|
+
export const OmniEvent = {
|
|
491
|
+
/** Handshake; advertises protocol version + audio formats. */
|
|
492
|
+
Hello: "hello",
|
|
493
|
+
/** Ack for your `configure` frame (echoes the resolved `voice_id`). */
|
|
494
|
+
Configured: "configured",
|
|
495
|
+
/** Session is live; includes the resolved agent + audio caps. */
|
|
496
|
+
SessionStarted: "session_started",
|
|
497
|
+
/** Turn boundary (user/assistant speaking). */
|
|
498
|
+
Turn: "turn",
|
|
499
|
+
/** Incremental/final transcript text. */
|
|
500
|
+
Transcript: "transcript",
|
|
501
|
+
/** User interrupted; assistant audio is being cut. */
|
|
502
|
+
BargeIn: "barge_in",
|
|
503
|
+
/** Alias for barge-in on some engine builds. */
|
|
504
|
+
Flush: "flush",
|
|
505
|
+
/** Session is closing; see close code. */
|
|
506
|
+
SessionEnd: "session_end",
|
|
507
|
+
/** Server fault frame. */
|
|
508
|
+
Error: "error",
|
|
509
|
+
} as const;
|
|
510
|
+
export type OmniEvent = (typeof OmniEvent)[keyof typeof OmniEvent];
|
|
511
|
+
|
|
512
|
+
/** A server → client Omni frame. Keyed on `event` (not `type`); open for forward-compat. */
|
|
513
|
+
export interface OmniServerFrame {
|
|
514
|
+
event: string;
|
|
515
|
+
[k: string]: unknown;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** A binary agent-audio chunk delivered to {@link OmniConnectOptions.onAudio}. */
|
|
519
|
+
export type OmniAudioChunk = ArrayBuffer | ArrayBufferView | Blob;
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* The agent config sent as the `configure` control frame. These are the wire
|
|
523
|
+
* (snake_case) fields the engine reads; it's an **open bag**, so forward-compat
|
|
524
|
+
* fields (e.g. `greeting` / `language` once the engine acks them) pass straight
|
|
525
|
+
* through. The SDK supplies the `{"type":"configure"}` envelope for you — which
|
|
526
|
+
* is the whole point: a hand-rolled `{"event":"configure"}` is acked but
|
|
527
|
+
* silently dropped, leaving the agent with no brain and zero turns.
|
|
528
|
+
*/
|
|
529
|
+
export interface OmniConfigure {
|
|
530
|
+
/** Voice to speak with (stock / clone / designed id). */
|
|
531
|
+
voice_id?: string;
|
|
532
|
+
/** System prompt / role + instructions for the agent. */
|
|
533
|
+
persona?: string;
|
|
534
|
+
/** Customer-hosted URL the engine calls per turn for grounding. */
|
|
535
|
+
kb_endpoint?: string;
|
|
536
|
+
/** Bearer the engine presents to `kb_endpoint`. */
|
|
537
|
+
kb_token?: string;
|
|
538
|
+
/** Forward-compatible: any other key the engine honors. */
|
|
539
|
+
[k: string]: unknown;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export interface OmniConnectOptions {
|
|
543
|
+
/** Optional opaque per-session tag echoed to your `kb_endpoint`. Omni is zero-state. */
|
|
544
|
+
sessionLabel?: string;
|
|
545
|
+
/**
|
|
546
|
+
* Ephemeral browser token from `omni.createSession`. When set it is used as
|
|
547
|
+
* the WS subprotocol (`pyai-key.<token>`) instead of the client's secret key,
|
|
548
|
+
* so a page never holds a secret key.
|
|
549
|
+
*/
|
|
550
|
+
token?: string;
|
|
551
|
+
/** Connect-URL sample rate: 24000 browser, 16000 wideband telephony, 8000 G.711/Twilio. */
|
|
552
|
+
rate?: 24000 | 16000 | 8000;
|
|
553
|
+
/** Connect-URL audio format. Default `pcm16`. */
|
|
554
|
+
format?: "pcm16";
|
|
555
|
+
/**
|
|
556
|
+
* Agent config sent as `{"type":"configure",...}` the instant the socket
|
|
557
|
+
* opens. Omit to send it yourself later via {@link OmniConnection.configure}.
|
|
558
|
+
*/
|
|
559
|
+
configure?: OmniConfigure;
|
|
560
|
+
/** Extra query params merged onto the connect URL. */
|
|
561
|
+
query?: Record<string, string>;
|
|
562
|
+
/** Fired once the socket opens (after the optional auto-configure). */
|
|
563
|
+
onOpen?: () => void;
|
|
564
|
+
/** Fired for each binary agent-audio chunk — play it out as it arrives. */
|
|
565
|
+
onAudio?: (chunk: OmniAudioChunk) => void;
|
|
566
|
+
/** Fired on the `hello` handshake frame. */
|
|
567
|
+
onHello?: (frame: OmniServerFrame) => void;
|
|
568
|
+
/** Fired on the `configured` ack. */
|
|
569
|
+
onConfigured?: (frame: OmniServerFrame) => void;
|
|
570
|
+
/** Fired on `session_started`. */
|
|
571
|
+
onSessionStarted?: (frame: OmniServerFrame) => void;
|
|
572
|
+
/** Fired on `turn` boundaries. */
|
|
573
|
+
onTurn?: (frame: OmniServerFrame) => void;
|
|
574
|
+
/** Fired on `transcript` text frames. */
|
|
575
|
+
onTranscript?: (frame: OmniServerFrame) => void;
|
|
576
|
+
/** Fired on `barge_in` / `flush` (user interrupted). */
|
|
577
|
+
onBargeIn?: (frame: OmniServerFrame) => void;
|
|
578
|
+
/** Fired on `session_end`. */
|
|
579
|
+
onSessionEnd?: (frame: OmniServerFrame) => void;
|
|
580
|
+
/** Fired on EVERY JSON frame (including unknown/forward-compat ones). */
|
|
581
|
+
onEvent?: (frame: OmniServerFrame) => void;
|
|
582
|
+
/** Fired on an `error` frame or a transport-level error. */
|
|
583
|
+
onError?: (err: OmniServerFrame | Error) => void;
|
|
584
|
+
/** Fired when the socket closes (code per {@link WSCloseCode}). */
|
|
585
|
+
onClose?: (code: number, reason: string) => void;
|
|
586
|
+
/** Injectable WebSocket constructor (defaults to the global). */
|
|
587
|
+
webSocket?: WebSocketCtor;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* A live Omni agentic-voice session over `/v1/omni`. Hides the wire protocol's
|
|
592
|
+
* frame-key asymmetry: it sends control frames keyed on `type` (`configure`,
|
|
593
|
+
* `dtmf`) and parses server frames keyed on `event`, so you cannot trip the #1
|
|
594
|
+
* Omni integration bug (mirroring the server's `event` key on outbound, which
|
|
595
|
+
* is silently dropped). Construct via `pyai.omni.connect()`.
|
|
596
|
+
*
|
|
597
|
+
* @example
|
|
598
|
+
* const omni = pyai.omni.connect({
|
|
599
|
+
* rate: 16000,
|
|
600
|
+
* configure: { voice_id: "stock_emma_en_gb", persona: "You are a receptionist." },
|
|
601
|
+
* onAudio: (chunk) => speaker.write(chunk),
|
|
602
|
+
* onTranscript: (f) => console.log(f.text),
|
|
603
|
+
* });
|
|
604
|
+
* omni.sendAudio(pcm16Chunk); // stream caller audio continuously
|
|
605
|
+
*/
|
|
606
|
+
export class OmniConnection {
|
|
607
|
+
private readonly ws: WebSocketLike;
|
|
608
|
+
private readonly opts: OmniConnectOptions;
|
|
609
|
+
private closed = false;
|
|
610
|
+
|
|
611
|
+
constructor(url: string, subprotocol: string, opts: OmniConnectOptions) {
|
|
612
|
+
this.opts = opts;
|
|
613
|
+
const WS = opts.webSocket ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket;
|
|
614
|
+
if (!WS) {
|
|
615
|
+
throw new Error(
|
|
616
|
+
"No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to omni.connect()",
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
this.ws = new WS(url, [subprotocol]);
|
|
620
|
+
this.ws.onopen = () => {
|
|
621
|
+
if (opts.configure) {
|
|
622
|
+
try {
|
|
623
|
+
this.configure(opts.configure);
|
|
624
|
+
} catch {
|
|
625
|
+
/* surfaced via onerror */
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
opts.onOpen?.();
|
|
629
|
+
};
|
|
630
|
+
this.ws.onmessage = (ev) => this.handleMessage(ev.data);
|
|
631
|
+
this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
|
|
632
|
+
this.ws.onclose = (ev) => {
|
|
633
|
+
this.closed = true;
|
|
634
|
+
opts.onClose?.(ev.code, ev.reason);
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private handleMessage(data: unknown): void {
|
|
639
|
+
// Binary = agent audio (play it out). JSON text = an `event`-keyed frame.
|
|
640
|
+
if (typeof data !== "string") {
|
|
641
|
+
this.opts.onAudio?.(data as OmniAudioChunk);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
let frame: OmniServerFrame;
|
|
645
|
+
try {
|
|
646
|
+
frame = JSON.parse(data) as OmniServerFrame;
|
|
647
|
+
} catch {
|
|
648
|
+
this.opts.onError?.(new Error(`Unparseable Omni frame: ${data.slice(0, 120)}`));
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
this.opts.onEvent?.(frame);
|
|
652
|
+
switch (frame.event) {
|
|
653
|
+
case OmniEvent.Hello:
|
|
654
|
+
this.opts.onHello?.(frame);
|
|
655
|
+
break;
|
|
656
|
+
case OmniEvent.Configured:
|
|
657
|
+
this.opts.onConfigured?.(frame);
|
|
658
|
+
break;
|
|
659
|
+
case OmniEvent.SessionStarted:
|
|
660
|
+
this.opts.onSessionStarted?.(frame);
|
|
661
|
+
break;
|
|
662
|
+
case OmniEvent.Turn:
|
|
663
|
+
this.opts.onTurn?.(frame);
|
|
664
|
+
break;
|
|
665
|
+
case OmniEvent.Transcript:
|
|
666
|
+
this.opts.onTranscript?.(frame);
|
|
667
|
+
break;
|
|
668
|
+
case OmniEvent.BargeIn:
|
|
669
|
+
case OmniEvent.Flush:
|
|
670
|
+
this.opts.onBargeIn?.(frame);
|
|
671
|
+
break;
|
|
672
|
+
case OmniEvent.SessionEnd:
|
|
673
|
+
this.opts.onSessionEnd?.(frame);
|
|
674
|
+
break;
|
|
675
|
+
case OmniEvent.Error:
|
|
676
|
+
this.opts.onError?.(frame);
|
|
677
|
+
break;
|
|
678
|
+
default:
|
|
679
|
+
// Unknown/forward-compatible frame — already delivered via onEvent.
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Send (or update) the agent config. Always emitted as
|
|
686
|
+
* `{"type":"configure", ...}` — the correct key. (A hand-rolled
|
|
687
|
+
* `{"event":"configure"}` is acked but silently dropped by the engine.)
|
|
688
|
+
*/
|
|
689
|
+
configure(cfg: OmniConfigure): void {
|
|
690
|
+
this.ws.send(JSON.stringify({ type: "configure", ...cfg }));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** Stream a chunk of caller audio (PCM16 LE mono at the negotiated rate). */
|
|
694
|
+
sendAudio(chunk: ArrayBufferLike | ArrayBufferView | Blob): void {
|
|
695
|
+
this.ws.send(chunk);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Send DTMF digits as a `{"type":"dtmf"}` control frame. */
|
|
699
|
+
sendDtmf(digits: string): void {
|
|
700
|
+
this.ws.send(JSON.stringify({ type: "dtmf", digits }));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Send an arbitrary control frame for forward-compat control types the SDK
|
|
705
|
+
* does not model yet. Reminder: client → server frames are keyed on `type`,
|
|
706
|
+
* never `event`.
|
|
707
|
+
*/
|
|
708
|
+
send(frame: Record<string, unknown>): void {
|
|
709
|
+
this.ws.send(JSON.stringify(frame));
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Close the session. */
|
|
713
|
+
close(code: number = WSCloseCode.Normal, reason = ""): void {
|
|
714
|
+
if (!this.closed) this.ws.close(code, reason);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** The underlying socket (escape hatch for advanced use). */
|
|
718
|
+
get socket(): WebSocketLike {
|
|
719
|
+
return this.ws;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** Current WebSocket readyState. */
|
|
723
|
+
get readyState(): number {
|
|
724
|
+
return this.ws.readyState;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/* ------------------------------------------------------------------------- *
|
|
729
|
+
* Key introspection — GET /v1/me
|
|
730
|
+
* ------------------------------------------------------------------------- */
|
|
731
|
+
|
|
732
|
+
/** Shape of `GET /v1/me`. Fields are best-effort / forward-compatible. */
|
|
733
|
+
export interface MeResponse {
|
|
734
|
+
object?: string;
|
|
735
|
+
key_id?: string;
|
|
736
|
+
org_id?: string;
|
|
737
|
+
/** "live" | "test" (a.k.a. environment). */
|
|
738
|
+
environment?: string;
|
|
739
|
+
env?: string;
|
|
740
|
+
scopes?: string[];
|
|
741
|
+
limits?: Record<string, unknown>;
|
|
742
|
+
[k: string]: unknown;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/* ------------------------------------------------------------------------- *
|
|
746
|
+
* Telephony
|
|
747
|
+
* ------------------------------------------------------------------------- */
|
|
748
|
+
|
|
749
|
+
export interface TelephonyCapabilities {
|
|
750
|
+
voice?: boolean;
|
|
751
|
+
sms?: boolean;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export interface TelephonyAvailableNumber {
|
|
755
|
+
object?: "telephony.available_number";
|
|
756
|
+
/** E.164. */
|
|
757
|
+
phone_number: string;
|
|
758
|
+
country?: string;
|
|
759
|
+
area_code?: string | null;
|
|
760
|
+
locality?: string | null;
|
|
761
|
+
region?: string | null;
|
|
762
|
+
capabilities?: TelephonyCapabilities;
|
|
763
|
+
monthly_cost_cents?: number;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export interface TelephonyNumber {
|
|
767
|
+
object?: "telephony.number";
|
|
768
|
+
id: string;
|
|
769
|
+
/** E.164. */
|
|
770
|
+
phone_number: string;
|
|
771
|
+
country?: string;
|
|
772
|
+
area_code?: string | null;
|
|
773
|
+
capabilities?: TelephonyCapabilities;
|
|
774
|
+
/** Agent that answers inbound calls to this number. */
|
|
775
|
+
agent_id?: string | null;
|
|
776
|
+
recording?: boolean;
|
|
777
|
+
monthly_cost_cents?: number;
|
|
778
|
+
status?: "active" | "released";
|
|
779
|
+
created_at?: number;
|
|
780
|
+
released_at?: number | null;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/* ------------------------------------------------------------------------- *
|
|
784
|
+
* Trace (compliance)
|
|
785
|
+
* ------------------------------------------------------------------------- */
|
|
786
|
+
|
|
787
|
+
export type TraceVerdict = "PASS" | "WARN" | "FAIL";
|
|
788
|
+
export type TraceSeverity = "low" | "medium" | "high" | "critical";
|
|
789
|
+
|
|
790
|
+
export interface TraceInteraction {
|
|
791
|
+
object?: "trace.interaction";
|
|
792
|
+
/** call_id */
|
|
793
|
+
id: string;
|
|
794
|
+
agent_id?: string | null;
|
|
795
|
+
product?: string | null;
|
|
796
|
+
verdict: TraceVerdict;
|
|
797
|
+
findings?: number;
|
|
798
|
+
blocked_turns?: number;
|
|
799
|
+
modified_turns?: number;
|
|
800
|
+
packs_enforced?: string;
|
|
801
|
+
scored_at?: number;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export interface TraceFinding {
|
|
805
|
+
object?: "trace.finding";
|
|
806
|
+
id: string;
|
|
807
|
+
/** call_id */
|
|
808
|
+
interaction_id: string;
|
|
809
|
+
agent_id?: string | null;
|
|
810
|
+
check_id: string;
|
|
811
|
+
severity: TraceSeverity;
|
|
812
|
+
verdict?: string;
|
|
813
|
+
confidence?: number;
|
|
814
|
+
action?: "flag" | "preempt_next" | "escalate";
|
|
815
|
+
speaker?: "agent" | "caller" | "any" | null;
|
|
816
|
+
reason?: string;
|
|
817
|
+
preempt_instruction?: string | null;
|
|
818
|
+
at_t?: number | null;
|
|
819
|
+
tier?: number;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/* --- per-call eval scorecard (scorecard-v1) -----------------------------
|
|
823
|
+
* Forward-compatible operational timeline + quality metrics for a single call,
|
|
824
|
+
* surfaced on the interaction detail. All fields are optional and present only
|
|
825
|
+
* once the engine emits them (see docs/PLATFORM_ASK_EVALS_ENGINE_2026-06-16.md),
|
|
826
|
+
* so existing callers keep working unchanged.
|
|
827
|
+
*/
|
|
828
|
+
|
|
829
|
+
/** A tool invocation recorded on a timeline turn. */
|
|
830
|
+
export interface TraceToolCall {
|
|
831
|
+
name: string;
|
|
832
|
+
/** Arguments the agent passed (engine-shaped JSON). */
|
|
833
|
+
args?: unknown;
|
|
834
|
+
/** Tool result returned to the agent (engine-shaped JSON). */
|
|
835
|
+
result?: unknown;
|
|
836
|
+
/** Audio-timeline position of the call, ms. */
|
|
837
|
+
t_ms?: number;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Barge-in timing for a timeline turn. */
|
|
841
|
+
export interface TraceBargeMetrics {
|
|
842
|
+
/** Time from caller speech onset to barge-in detection, ms. */
|
|
843
|
+
detect_ms?: number;
|
|
844
|
+
/** Whether the agent cleanly recovered after the barge-in. */
|
|
845
|
+
recovered?: boolean;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Known timeline-turn roles. Left open (see {@link TraceTimelineTurn.role}) so a
|
|
850
|
+
* new server-side role never breaks the build — branch defensively.
|
|
851
|
+
*/
|
|
852
|
+
export type TraceTimelineRole = "agent" | "caller" | "system" | "tool";
|
|
853
|
+
|
|
854
|
+
/** One turn of a per-call operational timeline (eval scorecard-v1). */
|
|
855
|
+
export interface TraceTimelineTurn {
|
|
856
|
+
/** Monotonic turn index within the call. */
|
|
857
|
+
seq: number;
|
|
858
|
+
/** Audio-timeline position of the turn, ms. */
|
|
859
|
+
t_ms: number;
|
|
860
|
+
/** Who spoke/acted this turn. Open-ended for forward-compat. */
|
|
861
|
+
role: TraceTimelineRole | (string & {});
|
|
862
|
+
/** Transcript/text for the turn, when available. */
|
|
863
|
+
text?: string;
|
|
864
|
+
/** Time-to-first-audio for the turn (latency scoring), ms. */
|
|
865
|
+
ttfb_ms?: number;
|
|
866
|
+
/** Turn-detection (endpointing) latency, ms. */
|
|
867
|
+
endpointing_ms?: number;
|
|
868
|
+
/** Barge-in detect/recovery timing for the turn. */
|
|
869
|
+
barge?: TraceBargeMetrics;
|
|
870
|
+
/** Tool calls made during the turn (tool-use scoring). */
|
|
871
|
+
tool_calls?: TraceToolCall[];
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Aggregate per-call quality metrics (eval scorecard-v1). All optional and
|
|
876
|
+
* forward-compatible — present once the engine emits them.
|
|
877
|
+
*/
|
|
878
|
+
export interface QualityMetrics {
|
|
879
|
+
/** Word error rate vs. reference transcript, 0–1 (lower is better). */
|
|
880
|
+
wer?: number;
|
|
881
|
+
/** Representative time-to-first-audio across turns, ms. */
|
|
882
|
+
ttfb_ms?: number;
|
|
883
|
+
/** 95th-percentile end-to-end turn latency, ms. */
|
|
884
|
+
turn_p95_ms?: number;
|
|
885
|
+
/** Barge-in recovery rate, 0–1. */
|
|
886
|
+
barge_recovery?: number;
|
|
887
|
+
/** Task-success score, 0–1. */
|
|
888
|
+
task_success?: number;
|
|
889
|
+
/** Composite voice-agent quality index (engine-defined scale). */
|
|
890
|
+
vaqi?: number;
|
|
891
|
+
[k: string]: unknown;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
export interface TraceInteractionDetail extends TraceInteraction {
|
|
895
|
+
/** Hash-chain link proving the record is unaltered. */
|
|
896
|
+
audit_hash?: string;
|
|
897
|
+
scorecard?: Record<string, unknown>;
|
|
898
|
+
tier2_findings?: TraceFinding[];
|
|
899
|
+
/**
|
|
900
|
+
* Scorecard schema version, e.g. `"trace-scorecard-v1"`. Present once the
|
|
901
|
+
* engine emits the eval block below.
|
|
902
|
+
*/
|
|
903
|
+
scorecard_version?: string;
|
|
904
|
+
/**
|
|
905
|
+
* Per-call operational timeline. Forward-compatible: empty/undefined until
|
|
906
|
+
* the engine emits per-turn timing (Ask 1 of the evals engine plan).
|
|
907
|
+
*/
|
|
908
|
+
timeline?: TraceTimelineTurn[];
|
|
909
|
+
/** Aggregate per-call quality metrics. Forward-compatible (see above). */
|
|
910
|
+
quality_metrics?: QualityMetrics;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export interface TraceViolation {
|
|
914
|
+
object?: "trace.violation";
|
|
915
|
+
id: string;
|
|
916
|
+
/** call_id */
|
|
917
|
+
interaction_id: string;
|
|
918
|
+
agent_id?: string | null;
|
|
919
|
+
rule_id: string;
|
|
920
|
+
pack_id?: string | null;
|
|
921
|
+
severity: TraceSeverity;
|
|
922
|
+
action_taken?: "pass" | "flag" | "modify" | "block";
|
|
923
|
+
citation?: string;
|
|
924
|
+
reason?: string;
|
|
925
|
+
at_t?: number | null;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export interface TraceConfigInput {
|
|
929
|
+
agent_id?: string;
|
|
930
|
+
enabled?: boolean;
|
|
931
|
+
channels?: Array<"voice" | "text">;
|
|
932
|
+
/** Map of pack_id → { enabled, version }. */
|
|
933
|
+
rule_packs?: Record<string, { enabled?: boolean; version?: string | null }>;
|
|
934
|
+
guardrails?: Record<string, unknown>;
|
|
935
|
+
[k: string]: unknown;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
export interface TraceConfig {
|
|
939
|
+
object?: "trace.config";
|
|
940
|
+
agent_id?: string | null;
|
|
941
|
+
enabled?: boolean;
|
|
942
|
+
mode?: "warn" | "modify" | "block" | "human_handoff";
|
|
943
|
+
etag?: string;
|
|
944
|
+
updated_at?: number;
|
|
945
|
+
config?: TraceConfigInput;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
export interface TraceRulePackSpec {
|
|
949
|
+
pack_id: string;
|
|
950
|
+
version: string;
|
|
951
|
+
jurisdiction?: string;
|
|
952
|
+
legal_status?: string;
|
|
953
|
+
rules: Array<Record<string, unknown>>;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
export interface TraceRulePack {
|
|
957
|
+
object?: "trace.rule_pack";
|
|
958
|
+
id?: string;
|
|
959
|
+
pack_id: string;
|
|
960
|
+
version?: string;
|
|
961
|
+
builtin?: boolean;
|
|
962
|
+
jurisdiction?: string | null;
|
|
963
|
+
legal_status?: string | null;
|
|
964
|
+
etag?: string;
|
|
965
|
+
status?: "active" | "deprecated";
|
|
966
|
+
created_at?: number;
|
|
967
|
+
/** The authored DSL (only on the single-pack GET). */
|
|
968
|
+
spec?: Record<string, unknown>;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
export interface TraceExposure {
|
|
972
|
+
object?: "trace.exposure";
|
|
973
|
+
window_days?: number;
|
|
974
|
+
interactions_scanned?: number;
|
|
975
|
+
with_a_gap?: number;
|
|
976
|
+
gap_rate?: number;
|
|
977
|
+
by_rule?: Array<{ rule_id: string; pack_id?: string | null; count: number; rate: number }>;
|
|
978
|
+
by_verdict?: { PASS?: number; WARN?: number; FAIL?: number };
|
|
979
|
+
top_exposure?: string | null;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
export interface RecapConfigInput {
|
|
983
|
+
enabled?: boolean;
|
|
984
|
+
webhook_url?: string | null;
|
|
985
|
+
default_pack_id?: string;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
export interface RecapConfig {
|
|
989
|
+
object?: "recap.config";
|
|
990
|
+
enabled?: boolean;
|
|
991
|
+
webhook_url?: string | null;
|
|
992
|
+
default_pack_id?: string;
|
|
993
|
+
updated_at?: number;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
export interface RecapCallSummary {
|
|
997
|
+
object?: "recap.call";
|
|
998
|
+
call_id: string;
|
|
999
|
+
pack_id?: string;
|
|
1000
|
+
status?: "pending" | "processing" | "complete" | "failed";
|
|
1001
|
+
call_duration_s?: number | null;
|
|
1002
|
+
created_at?: number;
|
|
1003
|
+
completed_at?: number | null;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
export interface RecapCall extends RecapCallSummary {
|
|
1007
|
+
record?: unknown;
|
|
1008
|
+
error?: string | null;
|
|
1009
|
+
crm_write_status?: string | null;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
export interface RecapCallTriggerInput {
|
|
1013
|
+
utterances: Array<{ speaker_role?: "agent" | "customer"; text: string; offset_s?: number; duration_s?: number }>;
|
|
1014
|
+
pack_id?: string;
|
|
1015
|
+
call_duration_s?: number;
|
|
1016
|
+
call_direction?: "inbound" | "outbound";
|
|
1017
|
+
customer_name?: string;
|
|
1018
|
+
crm_fields?: Record<string, unknown>;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
95
1021
|
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
|
|
96
1022
|
|
|
97
1023
|
export class PyAI {
|
|
@@ -155,6 +1081,28 @@ export class PyAI {
|
|
|
155
1081
|
return (await res.json()) as T;
|
|
156
1082
|
}
|
|
157
1083
|
|
|
1084
|
+
private async postJson<T>(path: string, payload: unknown, extraHeaders: Record<string, string> = {}): Promise<T> {
|
|
1085
|
+
const res = await this.request(path, {
|
|
1086
|
+
method: "POST",
|
|
1087
|
+
headers: this.authHeaders({ "Content-Type": "application/json", ...extraHeaders }),
|
|
1088
|
+
body: JSON.stringify(payload),
|
|
1089
|
+
});
|
|
1090
|
+
return (await res.json()) as T;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
private async putJson<T>(path: string, payload: unknown): Promise<T> {
|
|
1094
|
+
const res = await this.request(path, {
|
|
1095
|
+
method: "PUT",
|
|
1096
|
+
headers: this.authHeaders({ "Content-Type": "application/json" }),
|
|
1097
|
+
body: JSON.stringify(payload),
|
|
1098
|
+
});
|
|
1099
|
+
return (await res.json()) as T;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
private deleteReq(path: string): Promise<Response> {
|
|
1103
|
+
return this.request(path, { method: "DELETE", headers: this.authHeaders() });
|
|
1104
|
+
}
|
|
1105
|
+
|
|
158
1106
|
// --- models -------------------------------------------------------------
|
|
159
1107
|
|
|
160
1108
|
models = {
|
|
@@ -209,10 +1157,23 @@ export class PyAI {
|
|
|
209
1157
|
file: Blob;
|
|
210
1158
|
filename?: string;
|
|
211
1159
|
model?: string;
|
|
1160
|
+
language?: string;
|
|
1161
|
+
response_format?: "json" | "text" | "verbose_json";
|
|
1162
|
+
/**
|
|
1163
|
+
* Deterministic seed for reproducible eval runs. Forward-compatible:
|
|
1164
|
+
* honored once the engine supports it, otherwise ignored.
|
|
1165
|
+
*/
|
|
1166
|
+
seed?: number;
|
|
1167
|
+
/** Sampling temperature. Forward-compatible (honored when supported). */
|
|
1168
|
+
temperature?: number;
|
|
212
1169
|
}): Promise<{ text: string; [k: string]: unknown }> => {
|
|
213
1170
|
const form = new FormData();
|
|
214
1171
|
form.set("file", params.file, params.filename ?? "audio.wav");
|
|
215
1172
|
form.set("model", params.model ?? "pyai-hear");
|
|
1173
|
+
if (params.language) form.set("language", params.language);
|
|
1174
|
+
if (params.response_format) form.set("response_format", params.response_format);
|
|
1175
|
+
if (params.seed !== undefined) form.set("seed", String(params.seed));
|
|
1176
|
+
if (params.temperature !== undefined) form.set("temperature", String(params.temperature));
|
|
216
1177
|
const res = await this.request("/v1/audio/transcriptions", {
|
|
217
1178
|
method: "POST",
|
|
218
1179
|
headers: this.authHeaders(),
|
|
@@ -220,6 +1181,14 @@ export class PyAI {
|
|
|
220
1181
|
});
|
|
221
1182
|
return (await res.json()) as { text: string };
|
|
222
1183
|
},
|
|
1184
|
+
/**
|
|
1185
|
+
* Open a live streaming-STT WebSocket (Hear). Hides the frame protocol
|
|
1186
|
+
* behind `onPartial`/`onFinal`/`onError`; stream audio with `sendAudio`,
|
|
1187
|
+
* force-finalize with `commit()`, flush+close with `close()`. Set
|
|
1188
|
+
* `grounding: true` for Cue (turn detection + KB context).
|
|
1189
|
+
*/
|
|
1190
|
+
stream: (opts: HearStreamOptions = {}): HearStream =>
|
|
1191
|
+
new HearStream(this.hearStreamURL(opts), this.realtimeSubprotocol(), opts),
|
|
223
1192
|
},
|
|
224
1193
|
};
|
|
225
1194
|
|
|
@@ -247,6 +1216,229 @@ export class PyAI {
|
|
|
247
1216
|
},
|
|
248
1217
|
};
|
|
249
1218
|
|
|
1219
|
+
// --- key introspection --------------------------------------------------
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Introspect the calling key: scopes, environment, and limits. Useful for a
|
|
1223
|
+
* preflight/doctor check. (New route; older deployments may 404 — handle it.)
|
|
1224
|
+
*/
|
|
1225
|
+
me = (): Promise<MeResponse> => this.getJson<MeResponse>("/v1/me");
|
|
1226
|
+
|
|
1227
|
+
// --- voice clones -------------------------------------------------------
|
|
1228
|
+
|
|
1229
|
+
clones = {
|
|
1230
|
+
/** List the org's cloned voices. */
|
|
1231
|
+
list: (): Promise<ListResponse<Voice>> => this.getJson("/v1/voice/clones"),
|
|
1232
|
+
/** Enroll a custom voice from reference audio (>= ~10s). Scope `voice:clone`. */
|
|
1233
|
+
create: async (params: { name: string; file: Blob; filename?: string }): Promise<Voice> => {
|
|
1234
|
+
const form = new FormData();
|
|
1235
|
+
form.set("name", params.name);
|
|
1236
|
+
form.set("file", params.file, params.filename ?? "sample.wav");
|
|
1237
|
+
const res = await this.request("/v1/voice/clones", { method: "POST", headers: this.authHeaders(), body: form });
|
|
1238
|
+
return (await res.json()) as Voice;
|
|
1239
|
+
},
|
|
1240
|
+
/**
|
|
1241
|
+
* Fetch a single cloned voice by id. The API exposes no GET-by-id for
|
|
1242
|
+
* clones, so this filters `list()` client-side and throws a 404 `PyAIError`
|
|
1243
|
+
* when the id isn't found.
|
|
1244
|
+
*/
|
|
1245
|
+
get: async (id: string): Promise<Voice> => {
|
|
1246
|
+
const { data } = await this.clones.list();
|
|
1247
|
+
const match = data.find((v) => v.id === id);
|
|
1248
|
+
if (!match) throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
|
|
1249
|
+
return match;
|
|
1250
|
+
},
|
|
1251
|
+
/** Delete a cloned voice (tenant-isolated). Scope `voice:clone`. */
|
|
1252
|
+
delete: async (id: string): Promise<void> => {
|
|
1253
|
+
await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
|
|
1254
|
+
},
|
|
1255
|
+
};
|
|
1256
|
+
|
|
1257
|
+
// --- telephony (managed numbers) ---------------------------------------
|
|
1258
|
+
|
|
1259
|
+
telephony = {
|
|
1260
|
+
numbers: {
|
|
1261
|
+
/** Search the carrier's available US local numbers. */
|
|
1262
|
+
available: (
|
|
1263
|
+
params: { areaCode?: string; contains?: string; limit?: number } = {},
|
|
1264
|
+
): Promise<ListResponse<TelephonyAvailableNumber>> => {
|
|
1265
|
+
const q = new URLSearchParams();
|
|
1266
|
+
if (params.areaCode) q.set("area_code", params.areaCode);
|
|
1267
|
+
if (params.contains) q.set("contains", params.contains);
|
|
1268
|
+
if (params.limit !== undefined) q.set("limit", String(params.limit));
|
|
1269
|
+
const qs = q.toString();
|
|
1270
|
+
return this.getJson(`/v1/telephony/available${qs ? `?${qs}` : ""}`);
|
|
1271
|
+
},
|
|
1272
|
+
/** List the org's managed numbers (active only unless `includeReleased`). */
|
|
1273
|
+
list: (params: { includeReleased?: boolean } = {}): Promise<ListResponse<TelephonyNumber>> => {
|
|
1274
|
+
const qs = params.includeReleased ? "?include_released=true" : "";
|
|
1275
|
+
return this.getJson(`/v1/telephony/numbers${qs}`);
|
|
1276
|
+
},
|
|
1277
|
+
/** Provision (buy) a specific available number, optionally bound to an agent. */
|
|
1278
|
+
buy: (params: { phone_number: string; agent_id?: string | null }): Promise<TelephonyNumber> =>
|
|
1279
|
+
this.postJson("/v1/telephony/numbers", params),
|
|
1280
|
+
/** Route a number to an agent (`agentId: null` to unassign). */
|
|
1281
|
+
assign: (id: string, agentId: string | null): Promise<TelephonyNumber> =>
|
|
1282
|
+
this.postJson(`/v1/telephony/numbers/${encodeURIComponent(id)}/assign`, { agent_id: agentId }),
|
|
1283
|
+
/** Release a number back to the carrier (idempotent). */
|
|
1284
|
+
release: async (id: string): Promise<TelephonyNumber> => {
|
|
1285
|
+
const res = await this.deleteReq(`/v1/telephony/numbers/${encodeURIComponent(id)}`);
|
|
1286
|
+
return (await res.json()) as TelephonyNumber;
|
|
1287
|
+
},
|
|
1288
|
+
},
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1291
|
+
// --- trace (compliance) -------------------------------------------------
|
|
1292
|
+
|
|
1293
|
+
trace = {
|
|
1294
|
+
interactions: {
|
|
1295
|
+
/** List scanned interactions (scorecards), newest first. Scope `trace:read`. */
|
|
1296
|
+
list: (
|
|
1297
|
+
params: { verdict?: TraceVerdict; agentId?: string; limit?: number; cursor?: string } = {},
|
|
1298
|
+
): Promise<ListResponse<TraceInteraction>> => {
|
|
1299
|
+
const q = new URLSearchParams();
|
|
1300
|
+
if (params.verdict) q.set("verdict", params.verdict);
|
|
1301
|
+
if (params.agentId) q.set("agent_id", params.agentId);
|
|
1302
|
+
if (params.limit !== undefined) q.set("limit", String(params.limit));
|
|
1303
|
+
if (params.cursor) q.set("cursor", params.cursor);
|
|
1304
|
+
const qs = q.toString();
|
|
1305
|
+
return this.getJson(`/v1/trace/interactions${qs ? `?${qs}` : ""}`);
|
|
1306
|
+
},
|
|
1307
|
+
/** The full per-call evidence view (findings, redactions, audit hash). */
|
|
1308
|
+
get: (id: string): Promise<TraceInteractionDetail> =>
|
|
1309
|
+
this.getJson(`/v1/trace/interactions/${encodeURIComponent(id)}`),
|
|
1310
|
+
},
|
|
1311
|
+
violations: {
|
|
1312
|
+
/** Drill-down of every fired Tier-0 rule across scorecards. Scope `trace:read`. */
|
|
1313
|
+
list: (
|
|
1314
|
+
params: { ruleId?: string; severity?: TraceSeverity; interactionId?: string; limit?: number; cursor?: string } = {},
|
|
1315
|
+
): Promise<ListResponse<TraceViolation>> => {
|
|
1316
|
+
const q = new URLSearchParams();
|
|
1317
|
+
if (params.ruleId) q.set("rule_id", params.ruleId);
|
|
1318
|
+
if (params.severity) q.set("severity", params.severity);
|
|
1319
|
+
if (params.interactionId) q.set("interaction_id", params.interactionId);
|
|
1320
|
+
if (params.limit !== undefined) q.set("limit", String(params.limit));
|
|
1321
|
+
if (params.cursor) q.set("cursor", params.cursor);
|
|
1322
|
+
const qs = q.toString();
|
|
1323
|
+
return this.getJson(`/v1/trace/violations${qs ? `?${qs}` : ""}`);
|
|
1324
|
+
},
|
|
1325
|
+
},
|
|
1326
|
+
findings: {
|
|
1327
|
+
/** List Tier-2 (async semantic) findings — advisory, non-blocking. Scope `trace:read`. */
|
|
1328
|
+
list: (
|
|
1329
|
+
params: {
|
|
1330
|
+
checkId?: string;
|
|
1331
|
+
action?: "flag" | "preempt_next" | "escalate";
|
|
1332
|
+
severity?: TraceSeverity;
|
|
1333
|
+
interactionId?: string;
|
|
1334
|
+
limit?: number;
|
|
1335
|
+
cursor?: string;
|
|
1336
|
+
} = {},
|
|
1337
|
+
): Promise<ListResponse<TraceFinding>> => {
|
|
1338
|
+
const q = new URLSearchParams();
|
|
1339
|
+
if (params.checkId) q.set("check_id", params.checkId);
|
|
1340
|
+
if (params.action) q.set("action", params.action);
|
|
1341
|
+
if (params.severity) q.set("severity", params.severity);
|
|
1342
|
+
if (params.interactionId) q.set("interaction_id", params.interactionId);
|
|
1343
|
+
if (params.limit !== undefined) q.set("limit", String(params.limit));
|
|
1344
|
+
if (params.cursor) q.set("cursor", params.cursor);
|
|
1345
|
+
const qs = q.toString();
|
|
1346
|
+
return this.getJson(`/v1/trace/findings${qs ? `?${qs}` : ""}`);
|
|
1347
|
+
},
|
|
1348
|
+
},
|
|
1349
|
+
config: {
|
|
1350
|
+
/** Read per-agent Trace config (omit `agentId` for the org default). Scope `trace:configure`. */
|
|
1351
|
+
get: (agentId?: string): Promise<TraceConfig> =>
|
|
1352
|
+
this.getJson(`/v1/trace/config${agentId ? `?agent_id=${encodeURIComponent(agentId)}` : ""}`),
|
|
1353
|
+
/** Upsert per-agent Trace config. Scope `trace:configure`. */
|
|
1354
|
+
set: (input: TraceConfigInput): Promise<TraceConfig> => this.putJson("/v1/trace/config", input),
|
|
1355
|
+
},
|
|
1356
|
+
rulePacks: {
|
|
1357
|
+
/** List built-in + custom rule packs. Scope `trace:configure`. */
|
|
1358
|
+
list: (): Promise<ListResponse<TraceRulePack>> => this.getJson("/v1/trace/rule-packs"),
|
|
1359
|
+
/** Upload a custom rule pack (Trace DSL). Scope `trace:configure`. */
|
|
1360
|
+
create: (spec: TraceRulePackSpec): Promise<TraceRulePack> => this.postJson("/v1/trace/rule-packs", spec),
|
|
1361
|
+
/** Resolve a rule pack by id (latest active, or pin `version`). */
|
|
1362
|
+
get: (id: string, version?: string): Promise<TraceRulePack> =>
|
|
1363
|
+
this.getJson(`/v1/trace/rule-packs/${encodeURIComponent(id)}${version ? `?version=${encodeURIComponent(version)}` : ""}`),
|
|
1364
|
+
},
|
|
1365
|
+
/** Compliance exposure summary over a trailing window. Scope `trace:read`. */
|
|
1366
|
+
exposure: (windowDays?: number): Promise<TraceExposure> =>
|
|
1367
|
+
this.getJson(`/v1/trace/exposure${windowDays !== undefined ? `?window_days=${windowDays}` : ""}`),
|
|
1368
|
+
/**
|
|
1369
|
+
* Convenience: the per-call operational timeline (eval scorecard-v1). A thin
|
|
1370
|
+
* wrapper over `interactions.get(id)` that returns the `timeline` array, or
|
|
1371
|
+
* `[]` when the engine hasn't emitted one yet (forward-compatible). Scope
|
|
1372
|
+
* `trace:read`.
|
|
1373
|
+
*/
|
|
1374
|
+
callTimeline: async (id: string): Promise<TraceTimelineTurn[]> => {
|
|
1375
|
+
const detail = await this.trace.interactions.get(id);
|
|
1376
|
+
return detail.timeline ?? [];
|
|
1377
|
+
},
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1380
|
+
// --- recap (conversation intelligence) --------------------------------
|
|
1381
|
+
|
|
1382
|
+
recap = {
|
|
1383
|
+
config: {
|
|
1384
|
+
get: (): Promise<RecapConfig> => this.getJson("/v1/recap/config"),
|
|
1385
|
+
set: (input: RecapConfigInput): Promise<RecapConfig> => this.putJson("/v1/recap/config", input),
|
|
1386
|
+
},
|
|
1387
|
+
calls: {
|
|
1388
|
+
list: (params: { limit?: number; cursor?: string; status?: string } = {}): Promise<ListResponse<RecapCallSummary>> => {
|
|
1389
|
+
const q = new URLSearchParams();
|
|
1390
|
+
if (params.limit !== undefined) q.set("limit", String(params.limit));
|
|
1391
|
+
if (params.cursor) q.set("cursor", params.cursor);
|
|
1392
|
+
if (params.status) q.set("status", params.status);
|
|
1393
|
+
const qs = q.toString();
|
|
1394
|
+
return this.getJson(`/v1/recap/calls${qs ? `?${qs}` : ""}`);
|
|
1395
|
+
},
|
|
1396
|
+
get: (callId: string): Promise<RecapCall> =>
|
|
1397
|
+
this.getJson(`/v1/recap/calls/${encodeURIComponent(callId)}`),
|
|
1398
|
+
trigger: (callId: string, input: RecapCallTriggerInput): Promise<RecapCallSummary> =>
|
|
1399
|
+
this.postJson(`/v1/recap/calls/${encodeURIComponent(callId)}`, input),
|
|
1400
|
+
},
|
|
1401
|
+
};
|
|
1402
|
+
|
|
1403
|
+
// --- omni (agentic voice) ----------------------------------------------
|
|
1404
|
+
|
|
1405
|
+
omni = {
|
|
1406
|
+
/**
|
|
1407
|
+
* Mint an ephemeral, origin-locked Omni session token a browser can use to
|
|
1408
|
+
* open ONE realtime session **directly** — the public/private split for
|
|
1409
|
+
* realtime. **Call this from your server** with a secret key holding
|
|
1410
|
+
* `omni:session`; never ship the secret key to a page. Hand the returned
|
|
1411
|
+
* `token` to the browser, which connects with
|
|
1412
|
+
* `new WebSocket(session.url, ["pyai-key." + session.token])`. The token
|
|
1413
|
+
* expires after `ttlSeconds` (default 60s) and only works from
|
|
1414
|
+
* `allowedOrigins`. Scope `omni:session`.
|
|
1415
|
+
*/
|
|
1416
|
+
createSession: (params: OmniSessionParams): Promise<OmniSession> =>
|
|
1417
|
+
this.postJson("/v1/omni/sessions", {
|
|
1418
|
+
allowed_origins: params.allowedOrigins,
|
|
1419
|
+
...(params.ttlSeconds !== undefined ? { ttl_seconds: params.ttlSeconds } : {}),
|
|
1420
|
+
...(params.sessionLabel !== undefined ? { session_label: params.sessionLabel } : {}),
|
|
1421
|
+
}),
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* Open a live Omni agentic-voice session over `/v1/omni`. Returns an
|
|
1425
|
+
* {@link OmniConnection} that handles the wire protocol's frame-key
|
|
1426
|
+
* asymmetry for you — it sends `type`-keyed control frames (`configure`,
|
|
1427
|
+
* `dtmf`) and parses `event`-keyed server frames — so you can't trip the #1
|
|
1428
|
+
* Omni integration bug. Omni is zero-state: nothing to create first; the
|
|
1429
|
+
* agent's behavior travels in the `configure` frame. Pass `token` (from
|
|
1430
|
+
* `createSession`) to connect from a browser without the secret key.
|
|
1431
|
+
*/
|
|
1432
|
+
connect: (opts: OmniConnectOptions = {}): OmniConnection => {
|
|
1433
|
+
const query: Record<string, string> = { ...(opts.query ?? {}) };
|
|
1434
|
+
if (opts.format) query.format = opts.format;
|
|
1435
|
+
if (opts.rate) query.rate = String(opts.rate);
|
|
1436
|
+
const url = this.realtimeURL({ product: "omni", sessionLabel: opts.sessionLabel, query });
|
|
1437
|
+
const sub = opts.token ? `pyai-key.${opts.token}` : this.realtimeSubprotocol();
|
|
1438
|
+
return new OmniConnection(url, sub, opts);
|
|
1439
|
+
},
|
|
1440
|
+
};
|
|
1441
|
+
|
|
250
1442
|
// --- realtime (WebSocket) ----------------------------------------------
|
|
251
1443
|
|
|
252
1444
|
/** Build the realtime WebSocket URL for the chosen product. */
|
|
@@ -254,10 +1446,12 @@ export class PyAI {
|
|
|
254
1446
|
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
255
1447
|
const q = new URLSearchParams(opts.query ?? {});
|
|
256
1448
|
if ((opts.product ?? "omni") === "omni") {
|
|
257
|
-
// Omni's native realtime surface is /v1/omni.
|
|
258
|
-
//
|
|
259
|
-
// connect URL, so default to
|
|
260
|
-
|
|
1449
|
+
// Omni's native realtime surface is /v1/omni. The session is authorized by
|
|
1450
|
+
// the key's org (zero-state) — sessionLabel is an optional opaque tag.
|
|
1451
|
+
// format/rate are load-bearing on the connect URL, so default to
|
|
1452
|
+
// browser-grade PCM16/24kHz.
|
|
1453
|
+
if (opts.sessionLabel) q.set("session_label", opts.sessionLabel);
|
|
1454
|
+
else if (opts.agentId) q.set("agent_id", opts.agentId); // deprecated alias
|
|
261
1455
|
if (!q.has("format")) q.set("format", "pcm16");
|
|
262
1456
|
if (!q.has("rate")) q.set("rate", "24000");
|
|
263
1457
|
const qs = q.toString();
|
|
@@ -272,6 +1466,21 @@ export class PyAI {
|
|
|
272
1466
|
return `pyai-key.${this.apiKey}`;
|
|
273
1467
|
}
|
|
274
1468
|
|
|
1469
|
+
/** Build the Hear streaming-STT WebSocket URL (`/v1/audio/transcriptions/stream`). */
|
|
1470
|
+
hearStreamURL(opts: HearStreamOptions = {}): string {
|
|
1471
|
+
const wsBase = this.baseURL.replace(/^http/, "ws");
|
|
1472
|
+
const q = new URLSearchParams(opts.query ?? {});
|
|
1473
|
+
if (opts.model) q.set("model", opts.model);
|
|
1474
|
+
if (opts.language) q.set("language", opts.language);
|
|
1475
|
+
if (opts.sampleRate !== undefined) q.set("sample_rate", String(opts.sampleRate));
|
|
1476
|
+
if (opts.encoding) q.set("encoding", opts.encoding);
|
|
1477
|
+
if (opts.interimResults !== undefined) q.set("interim_results", String(opts.interimResults));
|
|
1478
|
+
if (opts.numerals !== undefined) q.set("numerals", String(opts.numerals));
|
|
1479
|
+
if (opts.endpointingMs !== undefined) q.set("endpointing_ms", String(opts.endpointingMs));
|
|
1480
|
+
const qs = q.toString();
|
|
1481
|
+
return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
275
1484
|
/**
|
|
276
1485
|
* Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
|
|
277
1486
|
* The key travels as a subprotocol so it works from the browser without
|