realtime-avatar 0.24.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/dist/browser.d.ts +2 -87
- package/dist/browser.js +6 -1
- package/dist/{chunk-EODL5S3A.js → chunk-HMESE54B.js} +46 -10
- package/dist/chunk-XJPGQMEW.js +161 -0
- package/dist/express.d.ts +1 -1
- package/dist/express.js +5 -3
- package/dist/hono.d.ts +1 -1
- package/dist/hono.js +2 -2
- package/dist/index.d.ts +24 -1
- package/dist/index.js +1 -1
- package/dist/nextjs.d.ts +1 -1
- package/dist/nextjs.js +2 -2
- package/dist/playout-delay-CtRZdtsj.d.ts +168 -0
- package/dist/{proxy-client-DlFXaXtn.d.ts → proxy-client-C8M_G_Kk.d.ts} +168 -18
- package/dist/react-native.d.ts +3 -3
- package/dist/react-native.js +385 -40
- package/dist/react.d.ts +75 -14
- package/dist/react.js +576 -97
- package/dist/server.js +1 -1
- package/dist/tanstack-start.d.ts +1 -1
- package/dist/tanstack-start.js +2 -2
- package/dist/tools.d.ts +17 -1
- package/dist/tools.js +28 -8
- package/dist/{types-CpqMHna3.d.ts → types-C7B7jOx6.d.ts} +7 -0
- package/package.json +1 -1
- package/dist/chunk-4PWWEERK.js +0 -87
- package/dist/playout-delay-DWOc8iCl.d.ts +0 -65
package/README.md
CHANGED
|
@@ -224,6 +224,7 @@ all), and the `video` policy types are deliberately not one-to-one with the wire
|
|
|
224
224
|
// calls
|
|
225
225
|
rta.startCall({ avatarId, mode?, instructions?, context?, maxSeconds?, video?, recording?, connectionHistory?, transcript?, metadata? })
|
|
226
226
|
rta.endCall(sessionId, { reason? }) // free an abandoned call's slot; idempotent, never throws
|
|
227
|
+
rta.leaveQueue(queueTicketId, { reason? }) // give up a queued call's place in line; idempotent, never throws
|
|
227
228
|
|
|
228
229
|
// optional recordings; server only, requires recordings:read
|
|
229
230
|
rta.listRecordings({ sessionId, limit?, cursor? })
|
|
@@ -294,6 +295,61 @@ call. Policy — `instructions`, `maxSeconds`, `voice`, `video` — is decided i
|
|
|
294
295
|
your server. A route that spreads the request body into `startCall` hands your caller your
|
|
295
296
|
system prompt and your bill.
|
|
296
297
|
|
|
298
|
+
### Styling
|
|
299
|
+
|
|
300
|
+
`AvatarCall` and `AvatarVideoSurface` lay themselves out with inline styles, so they need no
|
|
301
|
+
CSS build and no configuration: no stylesheet to import and no Tailwind `@source` for this
|
|
302
|
+
package. The surface fills its container; give that container a definite size (an aspect
|
|
303
|
+
ratio plus a height, say). `className` on the surface adds your own classes; to change a
|
|
304
|
+
property the surface sets itself, such as its 100% size, pass `style`.
|
|
305
|
+
|
|
306
|
+
`AvatarCall`'s `children` render in a layer that fills the call's box above the video and
|
|
307
|
+
the live badge, so an absolutely positioned control places itself against the box and stays
|
|
308
|
+
clickable while she is live.
|
|
309
|
+
|
|
310
|
+
### Microphone and sound
|
|
311
|
+
|
|
312
|
+
The handle reports the two local failures a "live" call can hide. `call.microphone` says
|
|
313
|
+
whether she can hear the user (`off`, `pending`, `on`, `muted`, `blocked`, `unavailable`; the
|
|
314
|
+
last two carry `reason`, `message` and `hint`), and `onMicrophoneProblem` fires once per new
|
|
315
|
+
problem. A device that stops mid-call reads `pending` while LiveKit retries the default device,
|
|
316
|
+
and `unavailable` (`device-lost`) only once that fails. `call.retryMicrophone()` asks again after
|
|
317
|
+
the user fixes it, and `call.setMicrophoneEnabled(enabled)` mutes and unmutes. While the call is
|
|
318
|
+
waiting or connecting those only record the choice (nothing is captured before the room
|
|
319
|
+
connects), a mute made then is kept when the call goes live, and after `end()` they do nothing.
|
|
320
|
+
|
|
321
|
+
`call.audio` says whether the user can hear her (`unknown`, `allowed`, `blocked`; `unknown` again
|
|
322
|
+
once the call ended). While it is `blocked`, `AvatarCall` shows a "Tap to turn on sound" button,
|
|
323
|
+
top-centre in a polite live region, that calls `call.startAudio()`. Pass `audioUnlockPrompt` a
|
|
324
|
+
function to render your own (your words, your placement), or `false` for nothing.
|
|
325
|
+
`startAudio()` must run inside a click or tap handler; it never rejects and resolves whether
|
|
326
|
+
playback is allowed afterwards.
|
|
327
|
+
|
|
328
|
+
Every state is derived from LiveKit's public events, and LiveKit still owns the device: it
|
|
329
|
+
captures on connect and stops when the room leaves. A microphone error from an earlier call on
|
|
330
|
+
the same room is not reported for the next one. A publish that fails after the microphone was
|
|
331
|
+
captured reaches only the room's `onError` and stays `pending`: that callback also carries
|
|
332
|
+
unrelated failures, so it is not guessed into a microphone problem.
|
|
333
|
+
|
|
334
|
+
### Ending a call
|
|
335
|
+
|
|
336
|
+
`call.end()` (and `useRealtimeSession().end()`) is terminal from every phase, including
|
|
337
|
+
`waiting` in the queue and `connecting`. It stops the queue retry and the reconnect ladder,
|
|
338
|
+
releases the held session or the queue ticket, leaves the room (which stops the microphone),
|
|
339
|
+
moves `status` to `"ended"` and fires `onEnded({ reason: "user_ended" })` once. Ending a call
|
|
340
|
+
that is already over does nothing. `sayAndEnd(text)` is the graceful path when she should speak
|
|
341
|
+
a last line first.
|
|
342
|
+
|
|
343
|
+
**After `end()`, nothing starts a call except you, explicitly.** Changing `avatarId`, `mode` or
|
|
344
|
+
`listen` on an ended `AvatarCall` does not redial; remount it, for example with a `key`:
|
|
345
|
+
|
|
346
|
+
```tsx
|
|
347
|
+
<AvatarCall key={callId} client={client} avatarId={avatarId} /> // new callId → new call
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
On the lower-level hooks, `reconnect()` redials (a double tap mints once), and so does setting
|
|
351
|
+
`active` to false and back. `reset()` does not undo an end.
|
|
352
|
+
|
|
297
353
|
### Optional connection details
|
|
298
354
|
|
|
299
355
|
`AvatarCall` provides call status, actions and end reasons for your default UI. To show
|
|
@@ -390,6 +446,28 @@ room-wide to whatever you render inside it (`playoutDelaySeconds={false}` opts o
|
|
|
390
446
|
`attachAvatarTools` runs your functions in the page. Nothing is executed on the platform, and
|
|
391
447
|
a tool has **2.5 seconds** to answer before the call gives up on it and tells her it failed.
|
|
392
448
|
|
|
449
|
+
A failed registration throws `ToolRegistrationError`, whose `retryable` says whether trying again
|
|
450
|
+
can help: the agent not there or not armed by the deadline (a slow start, or a session minted
|
|
451
|
+
without `client_tools`; one attempt cannot tell which), or an RPC transport failure, is retryable;
|
|
452
|
+
an oversized manifest or a request LiveKit or the agent refused is not.
|
|
453
|
+
|
|
454
|
+
In React, `useCharacterTools(tools)` registers them while the room is connected and returns
|
|
455
|
+
their state: `idle`, `registering` (with `attempt`), `ready` (`registered`, `rejected`) or
|
|
456
|
+
`error`. A retryable failure is retried twice (after 1s, then 3s) while the room stays
|
|
457
|
+
connected; only then is it `error`. It re-registers when the manifest changes (names,
|
|
458
|
+
descriptions, parameters), not when the `tools` object does, so an inline object is fine and
|
|
459
|
+
each call reaches your latest `execute`. With `AvatarCall`, call it from a component you render
|
|
460
|
+
as a child, which runs inside the call's room, and show its state rather than ignore it:
|
|
461
|
+
|
|
462
|
+
```tsx
|
|
463
|
+
function Tools() {
|
|
464
|
+
const tools = useCharacterTools(myTools);
|
|
465
|
+
return tools.status === "error" ? <p role="status">Tools are off for this call: {tools.error}</p> : null;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
<AvatarCall client={client} avatarId="ava_…">{() => <Tools />}</AvatarCall>
|
|
469
|
+
```
|
|
470
|
+
|
|
393
471
|
## What `/react` exports, and what it deliberately does not
|
|
394
472
|
|
|
395
473
|
31 names, down from 82 on 2026-08-26. Two groups came out and are not coming back:
|
package/dist/browser.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { D as DEFAULT_AVATAR_PLAYOUT_DELAY_SECONDS, P as PlayoutDelayTarget,
|
|
1
|
+
export { D as DEFAULT_AVATAR_PLAYOUT_DELAY_SECONDS, E as EnableMicrophoneOptions, M as MicrophoneCapableRoom, a as MicrophoneFailureReason, b as MicrophoneProblem, c as MicrophoneProblemReason, d as MicrophoneResult, P as PlayoutDelayTarget, e as applyAvatarPlayoutDelay, f as applyPlayoutDelay, g as describeMicrophoneFailure, h as enableMicrophone } from './playout-delay-CtRZdtsj.js';
|
|
2
2
|
|
|
3
3
|
type VideoSupport = {
|
|
4
4
|
supported: true;
|
|
@@ -10,91 +10,6 @@ type VideoSupport = {
|
|
|
10
10
|
/** Check before starting a billed video session; this does not prove negotiated playback. */
|
|
11
11
|
declare function checkVideoSupport(codec?: string): VideoSupport;
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
* Turning the microphone on, and saying what went wrong when it does not.
|
|
15
|
-
*
|
|
16
|
-
* WHY THIS EXISTS. `room.localParticipant.setMicrophoneEnabled(true)` is one line, so every
|
|
17
|
-
* integration writes it inline and none of them handle its rejection — the reject path is
|
|
18
|
-
* invisible until a real user is on a real machine. When it fails there is nothing to see:
|
|
19
|
-
* the call is already connected, the page is mid-`await`, and the rejection becomes an
|
|
20
|
-
* unhandled promise nobody is looking at. The reported symptom is always the same sentence —
|
|
21
|
-
* "the mic won't start" — and it covers at least six unrelated causes with different fixes,
|
|
22
|
-
* one of which is not in the browser at all.
|
|
23
|
-
*
|
|
24
|
-
* So this returns a RESULT rather than throwing: the cause becomes a value the caller has to
|
|
25
|
-
* handle, and each one carries the sentence that names its actual fix. `message` is what the
|
|
26
|
-
* browser said; `hint` is what the person should do.
|
|
27
|
-
*
|
|
28
|
-
* It is deliberately structural — it never imports `livekit-client`, so it pins no version
|
|
29
|
-
* and adds no bytes. Anything with a `localParticipant.setMicrophoneEnabled` satisfies it.
|
|
30
|
-
*/
|
|
31
|
-
/** The sliver of livekit-client's `Room` this needs. Structural on purpose — see above. */
|
|
32
|
-
interface MicrophoneCapableRoom {
|
|
33
|
-
localParticipant: {
|
|
34
|
-
setMicrophoneEnabled(enabled: boolean): Promise<unknown>;
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Why the microphone did not start. Every variant has a DIFFERENT fix, which is the whole
|
|
39
|
-
* reason this is an enum and not a boolean.
|
|
40
|
-
*/
|
|
41
|
-
type MicrophoneFailureReason =
|
|
42
|
-
/** No `navigator.mediaDevices` — the page is not a secure context. */
|
|
43
|
-
"insecure-origin"
|
|
44
|
-
/** The permission prompt was never answered. getUserMedia may never settle; we time out. */
|
|
45
|
-
| "no-answer"
|
|
46
|
-
/** The BROWSER denied it — the site's own permission, resettable from the address bar. */
|
|
47
|
-
| "denied-by-browser"
|
|
48
|
-
/** The OS denied it — macOS System Settings, nothing the page or the site can change. */
|
|
49
|
-
| "denied-by-os"
|
|
50
|
-
/** No input device is connected, or none matches the constraints. */
|
|
51
|
-
| "no-device"
|
|
52
|
-
/** A device exists but something else holds it — another tab, app, or a stale call. */
|
|
53
|
-
| "device-in-use"
|
|
54
|
-
/** Anything unrecognised. `message` still carries what the browser said. */
|
|
55
|
-
| "unknown";
|
|
56
|
-
type MicrophoneResult = {
|
|
57
|
-
ok: true;
|
|
58
|
-
} | {
|
|
59
|
-
ok: false;
|
|
60
|
-
reason: MicrophoneFailureReason;
|
|
61
|
-
/** What the browser reported, verbatim. Show it — it is often exact. */
|
|
62
|
-
message: string;
|
|
63
|
-
/** What the person should actually do about it. */
|
|
64
|
-
hint: string;
|
|
65
|
-
};
|
|
66
|
-
interface EnableMicrophoneOptions {
|
|
67
|
-
/**
|
|
68
|
-
* How long to wait before calling it a no-answer. Default 15s.
|
|
69
|
-
*
|
|
70
|
-
* This is not belt-and-braces. Per the getUserMedia specification a prompt the user never
|
|
71
|
-
* dismisses may leave the promise permanently pending — "neither resolve nor reject" — and
|
|
72
|
-
* a page that awaits it in its connect path hangs on a state that has no error and no end.
|
|
73
|
-
* A deadline turns that into a reportable outcome.
|
|
74
|
-
*/
|
|
75
|
-
timeoutMs?: number;
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* Publish the caller's microphone, and report WHY if it does not start.
|
|
79
|
-
*
|
|
80
|
-
* ```ts
|
|
81
|
-
* const mic = await enableMicrophone(room);
|
|
82
|
-
* if (!mic.ok) {
|
|
83
|
-
* status.textContent = `Microphone: ${mic.message}`;
|
|
84
|
-
* help.textContent = mic.hint; // the sentence that names the fix
|
|
85
|
-
* }
|
|
86
|
-
* ```
|
|
87
|
-
*
|
|
88
|
-
* Never throws. A call that cannot start the microphone is an ordinary outcome of asking a
|
|
89
|
-
* person for a device, not a programmer error, so it belongs in the return type.
|
|
90
|
-
*
|
|
91
|
-
* Note it does NOT hang up on failure — the call is still live and still billing, and only
|
|
92
|
-
* the caller knows whether a text-only session is useful to them. If it is not, disconnect
|
|
93
|
-
* in the `!ok` branch; leaving a room connected against a device the user never granted is
|
|
94
|
-
* how the retry ends up contending with the call that is still holding it.
|
|
95
|
-
*/
|
|
96
|
-
declare function enableMicrophone(room: MicrophoneCapableRoom, options?: EnableMicrophoneOptions): Promise<MicrophoneResult>;
|
|
97
|
-
|
|
98
13
|
/**
|
|
99
14
|
* Making the character AUDIBLE — the half of "audio works" that has nothing to do with the
|
|
100
15
|
* microphone.
|
|
@@ -251,4 +166,4 @@ type BoomerangPlayback = {
|
|
|
251
166
|
*/
|
|
252
167
|
declare function attachBoomerangPlayback(video: HTMLVideoElement, options?: BoomerangPlaybackOptions): BoomerangPlayback;
|
|
253
168
|
|
|
254
|
-
export { type AttachRemoteAudioOptions, type AttachableTrack, type AudioCapableRoom, type BoomerangPlayback, type BoomerangPlaybackOptions, type
|
|
169
|
+
export { type AttachRemoteAudioOptions, type AttachableTrack, type AudioCapableRoom, type BoomerangPlayback, type BoomerangPlaybackOptions, type PreparableParticipant, type PreparableRoom, type PrepareAvatarRoomOptions, type PreparedAvatarRoom, type RemoteAudioAttachment, type VideoSupport, attachBoomerangPlayback, attachRemoteAudio, checkVideoSupport, prepareAvatarRoom };
|
package/dist/browser.js
CHANGED
|
@@ -18,6 +18,7 @@ var HINTS = {
|
|
|
18
18
|
"denied-by-os": "The operating system is blocking the browser itself, so no site can record. On macOS: System Settings > Privacy & Security > Microphone, enable your browser, then RESTART it \u2014 the change does not apply to a running browser.",
|
|
19
19
|
"no-device": "No microphone is available. Connect one, and if you are on Bluetooth headphones check they are in a mode that has a microphone.",
|
|
20
20
|
"device-in-use": "Another application or browser tab is holding the microphone. Close it \u2014 including any call from this page you did not hang up \u2014 and try again.",
|
|
21
|
+
"device-lost": "The microphone stopped during the call: it was unplugged, or access to it was turned off. Reconnect it or allow access again, then turn the microphone back on.",
|
|
21
22
|
unknown: "Try again, and if it persists include the message above in your report."
|
|
22
23
|
};
|
|
23
24
|
function isOperatingSystemDenial(message) {
|
|
@@ -54,6 +55,10 @@ function classify(error) {
|
|
|
54
55
|
function fail(reason, message) {
|
|
55
56
|
return { ok: false, reason, message, hint: HINTS[reason] };
|
|
56
57
|
}
|
|
58
|
+
function describeMicrophoneFailure(error) {
|
|
59
|
+
const { reason, message } = classify(error);
|
|
60
|
+
return { reason, message, hint: HINTS[reason] };
|
|
61
|
+
}
|
|
57
62
|
async function enableMicrophone(room, options = {}) {
|
|
58
63
|
const timeoutMs = options.timeoutMs ?? 15e3;
|
|
59
64
|
if (typeof navigator === "undefined" || !navigator.mediaDevices) {
|
|
@@ -253,4 +258,4 @@ function now() {
|
|
|
253
258
|
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
254
259
|
}
|
|
255
260
|
|
|
256
|
-
export { DEFAULT_AVATAR_PLAYOUT_DELAY_SECONDS, applyAvatarPlayoutDelay, applyPlayoutDelay, attachBoomerangPlayback, attachRemoteAudio, checkVideoSupport, enableMicrophone, prepareAvatarRoom };
|
|
261
|
+
export { DEFAULT_AVATAR_PLAYOUT_DELAY_SECONDS, applyAvatarPlayoutDelay, applyPlayoutDelay, attachBoomerangPlayback, attachRemoteAudio, checkVideoSupport, describeMicrophoneFailure, enableMicrophone, prepareAvatarRoom };
|
|
@@ -8,11 +8,16 @@ var RealtimeAvatarError = class extends Error {
|
|
|
8
8
|
this.name = "RealtimeAvatarError";
|
|
9
9
|
}
|
|
10
10
|
};
|
|
11
|
+
function isRequestId(value) {
|
|
12
|
+
return typeof value === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value);
|
|
13
|
+
}
|
|
11
14
|
var RealtimeAvatarHttpError = class extends RealtimeAvatarError {
|
|
12
15
|
status;
|
|
13
16
|
code;
|
|
14
17
|
body;
|
|
15
18
|
requestId;
|
|
19
|
+
/** The platform's own verdict on retrying as-is, when its body gave one. */
|
|
20
|
+
retryable;
|
|
16
21
|
concurrency;
|
|
17
22
|
constructor(status, code, body, requestId) {
|
|
18
23
|
const bounded = body.slice(0, 400);
|
|
@@ -27,8 +32,8 @@ var RealtimeAvatarHttpError = class extends RealtimeAvatarError {
|
|
|
27
32
|
if (value && typeof value === "object" && !Array.isArray(value)) parsed = value;
|
|
28
33
|
} catch {
|
|
29
34
|
}
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
this.requestId = isRequestId(parsed.requestId) ? parsed.requestId : isRequestId(requestId) ? requestId : void 0;
|
|
36
|
+
if (typeof parsed.retryable === "boolean") this.retryable = parsed.retryable;
|
|
32
37
|
if (status === 429 && code === "concurrency_limit_reached") {
|
|
33
38
|
this.concurrency = {};
|
|
34
39
|
for (const key of ["maxConcurrentSessions", "liveSessions", "activeSessions", "connectingSessions", "pendingSessions"]) {
|
|
@@ -293,6 +298,12 @@ function backoffMs(attempt, retryAfter) {
|
|
|
293
298
|
return Math.random() * Math.min(500 * 2 ** attempt, 8e3);
|
|
294
299
|
}
|
|
295
300
|
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
301
|
+
var PROXY_CLIENT_TIMEOUT_MS = 6e4;
|
|
302
|
+
var ROUTE_TIMEOUT_MS = PROXY_CLIENT_TIMEOUT_MS - 1e4;
|
|
303
|
+
function retryFits(attemptStartedAt, waitMs, deadline) {
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
return now + waitMs + (now - attemptStartedAt) <= deadline;
|
|
306
|
+
}
|
|
296
307
|
function isTransient(cause) {
|
|
297
308
|
const name = cause?.name;
|
|
298
309
|
return name === "TimeoutError" || name === "TypeError" || name === "FetchError";
|
|
@@ -643,7 +654,7 @@ z.object({
|
|
|
643
654
|
queue_position: z.number().int().positive().optional(),
|
|
644
655
|
recommended_retry_ms: z.number().int().nonnegative()
|
|
645
656
|
}).strict();
|
|
646
|
-
z.enum([
|
|
657
|
+
var liveKitSessionReleaseReasonSchema = z.enum([
|
|
647
658
|
"page_hide",
|
|
648
659
|
"disconnected",
|
|
649
660
|
"superseded",
|
|
@@ -654,12 +665,13 @@ z.enum([
|
|
|
654
665
|
|
|
655
666
|
// ../http-client/src/client.ts
|
|
656
667
|
var DEFAULT_BASE_URL = "https://realtimeavatar.ai/api/v1";
|
|
657
|
-
var SDK_VERSION = "0.
|
|
668
|
+
var SDK_VERSION = "0.26.0";
|
|
658
669
|
var RealtimeAvatar = class {
|
|
659
670
|
#apiKey;
|
|
660
671
|
#baseUrl;
|
|
661
672
|
#fetch;
|
|
662
673
|
#timeoutMs;
|
|
674
|
+
#totalTimeoutMs;
|
|
663
675
|
#maxRetries;
|
|
664
676
|
#userAgent;
|
|
665
677
|
constructor(options) {
|
|
@@ -673,6 +685,7 @@ var RealtimeAvatar = class {
|
|
|
673
685
|
this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
674
686
|
this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
|
|
675
687
|
this.#timeoutMs = options.timeoutMs ?? 6e4;
|
|
688
|
+
this.#totalTimeoutMs = options.totalTimeoutMs;
|
|
676
689
|
this.#maxRetries = Math.max(0, options.maxRetries ?? 2);
|
|
677
690
|
this.#userAgent = [`realtime-avatar-sdk/${SDK_VERSION}`, runtimeTag(), options.userAgent].filter(Boolean).join(" ");
|
|
678
691
|
}
|
|
@@ -764,7 +777,25 @@ var RealtimeAvatar = class {
|
|
|
764
777
|
*/
|
|
765
778
|
async endCall(sessionId, options = {}) {
|
|
766
779
|
if (!sessionId) return false;
|
|
767
|
-
|
|
780
|
+
return this.#release({ session_id: sessionId }, options);
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Give up a QUEUED call's place in line, by the `queueTicketId` that `startCall` returned.
|
|
784
|
+
*
|
|
785
|
+
* A queued call has no session yet, so {@link endCall} cannot reach it: the release contract
|
|
786
|
+
* carries the ticket in its own field, and a ticket sent as a session id names nothing and is
|
|
787
|
+
* acknowledged as an idempotent no-op. The place is then held until its TTL, at the front of
|
|
788
|
+
* the queue, in front of everyone behind it.
|
|
789
|
+
*
|
|
790
|
+
* Same contract as `endCall`: best-effort, `true` only when the platform acknowledged it,
|
|
791
|
+
* never a throw, and only for tickets YOUR SERVER was handed.
|
|
792
|
+
*/
|
|
793
|
+
async leaveQueue(queueTicketId, options = {}) {
|
|
794
|
+
if (!queueTicketId) return false;
|
|
795
|
+
return this.#release({ queue_ticket_id: queueTicketId }, options);
|
|
796
|
+
}
|
|
797
|
+
async #release(target, options) {
|
|
798
|
+
const body = { ...target };
|
|
768
799
|
if (options.reason !== void 0) body.reason = options.reason;
|
|
769
800
|
if (options.capacityPool !== void 0) body.capacity_pool = options.capacityPool;
|
|
770
801
|
try {
|
|
@@ -1099,24 +1130,29 @@ var RealtimeAvatar = class {
|
|
|
1099
1130
|
body = JSON.stringify(init.json);
|
|
1100
1131
|
}
|
|
1101
1132
|
if (MUTATING.has(method)) headers["idempotency-key"] = newIdempotencyKey();
|
|
1133
|
+
const deadline = this.#totalTimeoutMs === void 0 ? Infinity : Date.now() + this.#totalTimeoutMs;
|
|
1102
1134
|
let lastError;
|
|
1103
1135
|
for (let attempt = 0; ; attempt++) {
|
|
1136
|
+
const startedAt = Date.now();
|
|
1104
1137
|
try {
|
|
1105
|
-
const signal = AbortSignal.timeout(this.#timeoutMs);
|
|
1138
|
+
const signal = AbortSignal.timeout(Math.max(0, Math.min(this.#timeoutMs, deadline - startedAt)));
|
|
1106
1139
|
const response = await this.#fetch(`${this.#baseUrl}${path}`, { method, headers, body, signal });
|
|
1107
1140
|
if (attempt >= this.#maxRetries || !RETRYABLE_STATUS.has(response.status)) return response;
|
|
1141
|
+
const wait = backoffMs(attempt, response.headers.get("retry-after"));
|
|
1142
|
+
if (!retryFits(startedAt, wait, deadline)) return response;
|
|
1108
1143
|
await response.body?.cancel().catch(() => {
|
|
1109
1144
|
});
|
|
1110
|
-
await sleep(
|
|
1145
|
+
await sleep(wait);
|
|
1111
1146
|
} catch (cause) {
|
|
1112
1147
|
lastError = cause;
|
|
1113
|
-
|
|
1148
|
+
const wait = backoffMs(attempt, null);
|
|
1149
|
+
if (attempt >= this.#maxRetries || !isTransient(cause) || isStream(body) || !retryFits(startedAt, wait, deadline)) {
|
|
1114
1150
|
throw new RealtimeAvatarError(
|
|
1115
1151
|
`${method} ${path} failed after ${attempt + 1} attempt(s): ${cause.message}`,
|
|
1116
1152
|
{ cause: lastError }
|
|
1117
1153
|
);
|
|
1118
1154
|
}
|
|
1119
|
-
await sleep(
|
|
1155
|
+
await sleep(wait);
|
|
1120
1156
|
}
|
|
1121
1157
|
}
|
|
1122
1158
|
}
|
|
@@ -1263,4 +1299,4 @@ function isQueued(result) {
|
|
|
1263
1299
|
return "queued" in result;
|
|
1264
1300
|
}
|
|
1265
1301
|
|
|
1266
|
-
export { RealtimeAvatar, RealtimeAvatarError, RealtimeAvatarHttpError, clipLibraryDeclarationSchema, isQueued, listRecordingsResponseSchema, recordingAccessResponseSchema, recordingArtifactSchema, recordingModeSchema, verifyTranscript };
|
|
1302
|
+
export { ROUTE_TIMEOUT_MS, RealtimeAvatar, RealtimeAvatarError, RealtimeAvatarHttpError, clipLibraryDeclarationSchema, isQueued, listRecordingsResponseSchema, liveKitSessionReleaseReasonSchema, recordingAccessResponseSchema, recordingArtifactSchema, recordingModeSchema, sessionModeSchema, verifyTranscript };
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { sessionModeSchema, liveKitSessionReleaseReasonSchema, RealtimeAvatar, ROUTE_TIMEOUT_MS, isQueued, RealtimeAvatarHttpError, RealtimeAvatarError } from './chunk-HMESE54B.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
// ../http-client/src/generated/error-semantics.ts
|
|
5
|
+
var ERROR_SEMANTICS = {
|
|
6
|
+
"bad_request": { statuses: [400], message: "Check the request and try again.", retryable: false },
|
|
7
|
+
"unauthorized": { statuses: [401], message: "Authentication is required to use Realtime Avatar.", retryable: false },
|
|
8
|
+
"payment_required": { statuses: [402], message: "Add credits to continue using realtime avatars.", retryable: false },
|
|
9
|
+
"forbidden": { statuses: [403], message: "This API key does not have access to that action.", retryable: false },
|
|
10
|
+
"not_found": { statuses: [404], message: "We could not find that avatar or resource.", retryable: false },
|
|
11
|
+
"method_not_allowed": { statuses: [405], message: "This request method is not supported.", retryable: false },
|
|
12
|
+
"not_acceptable": { statuses: [406], message: "The requested response format is not supported.", retryable: false },
|
|
13
|
+
"request_aborted": { statuses: [408], message: "The session request was cancelled.", retryable: false },
|
|
14
|
+
"conflict": { statuses: [409], message: "That request is already in progress. Try again in a moment.", retryable: true },
|
|
15
|
+
"length_required": { statuses: [411], message: "A request content length is required.", retryable: false },
|
|
16
|
+
"payload_too_large": { statuses: [413], message: "The request is too large.", retryable: false },
|
|
17
|
+
"unprocessable_entity": { statuses: [422], message: "Check the request and try again.", retryable: false },
|
|
18
|
+
"rate_limited": { statuses: [429], message: "Too many realtime requests. Please slow down and retry.", retryable: true },
|
|
19
|
+
"internal_error": { statuses: [500], message: "The session service encountered an error. Try again.", retryable: true },
|
|
20
|
+
"unavailable": { statuses: [503], message: "Realtime Avatar is temporarily unavailable. Try again in a moment.", retryable: true },
|
|
21
|
+
"insufficient_scope": { statuses: [403], message: "This API key is missing a required permission.", retryable: false },
|
|
22
|
+
"loop_pending": { statuses: [409], message: "An avatar loop update is still in progress. Try again shortly.", retryable: true },
|
|
23
|
+
"rate_limit_exceeded": { statuses: [429], message: "Too many realtime requests. Please slow down and retry.", retryable: true },
|
|
24
|
+
"upstream_failed": { statuses: [500, 502, 503], message: "The session service could not complete the request. Try again.", retryable: true },
|
|
25
|
+
"upstream_unreachable": { statuses: [502, 503], message: "The session service could not be reached. Try again.", retryable: true },
|
|
26
|
+
"invalid_upstream_response": { statuses: [502], message: "The session service returned an invalid response. Try again.", retryable: true },
|
|
27
|
+
"upstream_timeout": { statuses: [504], message: "The session service took too long to respond. Try again.", retryable: true },
|
|
28
|
+
"capacity_unavailable": { statuses: [503], message: "Session availability could not be checked. Try again.", retryable: true },
|
|
29
|
+
"session_dispatch_failed": { statuses: [503], message: "The avatar session could not be started. Try again.", retryable: true },
|
|
30
|
+
"admission_unavailable": { statuses: [503], message: "Session admission is temporarily unavailable. Try again.", retryable: true },
|
|
31
|
+
"service_warming": { statuses: [503], message: "Realtime Avatar is warming up. Try again in a moment.", retryable: true },
|
|
32
|
+
"avatar_not_ready": { statuses: [409], message: "This avatar is still being built \u2014 it is usually ready within a minute. Try again then.", retryable: true },
|
|
33
|
+
"clip_library_not_ready": { statuses: [409], message: "This avatar's motion clips are still rendering. Try again in a minute.", retryable: true },
|
|
34
|
+
"recording_unsupported": { statuses: [501], message: "Session recording is not enabled for this deployment.", retryable: false },
|
|
35
|
+
"recording_requires_new_room": { statuses: [422], message: "Recording requires a new session room. Start a fresh session to record it.", retryable: false },
|
|
36
|
+
"recording_unavailable": { statuses: [503], message: "Session recording is temporarily unavailable. Try again in a moment.", retryable: true }
|
|
37
|
+
};
|
|
38
|
+
var MESSAGE_SHAPE_PATTERNS = { "machine": { "source": "[_=<>{}\\[\\]|\\\\$`]|\\w+\\.\\w+\\.\\w|\\S:\\d|\\w\\(\\)|^\\s*(?:at|File)\\b|\\bTraceback \\(most recent call last\\)", "flags": "mu" }, "locator": { "source": "https?://|/[\\w.-]+/|[\\w.+-]+@[\\w.-]+\\.[a-z]{2,}|\\bbearer\\s|[\\r\\n]", "flags": "i" } };
|
|
39
|
+
Object.values(MESSAGE_SHAPE_PATTERNS).map(
|
|
40
|
+
(pattern) => new RegExp(pattern.source, pattern.flags)
|
|
41
|
+
);
|
|
42
|
+
var proxyConnectRequestSchema = z.object({
|
|
43
|
+
avatarId: z.string().min(1),
|
|
44
|
+
mode: sessionModeSchema.optional()
|
|
45
|
+
});
|
|
46
|
+
var proxyEndRequestSchema = z.object({
|
|
47
|
+
session_id: z.string().min(1).optional(),
|
|
48
|
+
queue_ticket_id: z.string().min(1).optional(),
|
|
49
|
+
reason: liveKitSessionReleaseReasonSchema.optional().catch(void 0)
|
|
50
|
+
}).strict().refine((body) => body.session_id !== void 0 || body.queue_ticket_id !== void 0, {
|
|
51
|
+
message: "session_id or queue_ticket_id is required"
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ../proxy/src/config.ts
|
|
55
|
+
var MISCONFIGURED = "internal_error";
|
|
56
|
+
var TIMED_OUT = "upstream_timeout";
|
|
57
|
+
var json = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
58
|
+
status,
|
|
59
|
+
headers: { "content-type": "application/json", "cache-control": "no-store" }
|
|
60
|
+
});
|
|
61
|
+
function relayFailure(operation, error) {
|
|
62
|
+
const misconfigured = error.status === 401 || error.status === 403;
|
|
63
|
+
const status = misconfigured ? ERROR_SEMANTICS[MISCONFIGURED].statuses[0] : error.status;
|
|
64
|
+
const verdict = error.retryable === void 0 ? {} : { retryable: error.retryable };
|
|
65
|
+
const body = misconfigured ? { code: MISCONFIGURED, status, retryable: false } : error.status === 429 ? throttled(error) : { code: error.isBilling ? error.code ?? "insufficient_credits" : error.code, status, ...verdict };
|
|
66
|
+
console.error(
|
|
67
|
+
`realtime-avatar: platform answered ${operation} with ${error.status} (code ${error.code ?? "none"}, requestId ${error.requestId ?? "none"}); relayed as ${status}` + (misconfigured ? " \u2014 the platform refused this route's API key" : "")
|
|
68
|
+
);
|
|
69
|
+
const response = json({ ...body, ...error.requestId ? { requestId: error.requestId } : {} }, status);
|
|
70
|
+
if (error.requestId) response.headers.set("X-Request-ID", error.requestId);
|
|
71
|
+
return response;
|
|
72
|
+
}
|
|
73
|
+
function timedOut(operation) {
|
|
74
|
+
const { retryable, statuses: [status] } = ERROR_SEMANTICS[TIMED_OUT];
|
|
75
|
+
console.error(`realtime-avatar: platform did not answer ${operation} inside the route's budget; answered ${status}`);
|
|
76
|
+
return json({ code: TIMED_OUT, status, retryable }, status);
|
|
77
|
+
}
|
|
78
|
+
function throttled(error) {
|
|
79
|
+
const concurrency = error.code === "concurrency_limit_reached";
|
|
80
|
+
const counts = error.concurrency;
|
|
81
|
+
const message = concurrency ? counts?.maxConcurrentSessions !== void 0 && counts.activeSessions !== void 0 && counts.connectingSessions !== void 0 && counts.pendingSessions !== void 0 ? `Session limit reached (${counts.maxConcurrentSessions} allowed): ${counts.activeSessions} active, ${counts.connectingSessions} connecting, ${counts.pendingSessions} starting. End a session or wait for pending starts to clear, then retry.` : "The concurrent session limit is reached. Active and starting sessions count. End a session or wait for pending starts to clear, then retry." : "Too many requests. Wait before retrying.";
|
|
82
|
+
return {
|
|
83
|
+
error: message,
|
|
84
|
+
code: concurrency ? "concurrency_limit_reached" : "rate_limited",
|
|
85
|
+
status: 429,
|
|
86
|
+
retryable: error.retryable ?? true,
|
|
87
|
+
...concurrency ? counts : {}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function operationFor(pathname, method) {
|
|
91
|
+
const tail = pathname.replace(/\/+$/, "").split("/").pop() ?? "";
|
|
92
|
+
if (method === "POST" && (tail === "connect" || tail === "call")) return "connect";
|
|
93
|
+
if (method === "POST" && (tail === "end" || tail === "release")) return "end";
|
|
94
|
+
if (method === "GET" && tail === "avatars") return "avatars";
|
|
95
|
+
if (method === "GET" && tail === "credits") return "credits";
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
function createProxyHandler(config) {
|
|
99
|
+
const minted = /* @__PURE__ */ new Map();
|
|
100
|
+
const MINTED_TTL_MS = 30 * 6e4;
|
|
101
|
+
return async (request) => {
|
|
102
|
+
const url = new URL(request.url);
|
|
103
|
+
const operation = operationFor(url.pathname, request.method);
|
|
104
|
+
if (!operation) return json({ error: "not found" }, 404);
|
|
105
|
+
const refusal = await config.authorize?.({ request, operation });
|
|
106
|
+
if (refusal instanceof Response) return refusal;
|
|
107
|
+
const apiKey = typeof config.apiKey === "function" ? await config.apiKey() : config.apiKey;
|
|
108
|
+
const rta = new RealtimeAvatar({ apiKey, baseUrl: config.baseUrl, totalTimeoutMs: config.timeoutMs ?? ROUTE_TIMEOUT_MS });
|
|
109
|
+
try {
|
|
110
|
+
if (operation === "avatars") return json({ data: await rta.listAvatars() });
|
|
111
|
+
if (operation === "credits") return json(await rta.creditBalance());
|
|
112
|
+
const raw = await request.json().catch(() => void 0);
|
|
113
|
+
if (operation === "end") {
|
|
114
|
+
const parsed2 = proxyEndRequestSchema.safeParse(raw);
|
|
115
|
+
if (!parsed2.success) return json({ error: "session_id or queue_ticket_id is required" }, 422);
|
|
116
|
+
const ended = parsed2.data;
|
|
117
|
+
const reason = ended.reason === "page_hide" || ended.reason === "unmount" ? ended.reason : "manual";
|
|
118
|
+
const owned = async (id) => config.ownsSession ? await config.ownsSession({ request, sessionId: id }) : minted.delete(id);
|
|
119
|
+
if (ended.session_id !== void 0 && await owned(ended.session_id)) {
|
|
120
|
+
await rta.endCall(ended.session_id, { reason });
|
|
121
|
+
}
|
|
122
|
+
if (ended.queue_ticket_id !== void 0 && await owned(ended.queue_ticket_id)) {
|
|
123
|
+
await rta.leaveQueue(ended.queue_ticket_id, { reason });
|
|
124
|
+
}
|
|
125
|
+
return new Response(null, { status: 204 });
|
|
126
|
+
}
|
|
127
|
+
const parsed = proxyConnectRequestSchema.safeParse(raw);
|
|
128
|
+
if (!parsed.success) return json({ error: "avatarId is required" }, 422);
|
|
129
|
+
const body = parsed.data;
|
|
130
|
+
const mode = body.mode ?? "avatar";
|
|
131
|
+
const decided = await config.session?.({ request, avatarId: body.avatarId, mode });
|
|
132
|
+
if (decided instanceof Response) return decided;
|
|
133
|
+
const call = await rta.startCall({ avatarId: body.avatarId, mode, ...decided ?? {} });
|
|
134
|
+
if (isQueued(call)) {
|
|
135
|
+
if (call.queueTicketId) minted.set(call.queueTicketId, Date.now());
|
|
136
|
+
return json(
|
|
137
|
+
{
|
|
138
|
+
queued: true,
|
|
139
|
+
position: call.position,
|
|
140
|
+
size: call.size,
|
|
141
|
+
retryAfterMs: call.retryAfterMs,
|
|
142
|
+
queue_ticket_id: call.queueTicketId
|
|
143
|
+
},
|
|
144
|
+
429
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
for (const [id, at] of minted) if (now - at > MINTED_TTL_MS) minted.delete(id);
|
|
149
|
+
minted.set(call.sessionId, now);
|
|
150
|
+
return json(call.raw);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error instanceof RealtimeAvatarHttpError) return relayFailure(operation, error);
|
|
153
|
+
if (error instanceof RealtimeAvatarError && error.cause instanceof Error && error.cause.name === "TimeoutError") {
|
|
154
|
+
return timedOut(operation);
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export { createProxyHandler };
|
package/dist/express.d.ts
CHANGED
package/dist/express.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { createProxyHandler } from './chunk-
|
|
2
|
-
import './chunk-
|
|
1
|
+
import { createProxyHandler } from './chunk-XJPGQMEW.js';
|
|
2
|
+
import './chunk-HMESE54B.js';
|
|
3
3
|
import './chunk-MCIXU6XP.js';
|
|
4
4
|
|
|
5
5
|
// ../proxy/src/express.ts
|
|
@@ -16,7 +16,9 @@ function realtimeAvatarExpress(config) {
|
|
|
16
16
|
body: req.method === "GET" ? void 0 : JSON.stringify(req.body ?? {})
|
|
17
17
|
});
|
|
18
18
|
const response = await handler(request);
|
|
19
|
-
res.status(response.status).set("content-type", "application/json")
|
|
19
|
+
res.status(response.status).set("content-type", "application/json");
|
|
20
|
+
response.headers.forEach((value, field) => res.set(field, value));
|
|
21
|
+
res.send(await response.text());
|
|
20
22
|
};
|
|
21
23
|
}
|
|
22
24
|
|
package/dist/hono.d.ts
CHANGED
package/dist/hono.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -8,8 +8,17 @@ interface RealtimeAvatarOptions {
|
|
|
8
8
|
apiKey: string;
|
|
9
9
|
baseUrl?: string;
|
|
10
10
|
fetch?: typeof fetch;
|
|
11
|
-
/** Per-
|
|
11
|
+
/** Per-attempt timeout. Default 60s; starting a call does real work upstream. */
|
|
12
12
|
timeoutMs?: number;
|
|
13
|
+
/**
|
|
14
|
+
* The whole request: every attempt and the backoff between them. Default unbounded, so the
|
|
15
|
+
* worst case is `timeoutMs` per attempt plus up to 20s of `Retry-After` between them.
|
|
16
|
+
*
|
|
17
|
+
* Set it when something else is waiting on this request with a deadline of its own (a
|
|
18
|
+
* browser, a serverless max duration). An attempt is cut off at the budget, and a retry is
|
|
19
|
+
* started only if it can take as long as the attempt before it and still finish inside it.
|
|
20
|
+
*/
|
|
21
|
+
totalTimeoutMs?: number;
|
|
13
22
|
/**
|
|
14
23
|
* Extra attempts after a transient failure. Default 2, so 3 attempts in total.
|
|
15
24
|
*
|
|
@@ -73,6 +82,18 @@ declare class RealtimeAvatar {
|
|
|
73
82
|
* request body lets any visitor hang up any call on your account.
|
|
74
83
|
*/
|
|
75
84
|
endCall(sessionId: string, options?: EndCallOptions): Promise<boolean>;
|
|
85
|
+
/**
|
|
86
|
+
* Give up a QUEUED call's place in line, by the `queueTicketId` that `startCall` returned.
|
|
87
|
+
*
|
|
88
|
+
* A queued call has no session yet, so {@link endCall} cannot reach it: the release contract
|
|
89
|
+
* carries the ticket in its own field, and a ticket sent as a session id names nothing and is
|
|
90
|
+
* acknowledged as an idempotent no-op. The place is then held until its TTL, at the front of
|
|
91
|
+
* the queue, in front of everyone behind it.
|
|
92
|
+
*
|
|
93
|
+
* Same contract as `endCall`: best-effort, `true` only when the platform acknowledged it,
|
|
94
|
+
* never a throw, and only for tickets YOUR SERVER was handed.
|
|
95
|
+
*/
|
|
96
|
+
leaveQueue(queueTicketId: string, options?: Pick<EndCallOptions, "reason">): Promise<boolean>;
|
|
76
97
|
/**
|
|
77
98
|
* Register a character from a looping clip you host.
|
|
78
99
|
*
|
|
@@ -319,6 +340,8 @@ declare class RealtimeAvatarHttpError extends RealtimeAvatarError {
|
|
|
319
340
|
readonly code: string | undefined;
|
|
320
341
|
readonly body: string;
|
|
321
342
|
readonly requestId?: string;
|
|
343
|
+
/** The platform's own verdict on retrying as-is, when its body gave one. */
|
|
344
|
+
readonly retryable?: boolean;
|
|
322
345
|
readonly concurrency?: Partial<Record<"maxConcurrentSessions" | "liveSessions" | "activeSessions" | "connectingSessions" | "pendingSessions", number>>;
|
|
323
346
|
constructor(status: number, code: string | undefined, body: string, requestId?: string);
|
|
324
347
|
/** Out of credits, or over this key's spend limit. Surface a paywall, not an error. */
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { RealtimeAvatar, RealtimeAvatarError, RealtimeAvatarHttpError, clipLibraryDeclarationSchema, isQueued, listRecordingsResponseSchema, recordingAccessResponseSchema, recordingArtifactSchema, recordingModeSchema, verifyTranscript } from './chunk-
|
|
1
|
+
export { RealtimeAvatar, RealtimeAvatarError, RealtimeAvatarHttpError, clipLibraryDeclarationSchema, isQueued, listRecordingsResponseSchema, recordingAccessResponseSchema, recordingArtifactSchema, recordingModeSchema, verifyTranscript } from './chunk-HMESE54B.js';
|
|
2
2
|
import './chunk-MCIXU6XP.js';
|
package/dist/nextjs.d.ts
CHANGED
package/dist/nextjs.js
CHANGED