@vellumai/assistant 0.11.3-staging.2 → 0.11.3-staging.4

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.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/call-controller.test.ts +120 -0
  3. package/src/__tests__/config-loader-backfill.test.ts +19 -0
  4. package/src/__tests__/config-schema.test.ts +142 -5
  5. package/src/__tests__/events-dev-bypass-actor.test.ts +112 -1
  6. package/src/__tests__/media-stream-output.test.ts +175 -0
  7. package/src/__tests__/media-stream-stt-session.test.ts +67 -0
  8. package/src/calls/__tests__/tts-text-sanitizer.test.ts +13 -0
  9. package/src/calls/__tests__/voice-session-bridge.test.ts +81 -12
  10. package/src/calls/__tests__/voice-triage-escalate.test.ts +106 -0
  11. package/src/calls/call-controller.ts +30 -2
  12. package/src/calls/call-speech-output.ts +12 -4
  13. package/src/calls/call-transport.ts +18 -1
  14. package/src/calls/media-stream-output.ts +53 -5
  15. package/src/calls/media-stream-server.ts +12 -0
  16. package/src/calls/media-stream-stt-session.ts +34 -0
  17. package/src/calls/telephony-synthesis-language.ts +84 -0
  18. package/src/calls/tts-text-sanitizer.ts +12 -5
  19. package/src/calls/voice-session-bridge.ts +31 -3
  20. package/src/calls/voice-triage-escalate.ts +52 -5
  21. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +15 -15
  22. package/src/config/loader.ts +5 -0
  23. package/src/config/schemas/__tests__/live-voice.test.ts +35 -0
  24. package/src/config/schemas/calls.ts +0 -4
  25. package/src/config/schemas/live-voice.ts +25 -0
  26. package/src/config/schemas/tts.ts +63 -0
  27. package/src/live-voice/__tests__/front-decision.test.ts +120 -0
  28. package/src/live-voice/__tests__/live-voice-events.test.ts +1 -1
  29. package/src/live-voice/__tests__/live-voice-integration.test.ts +5 -0
  30. package/src/live-voice/__tests__/live-voice-progress.test.ts +178 -11
  31. package/src/live-voice/__tests__/live-voice-stt.test.ts +304 -1
  32. package/src/live-voice/__tests__/live-voice-triage-escalate.test.ts +102 -2
  33. package/src/live-voice/__tests__/live-voice-tts.test.ts +148 -2
  34. package/src/live-voice/__tests__/live-voice-vad.test.ts +278 -1
  35. package/src/live-voice/__tests__/progress-phrases.test.ts +167 -0
  36. package/src/live-voice/front-decision.ts +50 -3
  37. package/src/live-voice/live-voice-session.ts +517 -45
  38. package/src/live-voice/live-voice-tts.ts +18 -2
  39. package/src/live-voice/progress-phrases.ts +105 -2
  40. package/src/providers/speech-to-text/deepgram-realtime.test.ts +283 -1
  41. package/src/providers/speech-to-text/deepgram-realtime.ts +117 -3
  42. package/src/providers/speech-to-text/provider-catalog.ts +38 -0
  43. package/src/runtime/__tests__/local-actor-identity-force-refresh.test.ts +104 -0
  44. package/src/runtime/assistant-event-hub.ts +23 -0
  45. package/src/runtime/local-actor-identity.ts +18 -5
  46. package/src/runtime/routes/__tests__/sse-actor-principal-heal.test.ts +165 -0
  47. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -0
  48. package/src/runtime/routes/events-routes.ts +17 -16
  49. package/src/runtime/routes/sse-actor-principal-heal.ts +113 -0
  50. package/src/stt/__tests__/language-metadata.test.ts +85 -0
  51. package/src/stt/__tests__/speech-energy.test.ts +79 -0
  52. package/src/stt/language-metadata.ts +65 -0
  53. package/src/stt/speech-energy.ts +115 -12
  54. package/src/stt/types.ts +16 -0
  55. package/src/tts/__tests__/provider-adapters.test.ts +147 -0
  56. package/src/tts/__tests__/speakable-segments.test.ts +470 -0
  57. package/src/tts/language-voices.ts +23 -0
  58. package/src/tts/providers/deepgram-provider.ts +3 -1
  59. package/src/tts/providers/elevenlabs-provider.ts +73 -1
  60. package/src/tts/providers/xai-provider.ts +28 -2
  61. package/src/tts/speakable-segments.ts +293 -23
  62. package/src/tts/synthesis-stream.ts +7 -0
  63. package/src/tts/types.ts +7 -0
  64. package/src/util/__tests__/language-subtag.test.ts +54 -0
  65. package/src/util/language-subtag.ts +43 -0
  66. package/src/util/unicode.ts +1 -1
@@ -3,6 +3,8 @@ import { describe, expect, test } from "bun:test";
3
3
  import {
4
4
  DEFAULT_SPEECH_ENERGY_THRESHOLD,
5
5
  detectPcm16SpeechActivity,
6
+ pcm16MaxNormalizedCorrelation,
7
+ pcm16MeanAmplitude,
6
8
  } from "../speech-energy.js";
7
9
 
8
10
  /** Build a PCM16LE buffer from an array of sample values. */
@@ -66,3 +68,80 @@ describe("detectPcm16SpeechActivity", () => {
66
68
  expect(detectPcm16SpeechActivity(quiet, 500)).toBe(false);
67
69
  });
68
70
  });
71
+
72
+ describe("pcm16MeanAmplitude", () => {
73
+ test("returns 0 for an empty buffer", () => {
74
+ expect(pcm16MeanAmplitude(Buffer.alloc(0))).toBe(0);
75
+ });
76
+
77
+ test("returns the exact mean absolute amplitude", () => {
78
+ expect(pcm16MeanAmplitude(pcm16([1_000, -2_000, 3_000, -4_000]))).toBe(
79
+ 2_500,
80
+ );
81
+ });
82
+
83
+ test("ignores a trailing odd byte", () => {
84
+ const samples = pcm16([3_000, -3_000]);
85
+ expect(
86
+ pcm16MeanAmplitude(Buffer.concat([samples, Buffer.from([0x01])])),
87
+ ).toBe(3_000);
88
+ });
89
+
90
+ test("matches the detector's threshold comparison", () => {
91
+ const chunks = [
92
+ Buffer.alloc(0),
93
+ pcm16([0, 0]),
94
+ pcm16([500, -500]),
95
+ pcm16([DEFAULT_SPEECH_ENERGY_THRESHOLD]),
96
+ pcm16([DEFAULT_SPEECH_ENERGY_THRESHOLD + 1]),
97
+ ];
98
+ for (const chunk of chunks) {
99
+ expect(detectPcm16SpeechActivity(chunk)).toBe(
100
+ pcm16MeanAmplitude(chunk) > DEFAULT_SPEECH_ENERGY_THRESHOLD,
101
+ );
102
+ }
103
+ });
104
+ });
105
+
106
+ describe("pcm16MaxNormalizedCorrelation", () => {
107
+ const wave = (frequency: number, count: number): Buffer =>
108
+ pcm16(
109
+ Array.from({ length: count }, (_, index) =>
110
+ Math.round(
111
+ 8_000 * Math.sin((2 * Math.PI * frequency * index) / 16_000),
112
+ ),
113
+ ),
114
+ );
115
+
116
+ test("finds a matching waveform at an arbitrary reference offset", () => {
117
+ const input = wave(240, 800);
118
+ const reference = Buffer.concat([wave(410, 400), input, wave(610, 400)]);
119
+
120
+ expect(pcm16MaxNormalizedCorrelation(input, reference)).toBeGreaterThan(
121
+ 0.99,
122
+ );
123
+ });
124
+
125
+ test("is invariant to gain and polarity", () => {
126
+ const inputSamples = Array.from({ length: 800 }, (_, index) =>
127
+ Math.round(5_000 * Math.sin((2 * Math.PI * index) / 83)),
128
+ );
129
+ const inverted = pcm16(inputSamples.map((sample) => -2 * sample));
130
+
131
+ expect(
132
+ pcm16MaxNormalizedCorrelation(pcm16(inputSamples), inverted),
133
+ ).toBeGreaterThan(0.99);
134
+ });
135
+
136
+ test("rejects an unrelated waveform and flat power", () => {
137
+ expect(
138
+ pcm16MaxNormalizedCorrelation(wave(240, 800), wave(610, 1_600)),
139
+ ).toBeLessThan(0.3);
140
+ expect(
141
+ pcm16MaxNormalizedCorrelation(
142
+ pcm16(new Array(800).fill(3_000)),
143
+ pcm16(new Array(1_600).fill(3_000)),
144
+ ),
145
+ ).toBe(0);
146
+ });
147
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pure helpers for tallying and ranking detected-language tags emitted
3
+ * by streaming STT providers (e.g. Deepgram nova-3 `multi` per-word tags).
4
+ *
5
+ * Tag normalization is `baseLanguageSubtag` (util/language-subtag.ts);
6
+ * these helpers layer vote-counting on top of it so provider adapters and
7
+ * event consumers share one notion of a dominant language.
8
+ */
9
+
10
+ import { baseLanguageSubtag } from "../util/language-subtag.js";
11
+
12
+ /**
13
+ * Cast one vote into a language tally for a transcript chunk's dominant
14
+ * detected language. `languages` is dominance-ranked, so only the first
15
+ * entry votes: secondary tags at full weight would let a minority
16
+ * language outvote the dominant one. Blank and absent tags cast no vote;
17
+ * regional variants count toward their base subtag.
18
+ */
19
+ export function voteDominantLanguage(
20
+ tally: Map<string, number>,
21
+ languages: readonly string[] | undefined,
22
+ ): void {
23
+ const dominant = baseLanguageSubtag(languages?.[0]);
24
+ if (dominant !== undefined) {
25
+ tally.set(dominant, (tally.get(dominant) ?? 0) + 1);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * The dominant tag in a language tally: most votes wins, ties break by
31
+ * first insertion (Map iteration is insertion order). Undefined for an
32
+ * empty tally.
33
+ */
34
+ export function dominantLanguageTag(
35
+ tally: ReadonlyMap<string, number>,
36
+ ): string | undefined {
37
+ let dominant: string | undefined;
38
+ let dominantCount = 0;
39
+ for (const [tag, count] of tally) {
40
+ if (count > dominantCount) {
41
+ dominant = tag;
42
+ dominantCount = count;
43
+ }
44
+ }
45
+ return dominant;
46
+ }
47
+
48
+ /**
49
+ * Tally normalized language tags and return them most-frequent-first.
50
+ * Ties are broken by first appearance in the input. Blank tags are
51
+ * skipped; regional variants count toward their base subtag.
52
+ */
53
+ export function rankLanguages(tags: Iterable<string>): string[] {
54
+ // Map iteration order is insertion order, so a stable sort by count
55
+ // leaves tied tags in first-appearance order.
56
+ const counts = new Map<string, number>();
57
+ for (const tag of tags) {
58
+ const normalized = baseLanguageSubtag(tag);
59
+ if (normalized === undefined) {
60
+ continue;
61
+ }
62
+ counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
63
+ }
64
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([tag]) => tag);
65
+ }
@@ -18,6 +18,120 @@
18
18
  */
19
19
  export const DEFAULT_SPEECH_ENERGY_THRESHOLD = 800;
20
20
 
21
+ /**
22
+ * Return the mean absolute amplitude of little-endian signed PCM16 audio.
23
+ * Empty buffers return 0, and a trailing odd byte is ignored.
24
+ */
25
+ export function pcm16MeanAmplitude(chunk: Buffer): number {
26
+ const sampleCount = Math.floor(chunk.length / 2);
27
+ if (sampleCount === 0) {
28
+ return 0;
29
+ }
30
+
31
+ let totalAmplitude = 0;
32
+ for (let i = 0; i < sampleCount; i += 1) {
33
+ totalAmplitude += Math.abs(chunk.readInt16LE(i * 2));
34
+ }
35
+ return totalAmplitude / sampleCount;
36
+ }
37
+
38
+ /**
39
+ * Find the strongest normalized correlation between a PCM16 input window and
40
+ * any same-length window in a PCM16 reference. The samples are block-averaged
41
+ * before matching so this stays cheap enough for live audio and remains
42
+ * tolerant of the low-pass filtering introduced by speakers and microphones.
43
+ *
44
+ * The absolute coefficient makes polarity inversion harmless. A flat input or
45
+ * reference has no identifying waveform and returns 0 instead of being treated
46
+ * as a match based on level alone.
47
+ */
48
+ export function pcm16MaxNormalizedCorrelation(
49
+ input: Buffer,
50
+ reference: Buffer,
51
+ downsampleFactor = 8,
52
+ ): number {
53
+ if (!Number.isInteger(downsampleFactor) || downsampleFactor <= 0) {
54
+ return 0;
55
+ }
56
+
57
+ let inputSamples = blockAveragePcm16(input, downsampleFactor);
58
+ const referenceSamples = blockAveragePcm16(reference, downsampleFactor);
59
+ if (inputSamples.length > referenceSamples.length) {
60
+ inputSamples = inputSamples.subarray(0, referenceSamples.length);
61
+ }
62
+ if (inputSamples.length < 2) {
63
+ return 0;
64
+ }
65
+
66
+ let inputSum = 0;
67
+ for (let index = 0; index < inputSamples.length; index += 1) {
68
+ inputSum += inputSamples[index]!;
69
+ }
70
+ const inputMean = inputSum / inputSamples.length;
71
+ const centeredInput = new Float64Array(inputSamples.length);
72
+ let inputEnergy = 0;
73
+ for (let index = 0; index < inputSamples.length; index += 1) {
74
+ const centered = inputSamples[index]! - inputMean;
75
+ centeredInput[index] = centered;
76
+ inputEnergy += centered * centered;
77
+ }
78
+ if (inputEnergy === 0) {
79
+ return 0;
80
+ }
81
+
82
+ const prefixSum = new Float64Array(referenceSamples.length + 1);
83
+ const prefixSquareSum = new Float64Array(referenceSamples.length + 1);
84
+ for (let index = 0; index < referenceSamples.length; index += 1) {
85
+ const sample = referenceSamples[index]!;
86
+ prefixSum[index + 1] = prefixSum[index]! + sample;
87
+ prefixSquareSum[index + 1] = prefixSquareSum[index]! + sample * sample;
88
+ }
89
+
90
+ let best = 0;
91
+ const windowLength = inputSamples.length;
92
+ for (
93
+ let offset = 0;
94
+ offset + windowLength <= referenceSamples.length;
95
+ offset += 1
96
+ ) {
97
+ const referenceSum = prefixSum[offset + windowLength]! - prefixSum[offset]!;
98
+ const referenceSquareSum =
99
+ prefixSquareSum[offset + windowLength]! - prefixSquareSum[offset]!;
100
+ const referenceEnergy =
101
+ referenceSquareSum - (referenceSum * referenceSum) / windowLength;
102
+ if (referenceEnergy <= 0) {
103
+ continue;
104
+ }
105
+
106
+ let dotProduct = 0;
107
+ for (let index = 0; index < windowLength; index += 1) {
108
+ dotProduct += centeredInput[index]! * referenceSamples[offset + index]!;
109
+ }
110
+ const correlation =
111
+ Math.abs(dotProduct) / Math.sqrt(inputEnergy * referenceEnergy);
112
+ best = Math.max(best, Math.min(correlation, 1));
113
+ }
114
+ return best;
115
+ }
116
+
117
+ function blockAveragePcm16(
118
+ chunk: Buffer,
119
+ downsampleFactor: number,
120
+ ): Float64Array {
121
+ const sampleCount = Math.floor(chunk.length / 2);
122
+ const blockCount = Math.floor(sampleCount / downsampleFactor);
123
+ const result = new Float64Array(blockCount);
124
+ for (let block = 0; block < blockCount; block += 1) {
125
+ let sum = 0;
126
+ const firstSample = block * downsampleFactor;
127
+ for (let offset = 0; offset < downsampleFactor; offset += 1) {
128
+ sum += chunk.readInt16LE((firstSample + offset) * 2);
129
+ }
130
+ result[block] = sum / downsampleFactor;
131
+ }
132
+ return result;
133
+ }
134
+
21
135
  /**
22
136
  * Detect speech activity in a chunk of little-endian signed 16-bit mono
23
137
  * PCM samples.
@@ -34,16 +148,5 @@ export function detectPcm16SpeechActivity(
34
148
  chunk: Buffer,
35
149
  threshold = DEFAULT_SPEECH_ENERGY_THRESHOLD,
36
150
  ): boolean {
37
- const sampleCount = Math.floor(chunk.length / 2);
38
- if (sampleCount === 0) {
39
- return false;
40
- }
41
-
42
- let totalAmplitude = 0;
43
- for (let i = 0; i < sampleCount; i++) {
44
- totalAmplitude += Math.abs(chunk.readInt16LE(i * 2));
45
- }
46
- const avgAmplitude = totalAmplitude / sampleCount;
47
-
48
- return avgAmplitude > threshold;
151
+ return pcm16MeanAmplitude(chunk) > threshold;
49
152
  }
package/src/stt/types.ts CHANGED
@@ -263,6 +263,14 @@ export interface SttStreamServerPartialEvent {
263
263
  * provider does not surface confidence on interim chunks.
264
264
  */
265
265
  readonly confidence?: number;
266
+ /**
267
+ * All detected languages of this chunk in dominance order (first entry
268
+ * dominant), already normalized to lowercase base subtags (e.g. "en",
269
+ * "hi") by the emitting adapter; consumers must not re-normalize. Only
270
+ * providers with code-switching metadata (currently Deepgram nova-3
271
+ * `multi`) populate this. Absence means "unknown", never English.
272
+ */
273
+ readonly languages?: readonly string[];
266
274
  }
267
275
 
268
276
  /**
@@ -279,6 +287,14 @@ export interface SttStreamServerFinalEvent {
279
287
  * provider does not surface confidence on this chunk.
280
288
  */
281
289
  readonly confidence?: number;
290
+ /**
291
+ * All detected languages of this chunk in dominance order (first entry
292
+ * dominant), already normalized to lowercase base subtags (e.g. "en",
293
+ * "hi") by the emitting adapter; consumers must not re-normalize. Only
294
+ * providers with code-switching metadata (currently Deepgram nova-3
295
+ * `multi`) populate this. Absence means "unknown", never English.
296
+ */
297
+ readonly languages?: readonly string[];
282
298
  /**
283
299
  * True when this final is the flush response to
284
300
  * {@link StreamingTranscriber.finalizeUtterance} — i.e. it commits audio
@@ -549,6 +549,81 @@ describe("ElevenLabs TTS provider adapter", () => {
549
549
 
550
550
  const body = JSON.parse(capturedBody);
551
551
  expect(body.model_id).toBe("eleven_flash_v2_5");
552
+ expect("language_code" in body).toBe(false);
553
+ });
554
+
555
+ test("synthesizeStream sends language_code when the effective model supports enforcement", async () => {
556
+ let capturedBody = "";
557
+
558
+ globalThis.fetch = mock(
559
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
560
+ capturedBody = init?.body as string;
561
+ return new Response(streamOf(new Uint8Array([0x01])), { status: 200 });
562
+ },
563
+ ) as unknown as typeof globalThis.fetch;
564
+
565
+ const provider = createElevenLabsProvider();
566
+ await provider.synthesizeStream!(makeRequest({ language: "hi" }), () => {});
567
+
568
+ const body = JSON.parse(capturedBody);
569
+ expect(body.model_id).toBe("eleven_flash_v2_5");
570
+ expect(body.language_code).toBe("hi");
571
+ });
572
+
573
+ test("maps the tl subtag to the fil code the v2.5 roster spells Filipino with", async () => {
574
+ let capturedBody = "";
575
+
576
+ globalThis.fetch = mock(
577
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
578
+ capturedBody = init?.body as string;
579
+ return new Response(streamOf(new Uint8Array([0x01])), { status: 200 });
580
+ },
581
+ ) as unknown as typeof globalThis.fetch;
582
+
583
+ const provider = createElevenLabsProvider();
584
+ await provider.synthesizeStream!(makeRequest({ language: "tl" }), () => {});
585
+
586
+ const body = JSON.parse(capturedBody);
587
+ expect(body.model_id).toBe("eleven_flash_v2_5");
588
+ expect(body.language_code).toBe("fil");
589
+ });
590
+
591
+ test("omits language_code for a language the v2.5 models do not support", async () => {
592
+ let capturedBody = "";
593
+
594
+ globalThis.fetch = mock(
595
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
596
+ capturedBody = init?.body as string;
597
+ return new Response(streamOf(new Uint8Array([0x01])), { status: 200 });
598
+ },
599
+ ) as unknown as typeof globalThis.fetch;
600
+
601
+ const provider = createElevenLabsProvider();
602
+ await provider.synthesizeStream!(makeRequest({ language: "he" }), () => {});
603
+
604
+ const body = JSON.parse(capturedBody);
605
+ expect(body.model_id).toBe("eleven_flash_v2_5");
606
+ expect("language_code" in body).toBe(false);
607
+ });
608
+
609
+ test("omits language_code when the configured model does not support enforcement", async () => {
610
+ mockElevenLabsConfig.voiceModelId = "eleven_multilingual_v2";
611
+ seedTtsConfig();
612
+ let capturedBody = "";
613
+
614
+ globalThis.fetch = mock(
615
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
616
+ capturedBody = init?.body as string;
617
+ return new Response(streamOf(new Uint8Array([0x01])), { status: 200 });
618
+ },
619
+ ) as unknown as typeof globalThis.fetch;
620
+
621
+ const provider = createElevenLabsProvider();
622
+ await provider.synthesizeStream!(makeRequest({ language: "hi" }), () => {});
623
+
624
+ const body = JSON.parse(capturedBody);
625
+ expect(body.model_id).toBe("eleven_multilingual_v2");
626
+ expect("language_code" in body).toBe(false);
552
627
  });
553
628
 
554
629
  test("synthesizeStream respects configured voiceModelId over flash default", async () => {
@@ -1179,6 +1254,22 @@ describe("Deepgram TTS provider adapter", () => {
1179
1254
  expect(body.text).toBe("Hello world");
1180
1255
  });
1181
1256
 
1257
+ test("request voiceId overrides the configured Deepgram model", async () => {
1258
+ let capturedUrl = "";
1259
+ globalThis.fetch = mock(async (input: RequestInfo | URL) => {
1260
+ capturedUrl = typeof input === "string" ? input : input.toString();
1261
+ return new Response(new Uint8Array([0x49, 0x44, 0x33]), { status: 200 });
1262
+ }) as unknown as typeof globalThis.fetch;
1263
+
1264
+ const provider = createDeepgramProvider();
1265
+ await provider.synthesize(makeRequest({ voiceId: "aura-2-thalia-en" }));
1266
+ expect(capturedUrl).toContain("model=aura-2-thalia-en");
1267
+
1268
+ // A blank request voiceId falls back to the configured model.
1269
+ await provider.synthesize(makeRequest({ voiceId: " " }));
1270
+ expect(capturedUrl).toContain("model=aura-asteria-en");
1271
+ });
1272
+
1182
1273
  test("uses linear16 encoding with container=none and sample_rate=16000 when outputFormat is pcm", async () => {
1183
1274
  const audioPayload = new Uint8Array([0x00, 0x01, 0x02]);
1184
1275
  let capturedUrl = "";
@@ -1685,6 +1776,42 @@ describe("xAI TTS provider adapter", () => {
1685
1776
  expect(body.voice_id).toBe("ara");
1686
1777
  });
1687
1778
 
1779
+ test("request language overrides config language", async () => {
1780
+ const audioPayload = new Uint8Array([0x49, 0x44, 0x33]);
1781
+ let capturedBody = "";
1782
+
1783
+ globalThis.fetch = mock(
1784
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
1785
+ capturedBody = init?.body as string;
1786
+ return new Response(audioPayload, { status: 200 });
1787
+ },
1788
+ ) as unknown as typeof globalThis.fetch;
1789
+
1790
+ const provider = createXaiProvider();
1791
+ await provider.synthesize(makeRequest({ language: "hi" }));
1792
+
1793
+ const body = JSON.parse(capturedBody);
1794
+ expect(body.language).toBe("hi");
1795
+ });
1796
+
1797
+ test("keeps configured language for a request code outside the code-switching roster", async () => {
1798
+ const audioPayload = new Uint8Array([0x49, 0x44, 0x33]);
1799
+ let capturedBody = "";
1800
+
1801
+ globalThis.fetch = mock(
1802
+ async (_input: RequestInfo | URL, init?: RequestInit) => {
1803
+ capturedBody = init?.body as string;
1804
+ return new Response(audioPayload, { status: 200 });
1805
+ },
1806
+ ) as unknown as typeof globalThis.fetch;
1807
+
1808
+ const provider = createXaiProvider();
1809
+ await provider.synthesize(makeRequest({ language: "th" }));
1810
+
1811
+ const body = JSON.parse(capturedBody);
1812
+ expect(body.language).toBe("auto");
1813
+ });
1814
+
1688
1815
  test("wav config format produces codec=wav without bit_rate", async () => {
1689
1816
  mockXaiConfig.format = "wav";
1690
1817
  seedTtsConfig();
@@ -1902,6 +2029,26 @@ describe("xAI TTS provider adapter", () => {
1902
2029
  expect(capturedSocketOptions().url).toContain("sample_rate=22050");
1903
2030
  });
1904
2031
 
2032
+ test("request language overrides config language in the stream URL", async () => {
2033
+ const provider = createXaiProvider();
2034
+ await provider.synthesizeStream!(
2035
+ makeRequest({ language: "es" }),
2036
+ () => {},
2037
+ );
2038
+
2039
+ expect(capturedSocketOptions().url).toContain("language=es");
2040
+ });
2041
+
2042
+ test("keeps configured language in the stream URL for an out-of-roster request code", async () => {
2043
+ const provider = createXaiProvider();
2044
+ await provider.synthesizeStream!(
2045
+ makeRequest({ language: "th" }),
2046
+ () => {},
2047
+ );
2048
+
2049
+ expect(capturedSocketOptions().url).toContain("language=auto");
2050
+ });
2051
+
1905
2052
  test("mp3 requests carry codec=mp3 and bit_rate and resolve audio/mpeg", async () => {
1906
2053
  const provider = createXaiProvider();
1907
2054
  const result = await provider.synthesizeStream!(makeRequest(), () => {});