feat(inference): add avatar session#2101
Conversation
🦋 Changeset detectedLatest commit: 9c0e24c The changes in this PR will be included in the next version bump. This PR includes changesets to release 39 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
shawnfeldman
left a comment
There was a problem hiding this comment.
PR Review Summary
Reviewed the diff, cross-checked the wire contract against agent-gateway's actual pkg/handler/avatar.go (this repo), and verified the two most consequential claims independently against upstream source (livekit/node-sdks) rather than trusting review-agent output at face value.
Good news first: this port does not repeat the Critical bug that hit the source Python PR (livekit/agents#6492) before it merged — terminate_token is correctly captured from the create response (avatar.ts:250) and aclose() only calls terminate when both provider_session_id and terminate_token are present (avatar.ts:293), matching the gateway's all-three-required validation in handleTerminate. Field-by-field, the create/terminate payloads match the gateway's avatarRequest/avatarTerminateRequest structs exactly.
A few other things turned up, though:
Important Issues (should fix)
-
resolveRoomSidchecks aRoom.sidproperty that doesn't exist on@livekit/rtc-node, so it always takes the slow RPC path — avatar.ts:474-486const maybeRoom = room as Room & { sid?: string | Promise<string> }; if (typeof maybeRoom.sid === 'string') return maybeRoom.sid; if (maybeRoom.sid) return await maybeRoom.sid; const client = new RoomServiceClient(livekitUrl, livekitApiKey, livekitApiSecret); const rooms = await client.listRooms([roomName]); return rooms[0]?.sid;
Checked
livekit/node-sdks(packages/livekit-rtc/src/room.ts) directly:Roomhas no publicsidproperty, onlyprivate sidPromise+async getSid(): Promise<string>. Theas Room & { sid?: ... }cast just satisfies the type-checker — at runtimemaybeRoom.sidis alwaysundefined, so both fast-path branches are dead and every standalone (non-job-context)start()unconditionally makes an extraRoomServiceClient.listRooms()call thatroom.getSid()would avoid. Worse,room_sidis optional server-side (json:"room_sid,omitempty"), but this client turns a failure of that avoidable RPC into a hard failure of the whole avatar session (if (!roomSid) throwat line 231). The test doubles mask this —FakeConnectedRoomdefinessidas a plain string field, which the real SDK class doesn't have.
Fix: useawait room.getSid()as the primary source (falling back toRoomServiceClient.listRoomsonly if it returns''), and update the test doubles to implementgetSid()instead of asidfield. -
connOptions.timeoutMsis a documented, public option that's silently ignored — avatar.ts:31, avatar.ts:425
AvatarSessionOptions.connOptions: APIConnectOptionsdocumentstimeoutMsas the per-request connect timeout, butavatar.tsonly ever readsmaxRetry/retryIntervalMsfrom it — the actual abort timeout is the hardcoded module constantREQUEST_TIMEOUT_MS = 60000, used unconditionally inpostJson'sAbortSignal.timeout(REQUEST_TIMEOUT_MS). Sibling code in this same package (inference/llm.ts) correctly threadsthis.connOptions.timeoutMsthrough. A caller who passes a shorttimeoutMsexpecting fail-fast behavior silently gets 60s instead.
Fix:signal: AbortSignal.timeout(this.connOptions.timeoutMs), drop the now-unused constant. -
aclose()'s terminate call has no retry (unlike create) and unconditionally swallows every failure, bypassing the framework's own shutdown-failure escalation — avatar.ts:289-311, avatar.ts:394-405
_createSessionretries up toconnOptions.maxRetrytimes with backoff;_terminateSessionmakes exactly one attempt.aclose()'s catch logs awarnand returns normally for any error — a DNS blip, a 502/503/504, a JWT-signing failure — with no discrimination. That's a real cost here specifically because retrying terminate is safe (the gateway's own hop to the provider already retries teardown and treats 404/410 as success — confirmed inpkg/provider/avatar/lemonslice/lemonslice.goin this repo), and becauseaclose()never rejecting means the job-shutdown callback's existinglogger.error('error while shutting down the job')safety net (inipc/job_proc_lazy_main.ts) never engages for a terminate failure. In the shipped example,aclose()is never called directly — it only runs once, automatically, at job shutdown — so the warning's own suggested remedy ("callaclose()again") has no real trigger in the documented usage pattern. Net effect: one transient failure at shutdown silently leaks one idle-timeout window of billing with no escalation beyond awarnlog on an already-exiting process.
Fix: give_terminateSessionthe same retry/backoff_createSessionhas, and after exhausting retries either let the rejection propagate (so the existing shutdown error-logging fires) or log aterrorwith real escalation, not justwarn-and-continue. -
TOCTOU race in the double-start guard — avatar.ts:190 vs. avatar.ts:250
The guard (if (this._sessionId !== null || this._providerSessionId !== null) throw) is checked synchronously at the top ofstart(), but the fields it checks aren't set until after severalawaitpoints (worker-token mint, room-sid resolution, the gateway create call itself). Two overlapping, unawaited calls tostart()on the same instance both pass the guard and both create a real, billed provider session — whichever response is written last wins the instance's fields, and the other session's ids are never stored anywhere, making it permanently un-terminable via this class. The existing "start twice" test only covers the sequential/awaited case, so this has no coverage.
Fix: set a synchronous_startedflag (or small state enum) as the very first statement instart(), before anyawait, rather than inferring "already started" from response data that arrives after the fact. -
Gateway's
Retry-Afterhint is never read by the retry loop — avatar.ts:355-390
The gateway setsRetry-Afteron 429 (60s) and provider-503-capacity responses, and 429 is one of the three statuses_exceptions.tsmarks retryable — butpostJsonnever readsresponse.headers.get('Retry-After'), so the retry loop always waitsintervalForRetry(~2s default) regardless. With defaultmaxRetry: 3, the client exhausts its whole retry budget in ~6s against a server that explicitly asked for 60s, and fails a request that likely would have succeeded.
Fix: captureRetry-Afterin the thrown error and prefer it overintervalForRetrywhen present.
Suggestions
_createSession/_terminateSessionhave no access modifier (should beprivate, matching the treatment already given to_sessionId/_providerSessionId/_terminateTokenin the same class) —@internalis a real, working typedoc-exclusion convention here, not a runtime boundary, so this is a "make it actually private too" nit rather than a misrepresentation.- A create response of
{}(nosession_id/provider_session_id) would also defeat the double-start guard, since both are optional inCreateSessionResponse. In practice the real gateway'ssession_idfield is notomitempty(always present on a 201), so this can't happen against the actual server today — but the client shouldn't rely on that guarantee holding forever. ATTRIBUTE_PUBLISH_ON_BEHALFintypes.tsduplicates an existing constant of the same name/value already exported fromconstants.tsand consumed byvoice/room_io/room_io.ts— two sources of truth for the same wire string;avatar.tsshould import the existing one instead of redeclaring it.- A JSON-parse failure on a "successful" (200) response gets folded into the same generic retryable
APIConnectionErroras a real network error (toAPIError's fallback), so a deterministic server bug and a flaky connection look identical in logs and both burn the retry budget. - The "no
terminate_token, will bill until idle timeout" fact is logged atwarninstart()(line ~262) but onlydebugfor the equivalent case inaclose()(line ~304) — worth matching severity so it's visible at both ends of the session's life. - Test coverage:
start()andaclose()are never exercised as one pipeline (everyaclose()test hand-sets_providerSessionId/_terminateTokendirectly rather than letting them flow from a realstart()call); the documented "callaclose()again to retry a failed terminate" recovery path is never simulated;resolveRoomSid'sRoomServiceClientfallback branch is untested (every fixture room has a plain-string.sid, which — per finding #1 — doesn't reflect the real SDK shape); none ofstart()'s three defensive throws are triggered by any test; no test asserts on logger content, so a regression that silently drops one of the billing-risk warnings wouldn't be caught.
Strengths
- The
terminate_tokencapture-and-gate logic is correct and doesn't repeat the sibling Python PR's pre-fix Critical bug — verified field-by-field against the gateway's actual Go structs. - Idempotency-Key generation is correct: one key per
_createSession()call, stable across all retry attempts, with a dedicated test. _providerSessionId/_terminateTokenare captured before the laterDataStreamAudioOutputassignment, so a downstream throw instart()doesn't orphan a session that was actually created — a real, tested exception-safety property.aclose()is safe to call from every reachable state (never started, started-without-a-token, terminate-already-failed, already-terminated).- Construction-time validation (missing credentials,
avatar_id/image_urlmutual exclusivity) fails fast with clear errors before any network call is possible.
- Fall back to the job room name when the rtc room reports an empty name before connecting (`||` instead of `??`), fixing avatar startup in job mode. Exercise the empty-string path in the unit test. - Honor the caller-provided `connOptions.timeoutMs` for gateway requests instead of a hard-coded 60s, and drop the unused constant. - Call `avatar.waitForJoin()` after `session.start()` in the example so it waits on a connected room instead of returning immediately. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses the deeper review findings on the avatar session: - resolveRoomSid: use `room.getSid()` (the real @livekit/rtc-node API) as the primary source. The previous `room.sid` cast was always undefined at runtime, so every standalone start() made an avoidable RoomService RPC and turned an optional lookup into a hard failure. Falls back to listRooms only when getSid() is empty. Test doubles now implement getSid(). - aclose(): give _terminateSession the same retry/backoff as _createSession (terminate is safe to replay), and propagate a failure after base cleanup so the job-shutdown handler escalates it instead of a silent warn-and-continue. - start(): set a synchronous `_started` guard before any await so two overlapping start() calls can't both create a separately-billed provider session (the old guard read ids that are only set after several awaits). - postJson/retry loop: honor a gateway `Retry-After` hint (e.g. 60s on 429) over the default ~2s backoff so we don't exhaust the retry budget against a server that asked us to wait. Adds regression tests for each (concurrent-start guard, Retry-After backoff, terminate retry+escalation) and covers the getSid path via the test doubles. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What
Ports livekit/agents#6492 to agents-js by adding
inference.AvatarSession, a LiveKit Inference gateway-provisioned avatar session for LemonSlice and future avatar providers.Changes
agents/src/inference/avatar.tswith gateway create/terminate calls, local avatar worker token minting, retry/idempotency behavior, terminate token handling, response sample-rate audio output rebinding, double-start guarding, and standalone/job-context credential and room resolution.AvatarSession,LemonSliceOptions, and related types frominference.agents/src/inference/avatar.test.ts.@livekit/agents.No known target infrastructure gap remains. One agents-js adaptation was needed: before a JS room is connected,
Room.namecan be unset, soAvatarSession.start()falls back toJobContext.job.room.nameandJobContext.job.room.sidin job mode; standalone mode usesroom.sidwhen available or looks up the room viaRoomServiceClient.Source diff coverage
examples/avatar/README.mdplugins/lemonslice/README.md, because agents-js does not have anexamples/avatar/directory and the LemonSlice plugin README is the existing target-side avatar docs counterpart.examples/avatar/inference_agent.pyexamples/src/inference_avatar.tsusing agents-jsdefineAgent,AgentSession,Agent, and object-style inference model constructors. The behavior, env vars, and Inference avatar flow are preserved.livekit-agents/livekit/agents/inference/__init__.pyagents/src/inference/index.tsby exportingAvatarSession,LemonSliceOptions,parseAvatarModel, and related public types. The Python lazy PEP 562 export is not applicable to the TypeScript barrel because the target inference package does not need that circular-import workaround.livekit-agents/livekit/agents/inference/avatar.pyagents/src/inference/avatar.ts. Pythonaiohttpand context APIs were adapted tofetch/AbortSignal,livekit-server-sdkAccessToken, agents-jsvoice.AvatarSession/DataStreamAudioOutput, camelCase public constructor option names where the target convention already uses them, and JS room/job context shapes.tests/test_inference_avatar.pyagents/src/inference/avatar.test.tswith Vitest and fetch fakes, preserving the source coverage areas: model parsing, credential fallback, payload/header/idempotency behavior, retry/error handling, sample-rate rebinding, worker JWT grants/attributes, double-start guard, terminate behavior, and standalone/job room paths.Validation
pnpm vitest run agents/src/inference/avatar.test.tspasses: 29 tests.pnpm vitest run agentspasses: 104 files, 1393 tests, 5 skipped.pnpm vitest run plugins/lemonslicepasses: 2 files, 9 tests.pnpm buildpasses.pnpm --filter @livekit/agents lintpasses with pre-existing warnings only.pnpm format:checkpasses.cue-clitext-mode smoke with a temporary dispatchable JS worker passed, asserting an assistantconversation_item_addedevent afterinference.AvatarSession.start()with a mocked gateway response.Known unrelated validation failures:
pnpm vitest run examplescurrently fails in existingexamples/src/testing/agent_task.test.ts(No FunctionCallOutputEvent matching criteria found) and reports existing FakeLLM unhandled errors fromsurvey_agent.test.ts. The newexamples/src/inference_avatar.tsbuilds successfully viapnpm build.pnpm api:checkfails before this change's API surface can be checked because@livekit/agents-plugin-liveavatarhas noapi-extractor.json. Directpnpm --filter @livekit/agents api:checkis also blocked by the repo's existing API Extractor limitation onexport * assyntax inagents/dist/index.d.ts.Source PR: livekit/agents#6492
Ported from livekit/agents#6492
Original PR description
What
Adds
livekit.agents.inference.AvatarSession— a thin subclass ofvoice.avatar.AvatarSessionthat provisions an avatar provider session through the LiveKit Inference gateway (POST /v1/avatar/sessions) instead of a BYOK provider key.Why
BYOK avatar plugins require a customer provider key and bill LiveKit nothing for the avatar. Routing provisioning through Inference gives a single-key experience (
LIVEKIT_API_KEY/LIVEKIT_API_SECRETonly) and lets LiveKit meter avatar usage — matchinginference.STT/LLM/TTS. BYOK plugins stay as-is; this is an additive path.How
kind=agent,room_join,lk.publish_on_behalf, plus anlk.avatar_providerattribute for future metering) and sends a normalized payload to the gateway; the gateway calls the provider with LiveKit's wholesale key. Media/lip-sync still flow in-room overDataStreamAudioOutput— unchanged from BYOK."lemonslice"or"lemonslice/<agent_id>";image_url/prompt/idle_promptfirst-class;extra_kwargsfor provider-specific fields.start()(stable across retries) so a retried create replays on the gateway instead of creating a second paid session.aclose()terminates the provider session via the gateway (/v1/avatar/sessions/terminate) so it stops billing on shutdown rather than lingering to the idle timeout.sample_rateforDataStreamAudioOutput(no per-provider hardcoding). Works inside an agent job or standalone (derives identity/room-sid from the connected room when there's no job context).inference/__init__.pyto avoid a circular import during voice-package init.Files
livekit-agents/livekit/agents/inference/avatar.py(new)livekit-agents/livekit/agents/inference/__init__.py— lazyAvatarSessionexportexamples/avatar/inference_agent.py(new) + a note inexamples/avatar/README.mdtests/test_inference_avatar.py(new)Testing
pytest tests/test_inference_avatar.py— 19 passing: model-string parsing, credential resolution/env fallbacks, create payload/headers (incl. stable Idempotency-Key), retry/error mapping (403 non-retryable, 502 retried), responsesample_rate→DataStreamAudioOutput, andaclose()terminate (+ no-op when no session).ruffclean.Dependencies / rollout
avatar_lemonslicefeature flag enabled for the project (staging first). Until then,start()returns a403 not_enabled.agents/avatar/suite), which activates once this ships and the flag is on.Refs: LKINF-386
🤖 Generated with Claude Code