Skip to content

Commit 87223ae

Browse files
feat(eot): allow disabling local fallback
1 parent 7d8cd69 commit 87223ae

4 files changed

Lines changed: 71 additions & 15 deletions

File tree

.changeset/tiny-clouds-wait.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@livekit/agents': minor
3+
---
4+
5+
Allow cloud turn detectors to disable fallback to the local model.

agents/src/inference/eot/base.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,12 @@ export class BaseStreamingTurnDetectorStream {
373373
this._audioInputNumChannels = undefined;
374374
}
375375

376+
/** Stop accepting audio and discard anything buffered for a transport that
377+
* can no longer consume it. */
378+
protected _discardAudioInput(): void {
379+
void this._audioChannel.abort(new Error('turn detection disabled')).catch(() => {});
380+
}
381+
376382
// endregion
377383

378384
// region: results

agents/src/inference/eot/detector.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@ describe('DetectorViewAfterFallback', () => {
371371
});
372372
// server defaults arrive, then the cloud session fails
373373
stream.thresholdsOptions._updateDefaults({ ...SERVER_THRESHOLDS }, SERVER_DEFAULT_THRESHOLD);
374-
stream._fallBackToLocal(new APIConnectionError({ message: 'boom' }));
374+
stream._fallbackToLocal(new APIConnectionError({ message: 'boom' }));
375375
await waitFor(() => stream.model === 'turn-detector-v1-mini');
376376

377377
// Both the stream and the detector (sharing one ThresholdOptions) reflect it.
@@ -408,7 +408,7 @@ describe('WarningDedupe', () => {
408408
const stream = makeStreamWithTransport(transport);
409409
await waitFor(() => stream.model === 'turn-detector-v1-mini');
410410
// Trigger a second fallback path directly.
411-
stream._fallBackToLocal(new APIConnectionError({ message: 'boom2' }));
411+
stream._fallbackToLocal(new APIConnectionError({ message: 'boom2' }));
412412
// Across both invocations only one warning was emitted — tracked by
413413
// the `warnedCloudFailure` flag staying flipped after the first call.
414414
expect(stream.warnedCloudFailure).toBe(true);
@@ -702,7 +702,7 @@ describe('ThresholdRescaleOnFallback', () => {
702702
const transport = new ScriptedTransport({ runBehavior: 'idle' });
703703
const stream = makeStreamWithTransport(transport, { userThreshold: 0.5 });
704704
stream.thresholdsOptions._updateDefaults({ ...SERVER_THRESHOLDS }, SERVER_DEFAULT_THRESHOLD);
705-
stream._fallBackToLocal(new APIConnectionError({ message: 'boom' }));
705+
stream._fallbackToLocal(new APIConnectionError({ message: 'boom' }));
706706
await waitFor(() => stream.model === 'turn-detector-v1-mini');
707707
expect(stream.isFallback).toBe(true);
708708
expect(await stream.unlikelyThreshold('en')).toBeCloseTo(
@@ -715,7 +715,7 @@ describe('ThresholdRescaleOnFallback', () => {
715715
const transport = new ScriptedTransport({ runBehavior: 'idle' });
716716
const stream = makeStreamWithTransport(transport);
717717
stream.thresholdsOptions._updateDefaults({ ...SERVER_THRESHOLDS }, SERVER_DEFAULT_THRESHOLD);
718-
stream._fallBackToLocal(new APIConnectionError({ message: 'boom' }));
718+
stream._fallbackToLocal(new APIConnectionError({ message: 'boom' }));
719719
await waitFor(() => stream.model === 'turn-detector-v1-mini');
720720
// ratio 1.0 → local table unchanged
721721
expect(await stream.unlikelyThreshold('en')).toBeCloseTo(LOCAL_LANGUAGES.en!);
@@ -726,7 +726,7 @@ describe('ThresholdRescaleOnFallback', () => {
726726
const transport = new ScriptedTransport({ runBehavior: 'idle' });
727727
const stream = makeStreamWithTransport(transport, { userThreshold: { en: 0.55, ja: 0.25 } });
728728
stream.thresholdsOptions._updateDefaults({ ...SERVER_THRESHOLDS }, SERVER_DEFAULT_THRESHOLD);
729-
stream._fallBackToLocal(new APIConnectionError({ message: 'boom' }));
729+
stream._fallbackToLocal(new APIConnectionError({ message: 'boom' }));
730730
await waitFor(() => stream.model === 'turn-detector-v1-mini');
731731
expect(stream.isFallback).toBe(true);
732732
expect(await stream.unlikelyThreshold('en')).toBeCloseTo(

agents/src/inference/eot/detector.ts

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,13 @@ export interface TurnDetectorOptions {
4444
apiSecret?: string;
4545
/** Sample rate (Hz). Defaults to 16000. */
4646
sampleRate?: number;
47+
/**
48+
* Whether a `v1` detector may degrade to the local `v1-mini` model when the
49+
* gateway fails. `false` keeps it cloud-only, so the mini weights (~138 MB,
50+
* resident for the process lifetime) are never loaded and turns commit on
51+
* the endpointing delay instead.
52+
*/
53+
localFallback?: boolean;
4754
connOptions?: APIConnectOptions;
4855
/**
4956
* Inference executor that runs the local `turn-detector-v1-mini` model in the
@@ -59,6 +66,7 @@ export class TurnDetector extends BaseStreamingTurnDetector {
5966
protected _model: TurnDetectorModel;
6067
protected _cloudOpts: CloudTransportOptions | undefined;
6168
protected _executor: InferenceExecutor | undefined;
69+
protected _localFallback: boolean;
6270

6371
constructor(opts: TurnDetectorOptions = {}) {
6472
// auto = caller didn't pin a version; missing cloud creds warn-and-
@@ -118,6 +126,13 @@ export class TurnDetector extends BaseStreamingTurnDetector {
118126
super(detectorOpts);
119127
this._model = resolvedModel;
120128
this._cloudOpts = cloudOpts;
129+
this._localFallback = opts.localFallback ?? true;
130+
if (!this._localFallback && resolvedModel === 'turn-detector-v1-mini') {
131+
log().warn(
132+
{ model: resolvedModel },
133+
'localFallback=false has no effect on the local model, which runs locally by design',
134+
);
135+
}
121136
this._warnThresholdOverride();
122137
// Default to the current job's shared inference executor. `getJobContext`
123138
// throws outside a job (tests, standalone) — degrade to `undefined`
@@ -208,6 +223,7 @@ export class TurnDetector extends BaseStreamingTurnDetector {
208223
opts: this._opts,
209224
cloudOpts,
210225
model: this._model,
226+
localFallback: this._localFallback,
211227
executor: this._executor,
212228
});
213229
this._streams.add(stream);
@@ -220,6 +236,7 @@ export interface TurnDetectorStreamImplArgs {
220236
opts: BaseStreamingTurnDetectorOptions;
221237
cloudOpts: CloudTransportOptions | undefined;
222238
model: TurnDetectorModel;
239+
localFallback?: boolean;
223240
/** Shared inference executor for the `turn-detector-v1-mini` (local) model
224241
* (undefined degrades to a positive-default prediction). */
225242
executor?: InferenceExecutor;
@@ -239,7 +256,9 @@ export class TurnDetectorStreamImpl extends BaseStreamingTurnDetectorStream {
239256
protected _model: TurnDetectorModel;
240257
protected _cloudOpts: CloudTransportOptions | undefined;
241258
protected _executor: InferenceExecutor | undefined;
259+
protected _localFallback: boolean;
242260
protected _isFallback = false;
261+
protected _isDegraded = false;
243262
protected _warnedCloudFailure = false;
244263
protected _warnedLocalFailure = false;
245264
private _detLogger = log();
@@ -258,6 +277,7 @@ export class TurnDetectorStreamImpl extends BaseStreamingTurnDetectorStream {
258277
this._model = args.model;
259278
this._cloudOpts = args.cloudOpts;
260279
this._executor = args.executor;
280+
this._localFallback = args.localFallback ?? true;
261281
}
262282

263283
/** This stream's *current* model name (flips to `'turn-detector-v1-mini'`
@@ -271,6 +291,11 @@ export class TurnDetectorStreamImpl extends BaseStreamingTurnDetectorStream {
271291
return this._isFallback;
272292
}
273293

294+
/** True once the cloud transport is gone with no local model to replace it. */
295+
get isDegraded(): boolean {
296+
return this._isDegraded;
297+
}
298+
274299
/** @internal Test-visible. */
275300
get warnedCloudFailure(): boolean {
276301
return this._warnedCloudFailure;
@@ -287,7 +312,12 @@ export class TurnDetectorStreamImpl extends BaseStreamingTurnDetectorStream {
287312
/** @internal Test-visible: same logic as the path taken when `_run` catches
288313
* a cloud transport error. Tests call this directly to verify the warning
289314
* dedupe across multiple invocations on the same stream. */
290-
_fallBackToLocal(reason: Error): void {
315+
_fallbackToLocal(reason: Error): boolean {
316+
if (!this._localFallback) {
317+
this._emitDefaultForInflight();
318+
return false;
319+
}
320+
291321
if (!this._warnedCloudFailure) {
292322
this._detLogger.warn(
293323
{ reason: reason.message },
@@ -313,6 +343,22 @@ export class TurnDetectorStreamImpl extends BaseStreamingTurnDetectorStream {
313343
this._transport.attach(this);
314344
this._model = 'turn-detector-v1-mini';
315345
this._isFallback = true;
346+
return true;
347+
}
348+
349+
protected _degrade(reason: Error): void {
350+
this._detLogger.warn(
351+
{ reason: reason.message },
352+
'cloud turn detector failed; local fallback is disabled, so turn detection is off for ' +
353+
'the rest of this stream and turns commit on the endpointing delay',
354+
);
355+
this._isDegraded = true;
356+
try {
357+
this._transport.detach();
358+
} catch {
359+
// ignore detach errors while terminating the failed transport
360+
}
361+
this._discardAudioInput();
316362
}
317363

318364
/** @internal Test-visible: same logic as the path taken when `_run` sees a
@@ -366,8 +412,11 @@ export class TurnDetectorStreamImpl extends BaseStreamingTurnDetectorStream {
366412
}
367413
const e = err instanceof Error ? err : new Error(String(err));
368414
if (this._model === 'turn-detector-v1') {
369-
this._fallBackToLocal(e);
370-
continue;
415+
if (this._fallbackToLocal(e)) {
416+
continue;
417+
}
418+
this._degrade(e);
419+
return;
371420
}
372421
this._onLocalFailure(e);
373422
return;
@@ -377,13 +426,9 @@ export class TurnDetectorStreamImpl extends BaseStreamingTurnDetectorStream {
377426

378427
protected override _onPredictTimeout(): void {
379428
if (this._model === 'turn-detector-v1') {
380-
// Signal the swap BEFORE mutating model/transport state. The
381-
// race in `_raceWithSwap` is rejected with `SwapAbortError`
382-
// immediately, so the main loop exits through the
383-
// SwapAbortError branch and never consults `_model` for a
384-
// classification that would race with the assignment below.
385-
this._signalSwap();
386-
this._fallBackToLocal(new Error('predict_end_of_turn'));
429+
if (this._fallbackToLocal(new Error('predict_end_of_turn'))) {
430+
this._signalSwap();
431+
}
387432
}
388433
}
389434
}

0 commit comments

Comments
 (0)