Skip to content

Commit 967160f

Browse files
authored
Standardize LanguageCode handling (#1120)
1 parent 60bd948 commit 967160f

46 files changed

Lines changed: 920 additions & 196 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/hot-planets-juggle.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@livekit/agents": patch
3+
"@livekit/agents-plugin-baseten": patch
4+
"@livekit/agents-plugin-cartesia": patch
5+
"@livekit/agents-plugin-deepgram": patch
6+
"@livekit/agents-plugin-elevenlabs": patch
7+
"@livekit/agents-plugin-google": patch
8+
"@livekit/agents-plugin-livekit": patch
9+
"@livekit/agents-plugin-neuphonic": patch
10+
"@livekit/agents-plugin-openai": patch
11+
"@livekit/agents-plugin-sarvam": patch
12+
---
13+
14+
Standardize LanguageCode handling

agents/src/index.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,33 +9,31 @@
99
* @see {@link https://docs.livekit.io/agents/overview | LiveKit Agents documentation}
1010
* @packageDocumentation
1111
*/
12-
import * as beta from './beta/index.js';
13-
import * as cli from './cli.js';
14-
import * as inference from './inference/index.js';
15-
import * as ipc from './ipc/index.js';
16-
import * as llm from './llm/index.js';
17-
import * as metrics from './metrics/index.js';
18-
import * as stream from './stream/index.js';
19-
import * as stt from './stt/index.js';
20-
import * as telemetry from './telemetry/index.js';
21-
import * as tokenize from './tokenize/index.js';
22-
import * as tts from './tts/index.js';
23-
import * as voice from './voice/index.js';
24-
2512
export * from './_exceptions.js';
2613
export * from './audio.js';
14+
export * as beta from './beta/index.js';
15+
export * as cli from './cli.js';
2716
export * from './connection_pool.js';
2817
export * from './generator.js';
18+
export * as inference from './inference/index.js';
2919
export * from './inference_runner.js';
20+
export * as ipc from './ipc/index.js';
3021
export * from './job.js';
22+
export * from './language.js';
23+
export * as llm from './llm/index.js';
3124
export * from './log.js';
25+
export * as metrics from './metrics/index.js';
3226
export * from './plugin.js';
27+
export * as stream from './stream/index.js';
28+
export * as stt from './stt/index.js';
29+
export * as telemetry from './telemetry/index.js';
30+
export * as tokenize from './tokenize/index.js';
3331
export * from './transcription.js';
32+
export * as tts from './tts/index.js';
3433
export * from './types.js';
3534
export * from './utils.js';
3635
export * from './vad.js';
3736
export * from './version.js';
37+
export * as voice from './voice/index.js';
3838
export { createTimedString, isTimedString, type TimedString } from './voice/io.js';
3939
export * from './worker.js';
40-
41-
export { beta, cli, inference, ipc, llm, metrics, stream, stt, telemetry, tokenize, tts, voice };

agents/src/inference/llm.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,11 @@
22
//
33
// SPDX-License-Identifier: Apache-2.0
44
import OpenAI from 'openai';
5-
import {
6-
APIConnectionError,
7-
APIStatusError,
8-
APITimeoutError,
9-
DEFAULT_API_CONNECT_OPTIONS,
10-
type Expand,
11-
toError,
12-
} from '../index.js';
5+
import { APIConnectionError, APIStatusError, APITimeoutError } from '../_exceptions.js';
136
import * as llm from '../llm/index.js';
7+
import { DEFAULT_API_CONNECT_OPTIONS } from '../types.js';
148
import type { APIConnectOptions } from '../types.js';
9+
import { type Expand, toError } from '../utils.js';
1510
import { type AnyString, createAccessToken } from './utils.js';
1611

1712
const DEFAULT_BASE_URL = 'https://agent-gateway.livekit.cloud/v1';

agents/src/inference/stt.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//
33
// SPDX-License-Identifier: Apache-2.0
44
import { beforeAll, describe, expect, it } from 'vitest';
5+
import { normalizeLanguage } from '../language.js';
56
import { initializeLogger } from '../log.js';
67
import { type APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS } from '../types.js';
78
import { STT, type STTFallbackModel, normalizeSTTFallback, parseSTTModelString } from './stt.js';
@@ -34,6 +35,12 @@ describe('parseSTTModelString', () => {
3435
expect(language).toBe('en');
3536
});
3637

38+
it('normalizes language suffixes', () => {
39+
const [model, language] = parseSTTModelString('deepgram:english');
40+
expect(model).toBe('deepgram');
41+
expect(language).toBe('en');
42+
});
43+
3744
it('provider/model format without language', () => {
3845
const [model, language] = parseSTTModelString('deepgram/nova-3');
3946
expect(model).toBe('deepgram/nova-3');
@@ -151,6 +158,16 @@ describe('normalizeSTTFallback', () => {
151158
});
152159

153160
describe('STT constructor fallback and connOptions', () => {
161+
it('normalizes language in constructor and model string', () => {
162+
const stt = makeStt({ model: 'deepgram/nova-3:english' });
163+
expect(stt['opts'].language).toBe('en');
164+
});
165+
166+
it('prefers explicit normalized language over model suffix', () => {
167+
const stt = makeStt({ model: 'deepgram/nova-3:english', language: 'en_US' });
168+
expect(stt['opts'].language).toBe(normalizeLanguage('en_US'));
169+
});
170+
154171
it('fallback not given defaults to undefined', () => {
155172
const stt = makeStt();
156173
expect(stt['opts'].fallback).toBeUndefined();

agents/src/inference/stt.ts

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type AudioFrame } from '@livekit/rtc-node';
55
import type { WebSocket } from 'ws';
66
import { APIError, APIStatusError } from '../_exceptions.js';
77
import { AudioByteStream } from '../audio.js';
8+
import { type LanguageCode, areLanguagesEquivalent, normalizeLanguage } from '../language.js';
89
import { log } from '../log.js';
910
import { createStreamChannel } from '../stream/stream_channel.js';
1011
import {
@@ -121,10 +122,10 @@ export interface STTFallbackModel {
121122
export type STTFallbackModelType = STTFallbackModel | string;
122123

123124
/** Parse a model string into [model, language]. Language is undefined if not specified. */
124-
export function parseSTTModelString(model: string): [string, string | undefined] {
125+
export function parseSTTModelString(model: string): [string, LanguageCode | undefined] {
125126
const idx = model.lastIndexOf(':');
126127
if (idx !== -1) {
127-
return [model.slice(0, idx), model.slice(idx + 1)];
128+
return [model.slice(0, idx), normalizeLanguage(model.slice(idx + 1))];
128129
}
129130
return [model, undefined];
130131
}
@@ -156,7 +157,7 @@ const DEFAULT_CANCEL_TIMEOUT = 5000;
156157

157158
export interface InferenceSTTOptions<TModel extends STTModels> {
158159
model?: TModel;
159-
language?: STTLanguages;
160+
language?: LanguageCode;
160161
encoding: STTEncoding;
161162
sampleRate: number;
162163
baseURL: string;
@@ -219,25 +220,24 @@ export class STT<TModel extends STTModels> extends BaseSTT {
219220
let nextModel = model;
220221
let nextLanguage = language;
221222
if (typeof nextModel === 'string') {
222-
const idx = nextModel.lastIndexOf(':');
223-
if (idx !== -1) {
224-
const languageFromModel = nextModel.slice(idx + 1) as STTLanguages;
225-
if (nextLanguage && nextLanguage !== languageFromModel) {
223+
const [parsedModel, parsedLanguage] = parseSTTModelString(nextModel);
224+
if (parsedLanguage !== undefined) {
225+
if (nextLanguage && !areLanguagesEquivalent(nextLanguage, parsedLanguage)) {
226226
this.#logger.warn(
227227
'`language` is provided via both argument and model, using the one from the argument',
228228
{ language: nextLanguage, model: nextModel },
229229
);
230230
} else {
231-
nextLanguage = languageFromModel;
231+
nextLanguage = parsedLanguage as STTLanguages;
232232
}
233-
nextModel = nextModel.slice(0, idx) as TModel;
233+
nextModel = parsedModel as TModel;
234234
}
235235
}
236236
const normalizedFallback = fallback ? normalizeSTTFallback(fallback) : undefined;
237237

238238
this.opts = {
239239
model: nextModel as TModel,
240-
language: nextLanguage,
240+
language: nextLanguage ? normalizeLanguage(nextLanguage) : undefined,
241241
encoding,
242242
sampleRate,
243243
baseURL: lkBaseURL,
@@ -263,7 +263,11 @@ export class STT<TModel extends STTModels> extends BaseSTT {
263263
}
264264

265265
updateOptions(opts: Partial<Pick<InferenceSTTOptions<TModel>, 'model' | 'language'>>): void {
266-
this.opts = { ...this.opts, ...opts };
266+
this.opts = {
267+
...this.opts,
268+
...opts,
269+
language: opts.language !== undefined ? normalizeLanguage(opts.language) : this.opts.language,
270+
};
267271

268272
for (const stream of this.streams) {
269273
stream.updateOptions(opts);
@@ -278,7 +282,7 @@ export class STT<TModel extends STTModels> extends BaseSTT {
278282
options || {};
279283
const streamOpts = {
280284
...this.opts,
281-
language: language ?? this.opts.language,
285+
language: language !== undefined ? normalizeLanguage(language) : this.opts.language,
282286
} as InferenceSTTOptions<TModel>;
283287

284288
const stream = new SpeechStream(this, streamOpts, connOptions);
@@ -364,7 +368,11 @@ export class SpeechStream<TModel extends STTModels> extends BaseSpeechStream {
364368
}
365369

366370
updateOptions(opts: Partial<Pick<InferenceSTTOptions<TModel>, 'model' | 'language'>>): void {
367-
this.opts = { ...this.opts, ...opts };
371+
this.opts = {
372+
...this.opts,
373+
...opts,
374+
language: opts.language !== undefined ? normalizeLanguage(opts.language) : this.opts.language,
375+
};
368376
this.reconnectEvent.set();
369377
}
370378

@@ -569,7 +577,7 @@ export class SpeechStream<TModel extends STTModels> extends BaseSpeechStream {
569577

570578
const requestId = data.session_id || this.requestId;
571579
const text = data.transcript;
572-
const language = data.language || this.opts.language || 'en';
580+
const language = normalizeLanguage(data.language || this.opts.language || 'en');
573581

574582
if (!text && !isFinal) return;
575583

agents/src/inference/tts.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
//
33
// SPDX-License-Identifier: Apache-2.0
44
import { beforeAll, describe, expect, it } from 'vitest';
5+
import { normalizeLanguage } from '../language.js';
56
import { initializeLogger } from '../log.js';
67
import { type APIConnectOptions, DEFAULT_API_CONNECT_OPTIONS } from '../types.js';
78
import { TTS, type TTSFallbackModel, normalizeTTSFallback, parseTTSModelString } from './tts.js';
@@ -165,6 +166,17 @@ describe('normalizeTTSFallback', () => {
165166
});
166167

167168
describe('TTS constructor fallback and connOptions', () => {
169+
it('normalizes language in constructor', () => {
170+
const tts = makeTts({ language: 'english' });
171+
expect(tts['opts'].language).toBe('en');
172+
});
173+
174+
it('normalizes updated language values', () => {
175+
const tts = makeTts();
176+
tts.updateOptions({ language: 'en_US' });
177+
expect(tts['opts'].language).toBe(normalizeLanguage('en_US'));
178+
});
179+
168180
it('fallback not given defaults to undefined', () => {
169181
const tts = makeTts();
170182
expect(tts['opts'].fallback).toBeUndefined();

agents/src/inference/tts.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { WebSocket } from 'ws';
66
import { APIError, APIStatusError } from '../_exceptions.js';
77
import { AudioByteStream } from '../audio.js';
88
import { ConnectionPool } from '../connection_pool.js';
9+
import { type LanguageCode, normalizeLanguage } from '../language.js';
910
import { log } from '../log.js';
1011
import { createStreamChannel } from '../stream/stream_channel.js';
1112
import { basic as tokenizeBasic } from '../tokenize/index.js';
@@ -150,7 +151,7 @@ const DEFAULT_LANGUAGE = 'en';
150151
export interface InferenceTTSOptions<TModel extends TTSModels> {
151152
model?: TModel;
152153
voice?: string;
153-
language?: string;
154+
language?: LanguageCode;
154155
encoding: TTSEncoding;
155156
sampleRate: number;
156157
baseURL: string;
@@ -236,7 +237,7 @@ export class TTS<TModel extends TTSModels> extends BaseTTS {
236237
this.opts = {
237238
model: nextModel,
238239
voice: nextVoice,
239-
language,
240+
language: normalizeLanguage(language),
240241
encoding,
241242
sampleRate,
242243
baseURL: lkBaseURL,
@@ -267,9 +268,13 @@ export class TTS<TModel extends TTSModels> extends BaseTTS {
267268
}
268269

269270
updateOptions(opts: Partial<Pick<InferenceTTSOptions<TModel>, 'model' | 'voice' | 'language'>>) {
270-
this.opts = { ...this.opts, ...opts };
271+
this.opts = {
272+
...this.opts,
273+
...opts,
274+
language: opts.language !== undefined ? normalizeLanguage(opts.language) : this.opts.language,
275+
};
271276
for (const stream of this.streams) {
272-
stream.updateOptions(opts);
277+
stream.updateOptions(this.opts);
273278
}
274279
}
275280

@@ -362,7 +367,11 @@ export class SynthesizeStream<TModel extends TTSModels> extends BaseSynthesizeSt
362367
}
363368

364369
updateOptions(opts: Partial<Pick<InferenceTTSOptions<TModel>, 'model' | 'voice' | 'language'>>) {
365-
this.opts = { ...this.opts, ...opts };
370+
this.opts = {
371+
...this.opts,
372+
...opts,
373+
language: opts.language !== undefined ? normalizeLanguage(opts.language) : this.opts.language,
374+
};
366375
}
367376

368377
protected async run(): Promise<void> {

agents/src/inference/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// SPDX-License-Identifier: Apache-2.0
44
import { AccessToken } from 'livekit-server-sdk';
55
import { WebSocket } from 'ws';
6-
import { APIConnectionError, APIStatusError } from '../index.js';
6+
import { APIConnectionError, APIStatusError } from '../_exceptions.js';
77

88
export type AnyString = string & NonNullable<unknown>;
99

agents/src/language.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// SPDX-FileCopyrightText: 2025 LiveKit, Inc.
2+
//
3+
// SPDX-License-Identifier: Apache-2.0
4+
import { describe, expect, it } from 'vitest';
5+
import {
6+
areLanguagesEquivalent,
7+
getBaseLanguage,
8+
getIsoLanguage,
9+
getLanguageRegion,
10+
normalizeLanguage,
11+
toLanguageName,
12+
} from './language.js';
13+
14+
describe('normalizeLanguage', () => {
15+
it('normalizes language names', () => {
16+
expect(normalizeLanguage('english')).toBe('en');
17+
});
18+
19+
it('normalizes iso 639-3 codes', () => {
20+
expect(normalizeLanguage('eng')).toBe('en');
21+
});
22+
23+
it('normalizes bcp-47 casing and separators', () => {
24+
expect(normalizeLanguage('en_us')).toBe('en-US');
25+
expect(normalizeLanguage('zh_hans_cn')).toBe('zh-Hans-CN');
26+
});
27+
28+
it('preserves iso 639-3 in compound tags', () => {
29+
expect(normalizeLanguage('cmn_hans_cn')).toBe('cmn-Hans-CN');
30+
});
31+
32+
it('passes unknown codes through in lowercase', () => {
33+
expect(normalizeLanguage('MULTI')).toBe('multi');
34+
});
35+
36+
it('preserves empty string sentinel', () => {
37+
expect(normalizeLanguage('')).toBe('');
38+
});
39+
});
40+
41+
describe('language helpers', () => {
42+
it('extracts base language', () => {
43+
expect(getBaseLanguage('cmn-Hans-CN')).toBe('zh');
44+
});
45+
46+
it('builds iso language tag', () => {
47+
expect(getIsoLanguage('cmn-Hans-CN')).toBe('zh-CN');
48+
});
49+
50+
it('extracts region', () => {
51+
expect(getLanguageRegion('en-US')).toBe('US');
52+
});
53+
54+
it('maps normalized code back to language name', () => {
55+
expect(toLanguageName('eng')).toBe('english');
56+
});
57+
58+
it('compares equivalent representations', () => {
59+
expect(areLanguagesEquivalent('english', 'en')).toBe(true);
60+
expect(areLanguagesEquivalent('en_us', 'en-US')).toBe(true);
61+
});
62+
});

0 commit comments

Comments
 (0)