@pyai/sdk 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 sample rate in Hz (8000-48000). Omit for the native 24 kHz. Most
69
- * useful with `response_format: "pcm"` (raw 16-bit mono samples), e.g. set
70
- * `8000`/`16000` for telephony pipelines.
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`.
108
+ */
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"`.
71
115
  */
72
- sample_rate?: number;
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 {
@@ -92,6 +147,541 @@ export interface RealtimeOptions {
92
147
  query?: Record<string, string>;
93
148
  }
94
149
 
150
+ /* ------------------------------------------------------------------------- *
151
+ * Stable enums — mirror the server so callers branch on named constants, not
152
+ * magic strings, and a contract change surfaces in one place. These are plain
153
+ * `as const` objects (not TS `enum`s) so the source still runs directly under
154
+ * Node's type-stripping and stays tree-shakeable.
155
+ * ------------------------------------------------------------------------- */
156
+
157
+ /** Frame `type`s emitted by the Hear streaming-STT WebSocket. */
158
+ export const HearFrameType = {
159
+ /** Eager live hypothesis for the current utterance. */
160
+ Partial: "partial",
161
+ /** Partial whose prefix has stabilized (won't be revised). */
162
+ PartialStable: "partial_stable",
163
+ /** Stable transcript at end-of-utterance (endpoint or commit). */
164
+ SpeechFinal: "speech_final",
165
+ /** Corrected, full-context transcript following `speech_final`. */
166
+ Final: "final",
167
+ /** Server-side fault frame. */
168
+ Error: "error",
169
+ } as const;
170
+ export type HearFrameType = (typeof HearFrameType)[keyof typeof HearFrameType];
171
+
172
+ /** WebSocket close codes used across the PyAI realtime/streaming surfaces. */
173
+ export const WSCloseCode = {
174
+ /** Normal closure. */
175
+ Normal: 1000,
176
+ /** Auth/policy: bad key, missing scope, or revoked token. */
177
+ PolicyViolation: 1008,
178
+ /** Engine/internal error. */
179
+ InternalError: 1011,
180
+ /** Over the concurrency cap (PyAI-specific; mirrors HTTP 429). */
181
+ OverCapacity: 4429,
182
+ } as const;
183
+ export type WSCloseCode = (typeof WSCloseCode)[keyof typeof WSCloseCode];
184
+
185
+ /**
186
+ * Stable, machine-readable error `code`s (the documented contract). Branch on
187
+ * these. The set is treated as open — `PyAIError.code` stays `string` — so a
188
+ * new server code never breaks the build, but the known ones are named here.
189
+ */
190
+ export const ErrorCode = {
191
+ Unauthorized: "unauthorized",
192
+ Forbidden: "forbidden",
193
+ OriginNotAllowed: "origin_not_allowed",
194
+ InvalidAgentId: "invalid_agent_id",
195
+ CreditExhausted: "credit_exhausted",
196
+ KeyBudgetExceeded: "key_budget_exceeded",
197
+ InsufficientQuota: "insufficient_quota",
198
+ RateLimitExceeded: "rate_limit_exceeded",
199
+ ConcurrencyLimitExceeded: "concurrency_limit_exceeded",
200
+ DailyCapExceeded: "daily_cap_exceeded",
201
+ IdempotencyConflict: "idempotency_conflict",
202
+ NotFound: "not_found",
203
+ NumberInUse: "number_in_use",
204
+ } as const;
205
+ export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
206
+
207
+ /* ------------------------------------------------------------------------- *
208
+ * Hear streaming STT (and Cue grounding)
209
+ * ------------------------------------------------------------------------- */
210
+
211
+ /** A top-3 knowledge-base passage attached to Cue (grounded) finals. */
212
+ export interface HearGroundingPassage {
213
+ content: string;
214
+ score: number;
215
+ }
216
+
217
+ /** Live hypothesis frame (`partial` / `partial_stable`). */
218
+ export interface HearPartialFrame {
219
+ type: "partial" | "partial_stable";
220
+ text: string;
221
+ /** Prefix that has stabilized (won't change). */
222
+ stable_text?: string;
223
+ /** The still-changing tail. */
224
+ active_text?: string;
225
+ utterance_id: string;
226
+ /** Audio-timeline position of the hypothesis, ms. */
227
+ t_ms: number;
228
+ }
229
+
230
+ /** Finalized-utterance frame (`speech_final` / `final`). */
231
+ export interface HearFinalFrame {
232
+ type: "speech_final" | "final";
233
+ text: string;
234
+ utterance_id: string;
235
+ t_ms: number;
236
+ /** Active-speech length of the utterance (the billed signal), ms. */
237
+ audio_ms: number;
238
+ /** Present only with Cue grounding enabled (top KB passages). */
239
+ grounding?: HearGroundingPassage[];
240
+ }
241
+
242
+ /** Server fault frame (`error`). */
243
+ export interface HearErrorFrame {
244
+ type: "error";
245
+ code?: string;
246
+ message: string;
247
+ }
248
+
249
+ export type HearFrame = HearPartialFrame | HearFinalFrame | HearErrorFrame;
250
+
251
+ /**
252
+ * Minimal structural WebSocket — matches both the browser/Node global
253
+ * `WebSocket` and the `ws` package, and lets tests inject a mock.
254
+ */
255
+ export interface WebSocketLike {
256
+ send(data: string | ArrayBufferLike | ArrayBufferView | Blob): void;
257
+ close(code?: number, reason?: string): void;
258
+ readonly readyState: number;
259
+ onopen: ((ev: unknown) => void) | null;
260
+ onmessage: ((ev: { data: unknown }) => void) | null;
261
+ onerror: ((ev: unknown) => void) | null;
262
+ onclose: ((ev: { code: number; reason: string }) => void) | null;
263
+ }
264
+
265
+ export type WebSocketCtor = new (url: string, protocols?: string | string[]) => WebSocketLike;
266
+
267
+ export interface HearStreamOptions {
268
+ /** Streaming STT model. Server default `pyai-hear`. */
269
+ model?: string;
270
+ /** ISO-639-1 hint, e.g. "en". */
271
+ language?: string;
272
+ /** Input PCM sample rate in Hz. Default 16000 server-side. */
273
+ sampleRate?: number;
274
+ /** Audio frame encoding. Default "pcm16". */
275
+ encoding?: "pcm16" | "opus";
276
+ /** Emit eager partial hypotheses. Default true server-side. */
277
+ interimResults?: boolean;
278
+ /**
279
+ * Enable Cue knowledge-base grounding: sends `{type:"config",grounding:true}`
280
+ * on open, after which `speech_final`/`final` frames carry a `grounding`
281
+ * array. Bills a single `cue.minutes` line instead of the Hear rate.
282
+ */
283
+ grounding?: boolean;
284
+ /** Extra query params merged onto the connect URL. */
285
+ query?: Record<string, string>;
286
+ /** Fired once the socket opens (after the optional grounding config). */
287
+ onOpen?: () => void;
288
+ /** Fired on `partial` / `partial_stable`. */
289
+ onPartial?: (frame: HearPartialFrame) => void;
290
+ /** Fired on `speech_final` / `final`. */
291
+ onFinal?: (frame: HearFinalFrame) => void;
292
+ /** Fired on an `error` frame or a transport-level error. */
293
+ onError?: (err: HearErrorFrame | Error) => void;
294
+ /** Fired when the socket closes (code per `WSCloseCode`). */
295
+ onClose?: (code: number, reason: string) => void;
296
+ /** Injectable WebSocket constructor (defaults to the global). */
297
+ webSocket?: WebSocketCtor;
298
+ }
299
+
300
+ /**
301
+ * A live Hear streaming-STT session. Hides the frame protocol: stream audio
302
+ * with {@link HearStream.sendAudio}, get `onPartial`/`onFinal`/`onError`
303
+ * callbacks, force-finalize with {@link HearStream.commit}, and flush+close
304
+ * with {@link HearStream.close}. Construct via `pyai.audio.transcriptions.stream()`.
305
+ */
306
+ export class HearStream {
307
+ private readonly ws: WebSocketLike;
308
+ private readonly opts: HearStreamOptions;
309
+ private closed = false;
310
+
311
+ constructor(url: string, subprotocol: string, opts: HearStreamOptions) {
312
+ this.opts = opts;
313
+ const WS = opts.webSocket ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket;
314
+ if (!WS) {
315
+ throw new Error(
316
+ "No global WebSocket available; pass options.webSocket (e.g. the `ws` package) to transcriptions.stream()",
317
+ );
318
+ }
319
+ this.ws = new WS(url, [subprotocol]);
320
+ this.ws.onopen = () => {
321
+ if (opts.grounding) {
322
+ try {
323
+ this.ws.send(JSON.stringify({ type: "config", grounding: true }));
324
+ } catch {
325
+ /* surfaced via onerror */
326
+ }
327
+ }
328
+ opts.onOpen?.();
329
+ };
330
+ this.ws.onmessage = (ev) => this.handleMessage(ev.data);
331
+ this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
332
+ this.ws.onclose = (ev) => {
333
+ this.closed = true;
334
+ opts.onClose?.(ev.code, ev.reason);
335
+ };
336
+ }
337
+
338
+ private handleMessage(data: unknown): void {
339
+ // Hear emits JSON text frames; ignore any unexpected binary.
340
+ if (typeof data !== "string") return;
341
+ let frame: HearFrame;
342
+ try {
343
+ frame = JSON.parse(data) as HearFrame;
344
+ } catch {
345
+ this.opts.onError?.(new Error(`Unparseable Hear frame: ${data.slice(0, 120)}`));
346
+ return;
347
+ }
348
+ switch (frame.type) {
349
+ case HearFrameType.Partial:
350
+ case HearFrameType.PartialStable:
351
+ this.opts.onPartial?.(frame);
352
+ break;
353
+ case HearFrameType.SpeechFinal:
354
+ case HearFrameType.Final:
355
+ this.opts.onFinal?.(frame);
356
+ break;
357
+ case HearFrameType.Error:
358
+ this.opts.onError?.(frame);
359
+ break;
360
+ default:
361
+ // Unknown/forward-compatible frame — ignore.
362
+ break;
363
+ }
364
+ }
365
+
366
+ /** Send a chunk of audio (PCM16 or opus per `encoding`). */
367
+ sendAudio(chunk: ArrayBufferLike | ArrayBufferView | Blob): void {
368
+ this.ws.send(chunk);
369
+ }
370
+
371
+ /** Force-finalize the current utterance (e.g. on VAD end-of-turn). */
372
+ commit(): void {
373
+ this.ws.send(JSON.stringify({ type: "commit" }));
374
+ }
375
+
376
+ /** Close the socket; the server flushes a final for any buffered audio. */
377
+ close(code: number = WSCloseCode.Normal, reason = ""): void {
378
+ if (!this.closed) this.ws.close(code, reason);
379
+ }
380
+
381
+ /** The underlying socket (escape hatch for advanced use). */
382
+ get socket(): WebSocketLike {
383
+ return this.ws;
384
+ }
385
+
386
+ /** Current WebSocket readyState. */
387
+ get readyState(): number {
388
+ return this.ws.readyState;
389
+ }
390
+ }
391
+
392
+ /* ------------------------------------------------------------------------- *
393
+ * Key introspection — GET /v1/me
394
+ * ------------------------------------------------------------------------- */
395
+
396
+ /** Shape of `GET /v1/me`. Fields are best-effort / forward-compatible. */
397
+ export interface MeResponse {
398
+ object?: string;
399
+ key_id?: string;
400
+ org_id?: string;
401
+ /** "live" | "test" (a.k.a. environment). */
402
+ environment?: string;
403
+ env?: string;
404
+ scopes?: string[];
405
+ limits?: Record<string, unknown>;
406
+ [k: string]: unknown;
407
+ }
408
+
409
+ /* ------------------------------------------------------------------------- *
410
+ * Telephony
411
+ * ------------------------------------------------------------------------- */
412
+
413
+ export interface TelephonyCapabilities {
414
+ voice?: boolean;
415
+ sms?: boolean;
416
+ }
417
+
418
+ export interface TelephonyAvailableNumber {
419
+ object?: "telephony.available_number";
420
+ /** E.164. */
421
+ phone_number: string;
422
+ country?: string;
423
+ area_code?: string | null;
424
+ locality?: string | null;
425
+ region?: string | null;
426
+ capabilities?: TelephonyCapabilities;
427
+ monthly_cost_cents?: number;
428
+ }
429
+
430
+ export interface TelephonyNumber {
431
+ object?: "telephony.number";
432
+ id: string;
433
+ /** E.164. */
434
+ phone_number: string;
435
+ country?: string;
436
+ area_code?: string | null;
437
+ capabilities?: TelephonyCapabilities;
438
+ /** Agent that answers inbound calls to this number. */
439
+ agent_id?: string | null;
440
+ recording?: boolean;
441
+ monthly_cost_cents?: number;
442
+ status?: "active" | "released";
443
+ created_at?: number;
444
+ released_at?: number | null;
445
+ }
446
+
447
+ /* ------------------------------------------------------------------------- *
448
+ * Trace (compliance)
449
+ * ------------------------------------------------------------------------- */
450
+
451
+ export type TraceVerdict = "PASS" | "WARN" | "FAIL";
452
+ export type TraceSeverity = "low" | "medium" | "high" | "critical";
453
+
454
+ export interface TraceInteraction {
455
+ object?: "trace.interaction";
456
+ /** call_id */
457
+ id: string;
458
+ agent_id?: string | null;
459
+ product?: string | null;
460
+ verdict: TraceVerdict;
461
+ findings?: number;
462
+ blocked_turns?: number;
463
+ modified_turns?: number;
464
+ packs_enforced?: string;
465
+ scored_at?: number;
466
+ }
467
+
468
+ export interface TraceFinding {
469
+ object?: "trace.finding";
470
+ id: string;
471
+ /** call_id */
472
+ interaction_id: string;
473
+ agent_id?: string | null;
474
+ check_id: string;
475
+ severity: TraceSeverity;
476
+ verdict?: string;
477
+ confidence?: number;
478
+ action?: "flag" | "preempt_next" | "escalate";
479
+ speaker?: "agent" | "caller" | "any" | null;
480
+ reason?: string;
481
+ preempt_instruction?: string | null;
482
+ at_t?: number | null;
483
+ tier?: number;
484
+ }
485
+
486
+ /* --- per-call eval scorecard (scorecard-v1) -----------------------------
487
+ * Forward-compatible operational timeline + quality metrics for a single call,
488
+ * surfaced on the interaction detail. All fields are optional and present only
489
+ * once the engine emits them (see docs/PLATFORM_ASK_EVALS_ENGINE_2026-06-16.md),
490
+ * so existing callers keep working unchanged.
491
+ */
492
+
493
+ /** A tool invocation recorded on a timeline turn. */
494
+ export interface TraceToolCall {
495
+ name: string;
496
+ /** Arguments the agent passed (engine-shaped JSON). */
497
+ args?: unknown;
498
+ /** Tool result returned to the agent (engine-shaped JSON). */
499
+ result?: unknown;
500
+ /** Audio-timeline position of the call, ms. */
501
+ t_ms?: number;
502
+ }
503
+
504
+ /** Barge-in timing for a timeline turn. */
505
+ export interface TraceBargeMetrics {
506
+ /** Time from caller speech onset to barge-in detection, ms. */
507
+ detect_ms?: number;
508
+ /** Whether the agent cleanly recovered after the barge-in. */
509
+ recovered?: boolean;
510
+ }
511
+
512
+ /**
513
+ * Known timeline-turn roles. Left open (see {@link TraceTimelineTurn.role}) so a
514
+ * new server-side role never breaks the build — branch defensively.
515
+ */
516
+ export type TraceTimelineRole = "agent" | "caller" | "system" | "tool";
517
+
518
+ /** One turn of a per-call operational timeline (eval scorecard-v1). */
519
+ export interface TraceTimelineTurn {
520
+ /** Monotonic turn index within the call. */
521
+ seq: number;
522
+ /** Audio-timeline position of the turn, ms. */
523
+ t_ms: number;
524
+ /** Who spoke/acted this turn. Open-ended for forward-compat. */
525
+ role: TraceTimelineRole | (string & {});
526
+ /** Transcript/text for the turn, when available. */
527
+ text?: string;
528
+ /** Time-to-first-audio for the turn (latency scoring), ms. */
529
+ ttfb_ms?: number;
530
+ /** Turn-detection (endpointing) latency, ms. */
531
+ endpointing_ms?: number;
532
+ /** Barge-in detect/recovery timing for the turn. */
533
+ barge?: TraceBargeMetrics;
534
+ /** Tool calls made during the turn (tool-use scoring). */
535
+ tool_calls?: TraceToolCall[];
536
+ }
537
+
538
+ /**
539
+ * Aggregate per-call quality metrics (eval scorecard-v1). All optional and
540
+ * forward-compatible — present once the engine emits them.
541
+ */
542
+ export interface QualityMetrics {
543
+ /** Word error rate vs. reference transcript, 0–1 (lower is better). */
544
+ wer?: number;
545
+ /** Representative time-to-first-audio across turns, ms. */
546
+ ttfb_ms?: number;
547
+ /** 95th-percentile end-to-end turn latency, ms. */
548
+ turn_p95_ms?: number;
549
+ /** Barge-in recovery rate, 0–1. */
550
+ barge_recovery?: number;
551
+ /** Task-success score, 0–1. */
552
+ task_success?: number;
553
+ /** Composite voice-agent quality index (engine-defined scale). */
554
+ vaqi?: number;
555
+ [k: string]: unknown;
556
+ }
557
+
558
+ export interface TraceInteractionDetail extends TraceInteraction {
559
+ /** Hash-chain link proving the record is unaltered. */
560
+ audit_hash?: string;
561
+ scorecard?: Record<string, unknown>;
562
+ tier2_findings?: TraceFinding[];
563
+ /**
564
+ * Scorecard schema version, e.g. `"trace-scorecard-v1"`. Present once the
565
+ * engine emits the eval block below.
566
+ */
567
+ scorecard_version?: string;
568
+ /**
569
+ * Per-call operational timeline. Forward-compatible: empty/undefined until
570
+ * the engine emits per-turn timing (Ask 1 of the evals engine plan).
571
+ */
572
+ timeline?: TraceTimelineTurn[];
573
+ /** Aggregate per-call quality metrics. Forward-compatible (see above). */
574
+ quality_metrics?: QualityMetrics;
575
+ }
576
+
577
+ export interface TraceViolation {
578
+ object?: "trace.violation";
579
+ id: string;
580
+ /** call_id */
581
+ interaction_id: string;
582
+ agent_id?: string | null;
583
+ rule_id: string;
584
+ pack_id?: string | null;
585
+ severity: TraceSeverity;
586
+ action_taken?: "pass" | "flag" | "modify" | "block";
587
+ citation?: string;
588
+ reason?: string;
589
+ at_t?: number | null;
590
+ }
591
+
592
+ export interface TraceConfigInput {
593
+ agent_id?: string;
594
+ enabled?: boolean;
595
+ channels?: Array<"voice" | "text">;
596
+ /** Map of pack_id → { enabled, version }. */
597
+ rule_packs?: Record<string, { enabled?: boolean; version?: string | null }>;
598
+ guardrails?: Record<string, unknown>;
599
+ [k: string]: unknown;
600
+ }
601
+
602
+ export interface TraceConfig {
603
+ object?: "trace.config";
604
+ agent_id?: string | null;
605
+ enabled?: boolean;
606
+ mode?: "warn" | "modify" | "block" | "human_handoff";
607
+ etag?: string;
608
+ updated_at?: number;
609
+ config?: TraceConfigInput;
610
+ }
611
+
612
+ export interface TraceRulePackSpec {
613
+ pack_id: string;
614
+ version: string;
615
+ jurisdiction?: string;
616
+ legal_status?: string;
617
+ rules: Array<Record<string, unknown>>;
618
+ }
619
+
620
+ export interface TraceRulePack {
621
+ object?: "trace.rule_pack";
622
+ id?: string;
623
+ pack_id: string;
624
+ version?: string;
625
+ builtin?: boolean;
626
+ jurisdiction?: string | null;
627
+ legal_status?: string | null;
628
+ etag?: string;
629
+ status?: "active" | "deprecated";
630
+ created_at?: number;
631
+ /** The authored DSL (only on the single-pack GET). */
632
+ spec?: Record<string, unknown>;
633
+ }
634
+
635
+ export interface TraceExposure {
636
+ object?: "trace.exposure";
637
+ window_days?: number;
638
+ interactions_scanned?: number;
639
+ with_a_gap?: number;
640
+ gap_rate?: number;
641
+ by_rule?: Array<{ rule_id: string; pack_id?: string | null; count: number; rate: number }>;
642
+ by_verdict?: { PASS?: number; WARN?: number; FAIL?: number };
643
+ top_exposure?: string | null;
644
+ }
645
+
646
+ export interface RecapConfigInput {
647
+ enabled?: boolean;
648
+ webhook_url?: string | null;
649
+ default_pack_id?: string;
650
+ }
651
+
652
+ export interface RecapConfig {
653
+ object?: "recap.config";
654
+ enabled?: boolean;
655
+ webhook_url?: string | null;
656
+ default_pack_id?: string;
657
+ updated_at?: number;
658
+ }
659
+
660
+ export interface RecapCallSummary {
661
+ object?: "recap.call";
662
+ call_id: string;
663
+ pack_id?: string;
664
+ status?: "pending" | "processing" | "complete" | "failed";
665
+ call_duration_s?: number | null;
666
+ created_at?: number;
667
+ completed_at?: number | null;
668
+ }
669
+
670
+ export interface RecapCall extends RecapCallSummary {
671
+ record?: unknown;
672
+ error?: string | null;
673
+ crm_write_status?: string | null;
674
+ }
675
+
676
+ export interface RecapCallTriggerInput {
677
+ utterances: Array<{ speaker_role?: "agent" | "customer"; text: string; offset_s?: number; duration_s?: number }>;
678
+ pack_id?: string;
679
+ call_duration_s?: number;
680
+ call_direction?: "inbound" | "outbound";
681
+ customer_name?: string;
682
+ crm_fields?: Record<string, unknown>;
683
+ }
684
+
95
685
  const RETRYABLE = new Set([429, 500, 502, 503, 504]);
96
686
 
97
687
  export class PyAI {
@@ -155,6 +745,28 @@ export class PyAI {
155
745
  return (await res.json()) as T;
156
746
  }
157
747
 
748
+ private async postJson<T>(path: string, payload: unknown, extraHeaders: Record<string, string> = {}): Promise<T> {
749
+ const res = await this.request(path, {
750
+ method: "POST",
751
+ headers: this.authHeaders({ "Content-Type": "application/json", ...extraHeaders }),
752
+ body: JSON.stringify(payload),
753
+ });
754
+ return (await res.json()) as T;
755
+ }
756
+
757
+ private async putJson<T>(path: string, payload: unknown): Promise<T> {
758
+ const res = await this.request(path, {
759
+ method: "PUT",
760
+ headers: this.authHeaders({ "Content-Type": "application/json" }),
761
+ body: JSON.stringify(payload),
762
+ });
763
+ return (await res.json()) as T;
764
+ }
765
+
766
+ private deleteReq(path: string): Promise<Response> {
767
+ return this.request(path, { method: "DELETE", headers: this.authHeaders() });
768
+ }
769
+
158
770
  // --- models -------------------------------------------------------------
159
771
 
160
772
  models = {
@@ -209,10 +821,23 @@ export class PyAI {
209
821
  file: Blob;
210
822
  filename?: string;
211
823
  model?: string;
824
+ language?: string;
825
+ response_format?: "json" | "text" | "verbose_json";
826
+ /**
827
+ * Deterministic seed for reproducible eval runs. Forward-compatible:
828
+ * honored once the engine supports it, otherwise ignored.
829
+ */
830
+ seed?: number;
831
+ /** Sampling temperature. Forward-compatible (honored when supported). */
832
+ temperature?: number;
212
833
  }): Promise<{ text: string; [k: string]: unknown }> => {
213
834
  const form = new FormData();
214
835
  form.set("file", params.file, params.filename ?? "audio.wav");
215
836
  form.set("model", params.model ?? "pyai-hear");
837
+ if (params.language) form.set("language", params.language);
838
+ if (params.response_format) form.set("response_format", params.response_format);
839
+ if (params.seed !== undefined) form.set("seed", String(params.seed));
840
+ if (params.temperature !== undefined) form.set("temperature", String(params.temperature));
216
841
  const res = await this.request("/v1/audio/transcriptions", {
217
842
  method: "POST",
218
843
  headers: this.authHeaders(),
@@ -220,6 +845,14 @@ export class PyAI {
220
845
  });
221
846
  return (await res.json()) as { text: string };
222
847
  },
848
+ /**
849
+ * Open a live streaming-STT WebSocket (Hear). Hides the frame protocol
850
+ * behind `onPartial`/`onFinal`/`onError`; stream audio with `sendAudio`,
851
+ * force-finalize with `commit()`, flush+close with `close()`. Set
852
+ * `grounding: true` for Cue (turn detection + KB context).
853
+ */
854
+ stream: (opts: HearStreamOptions = {}): HearStream =>
855
+ new HearStream(this.hearStreamURL(opts), this.realtimeSubprotocol(), opts),
223
856
  },
224
857
  };
225
858
 
@@ -247,6 +880,190 @@ export class PyAI {
247
880
  },
248
881
  };
249
882
 
883
+ // --- key introspection --------------------------------------------------
884
+
885
+ /**
886
+ * Introspect the calling key: scopes, environment, and limits. Useful for a
887
+ * preflight/doctor check. (New route; older deployments may 404 — handle it.)
888
+ */
889
+ me = (): Promise<MeResponse> => this.getJson<MeResponse>("/v1/me");
890
+
891
+ // --- voice clones -------------------------------------------------------
892
+
893
+ clones = {
894
+ /** List the org's cloned voices. */
895
+ list: (): Promise<ListResponse<Voice>> => this.getJson("/v1/voice/clones"),
896
+ /** Enroll a custom voice from reference audio (>= ~10s). Scope `voice:clone`. */
897
+ create: async (params: { name: string; file: Blob; filename?: string }): Promise<Voice> => {
898
+ const form = new FormData();
899
+ form.set("name", params.name);
900
+ form.set("file", params.file, params.filename ?? "sample.wav");
901
+ const res = await this.request("/v1/voice/clones", { method: "POST", headers: this.authHeaders(), body: form });
902
+ return (await res.json()) as Voice;
903
+ },
904
+ /**
905
+ * Fetch a single cloned voice by id. The API exposes no GET-by-id for
906
+ * clones, so this filters `list()` client-side and throws a 404 `PyAIError`
907
+ * when the id isn't found.
908
+ */
909
+ get: async (id: string): Promise<Voice> => {
910
+ const { data } = await this.clones.list();
911
+ const match = data.find((v) => v.id === id);
912
+ if (!match) throw new PyAIError(404, `No cloned voice ${id}`, ErrorCode.NotFound);
913
+ return match;
914
+ },
915
+ /** Delete a cloned voice (tenant-isolated). Scope `voice:clone`. */
916
+ delete: async (id: string): Promise<void> => {
917
+ await this.deleteReq(`/v1/voice/clones/${encodeURIComponent(id)}`);
918
+ },
919
+ };
920
+
921
+ // --- telephony (managed numbers) ---------------------------------------
922
+
923
+ telephony = {
924
+ numbers: {
925
+ /** Search the carrier's available US local numbers. */
926
+ available: (
927
+ params: { areaCode?: string; contains?: string; limit?: number } = {},
928
+ ): Promise<ListResponse<TelephonyAvailableNumber>> => {
929
+ const q = new URLSearchParams();
930
+ if (params.areaCode) q.set("area_code", params.areaCode);
931
+ if (params.contains) q.set("contains", params.contains);
932
+ if (params.limit !== undefined) q.set("limit", String(params.limit));
933
+ const qs = q.toString();
934
+ return this.getJson(`/v1/telephony/available${qs ? `?${qs}` : ""}`);
935
+ },
936
+ /** List the org's managed numbers (active only unless `includeReleased`). */
937
+ list: (params: { includeReleased?: boolean } = {}): Promise<ListResponse<TelephonyNumber>> => {
938
+ const qs = params.includeReleased ? "?include_released=true" : "";
939
+ return this.getJson(`/v1/telephony/numbers${qs}`);
940
+ },
941
+ /** Provision (buy) a specific available number, optionally bound to an agent. */
942
+ buy: (params: { phone_number: string; agent_id?: string | null }): Promise<TelephonyNumber> =>
943
+ this.postJson("/v1/telephony/numbers", params),
944
+ /** Route a number to an agent (`agentId: null` to unassign). */
945
+ assign: (id: string, agentId: string | null): Promise<TelephonyNumber> =>
946
+ this.postJson(`/v1/telephony/numbers/${encodeURIComponent(id)}/assign`, { agent_id: agentId }),
947
+ /** Release a number back to the carrier (idempotent). */
948
+ release: async (id: string): Promise<TelephonyNumber> => {
949
+ const res = await this.deleteReq(`/v1/telephony/numbers/${encodeURIComponent(id)}`);
950
+ return (await res.json()) as TelephonyNumber;
951
+ },
952
+ },
953
+ };
954
+
955
+ // --- trace (compliance) -------------------------------------------------
956
+
957
+ trace = {
958
+ interactions: {
959
+ /** List scanned interactions (scorecards), newest first. Scope `trace:read`. */
960
+ list: (
961
+ params: { verdict?: TraceVerdict; agentId?: string; limit?: number; cursor?: string } = {},
962
+ ): Promise<ListResponse<TraceInteraction>> => {
963
+ const q = new URLSearchParams();
964
+ if (params.verdict) q.set("verdict", params.verdict);
965
+ if (params.agentId) q.set("agent_id", params.agentId);
966
+ if (params.limit !== undefined) q.set("limit", String(params.limit));
967
+ if (params.cursor) q.set("cursor", params.cursor);
968
+ const qs = q.toString();
969
+ return this.getJson(`/v1/trace/interactions${qs ? `?${qs}` : ""}`);
970
+ },
971
+ /** The full per-call evidence view (findings, redactions, audit hash). */
972
+ get: (id: string): Promise<TraceInteractionDetail> =>
973
+ this.getJson(`/v1/trace/interactions/${encodeURIComponent(id)}`),
974
+ },
975
+ violations: {
976
+ /** Drill-down of every fired Tier-0 rule across scorecards. Scope `trace:read`. */
977
+ list: (
978
+ params: { ruleId?: string; severity?: TraceSeverity; interactionId?: string; limit?: number; cursor?: string } = {},
979
+ ): Promise<ListResponse<TraceViolation>> => {
980
+ const q = new URLSearchParams();
981
+ if (params.ruleId) q.set("rule_id", params.ruleId);
982
+ if (params.severity) q.set("severity", params.severity);
983
+ if (params.interactionId) q.set("interaction_id", params.interactionId);
984
+ if (params.limit !== undefined) q.set("limit", String(params.limit));
985
+ if (params.cursor) q.set("cursor", params.cursor);
986
+ const qs = q.toString();
987
+ return this.getJson(`/v1/trace/violations${qs ? `?${qs}` : ""}`);
988
+ },
989
+ },
990
+ findings: {
991
+ /** List Tier-2 (async semantic) findings — advisory, non-blocking. Scope `trace:read`. */
992
+ list: (
993
+ params: {
994
+ checkId?: string;
995
+ action?: "flag" | "preempt_next" | "escalate";
996
+ severity?: TraceSeverity;
997
+ interactionId?: string;
998
+ limit?: number;
999
+ cursor?: string;
1000
+ } = {},
1001
+ ): Promise<ListResponse<TraceFinding>> => {
1002
+ const q = new URLSearchParams();
1003
+ if (params.checkId) q.set("check_id", params.checkId);
1004
+ if (params.action) q.set("action", params.action);
1005
+ if (params.severity) q.set("severity", params.severity);
1006
+ if (params.interactionId) q.set("interaction_id", params.interactionId);
1007
+ if (params.limit !== undefined) q.set("limit", String(params.limit));
1008
+ if (params.cursor) q.set("cursor", params.cursor);
1009
+ const qs = q.toString();
1010
+ return this.getJson(`/v1/trace/findings${qs ? `?${qs}` : ""}`);
1011
+ },
1012
+ },
1013
+ config: {
1014
+ /** Read per-agent Trace config (omit `agentId` for the org default). Scope `trace:configure`. */
1015
+ get: (agentId?: string): Promise<TraceConfig> =>
1016
+ this.getJson(`/v1/trace/config${agentId ? `?agent_id=${encodeURIComponent(agentId)}` : ""}`),
1017
+ /** Upsert per-agent Trace config. Scope `trace:configure`. */
1018
+ set: (input: TraceConfigInput): Promise<TraceConfig> => this.putJson("/v1/trace/config", input),
1019
+ },
1020
+ rulePacks: {
1021
+ /** List built-in + custom rule packs. Scope `trace:configure`. */
1022
+ list: (): Promise<ListResponse<TraceRulePack>> => this.getJson("/v1/trace/rule-packs"),
1023
+ /** Upload a custom rule pack (Trace DSL). Scope `trace:configure`. */
1024
+ create: (spec: TraceRulePackSpec): Promise<TraceRulePack> => this.postJson("/v1/trace/rule-packs", spec),
1025
+ /** Resolve a rule pack by id (latest active, or pin `version`). */
1026
+ get: (id: string, version?: string): Promise<TraceRulePack> =>
1027
+ this.getJson(`/v1/trace/rule-packs/${encodeURIComponent(id)}${version ? `?version=${encodeURIComponent(version)}` : ""}`),
1028
+ },
1029
+ /** Compliance exposure summary over a trailing window. Scope `trace:read`. */
1030
+ exposure: (windowDays?: number): Promise<TraceExposure> =>
1031
+ this.getJson(`/v1/trace/exposure${windowDays !== undefined ? `?window_days=${windowDays}` : ""}`),
1032
+ /**
1033
+ * Convenience: the per-call operational timeline (eval scorecard-v1). A thin
1034
+ * wrapper over `interactions.get(id)` that returns the `timeline` array, or
1035
+ * `[]` when the engine hasn't emitted one yet (forward-compatible). Scope
1036
+ * `trace:read`.
1037
+ */
1038
+ callTimeline: async (id: string): Promise<TraceTimelineTurn[]> => {
1039
+ const detail = await this.trace.interactions.get(id);
1040
+ return detail.timeline ?? [];
1041
+ },
1042
+ };
1043
+
1044
+ // --- recap (conversation intelligence) --------------------------------
1045
+
1046
+ recap = {
1047
+ config: {
1048
+ get: (): Promise<RecapConfig> => this.getJson("/v1/recap/config"),
1049
+ set: (input: RecapConfigInput): Promise<RecapConfig> => this.putJson("/v1/recap/config", input),
1050
+ },
1051
+ calls: {
1052
+ list: (params: { limit?: number; cursor?: string; status?: string } = {}): Promise<ListResponse<RecapCallSummary>> => {
1053
+ const q = new URLSearchParams();
1054
+ if (params.limit !== undefined) q.set("limit", String(params.limit));
1055
+ if (params.cursor) q.set("cursor", params.cursor);
1056
+ if (params.status) q.set("status", params.status);
1057
+ const qs = q.toString();
1058
+ return this.getJson(`/v1/recap/calls${qs ? `?${qs}` : ""}`);
1059
+ },
1060
+ get: (callId: string): Promise<RecapCall> =>
1061
+ this.getJson(`/v1/recap/calls/${encodeURIComponent(callId)}`),
1062
+ trigger: (callId: string, input: RecapCallTriggerInput): Promise<RecapCallSummary> =>
1063
+ this.postJson(`/v1/recap/calls/${encodeURIComponent(callId)}`, input),
1064
+ },
1065
+ };
1066
+
250
1067
  // --- realtime (WebSocket) ----------------------------------------------
251
1068
 
252
1069
  /** Build the realtime WebSocket URL for the chosen product. */
@@ -272,6 +1089,19 @@ export class PyAI {
272
1089
  return `pyai-key.${this.apiKey}`;
273
1090
  }
274
1091
 
1092
+ /** Build the Hear streaming-STT WebSocket URL (`/v1/audio/transcriptions/stream`). */
1093
+ hearStreamURL(opts: HearStreamOptions = {}): string {
1094
+ const wsBase = this.baseURL.replace(/^http/, "ws");
1095
+ const q = new URLSearchParams(opts.query ?? {});
1096
+ if (opts.model) q.set("model", opts.model);
1097
+ if (opts.language) q.set("language", opts.language);
1098
+ if (opts.sampleRate !== undefined) q.set("sample_rate", String(opts.sampleRate));
1099
+ if (opts.encoding) q.set("encoding", opts.encoding);
1100
+ if (opts.interimResults !== undefined) q.set("interim_results", String(opts.interimResults));
1101
+ const qs = q.toString();
1102
+ return `${wsBase}/v1/audio/transcriptions/stream${qs ? `?${qs}` : ""}`;
1103
+ }
1104
+
275
1105
  /**
276
1106
  * Open a realtime WebSocket. Uses the global WebSocket (browser, Node 22+).
277
1107
  * The key travels as a subprotocol so it works from the browser without