@lokutor/sdk 1.5.1 → 1.6.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/src/client.ts CHANGED
@@ -50,6 +50,12 @@ function wsToHttp(url: string): string {
50
50
  return url.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:');
51
51
  }
52
52
 
53
+ // The discovery routes live at the API's root (https://api.lokutor.com/voices). They were built from the
54
+ // agent socket's URL, so every helper requested /ws/agent/voices and got a 404.
55
+ function apiOrigin(base?: string): string {
56
+ return new URL(wsToHttp(base || DEFAULT_URLS.VOICE_AGENT)).origin;
57
+ }
58
+
53
59
  async function fetchJson<T>(url: string, timeoutMs = 10000): Promise<T> {
54
60
  const controller = new AbortController();
55
61
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -216,9 +222,13 @@ export class VoiceAgentClient {
216
222
  private serverInputRate: number = 16000;
217
223
 
218
224
  private serverUrl: string;
225
+ private authProvider?: () => string | Promise<string>;
226
+ private voiceExplicit = false;
227
+ private languageExplicit = false;
219
228
 
220
229
  constructor(config: LokutorConfig & {
221
- prompt: string,
230
+ /** Required unless agentId is set (a dashboard agent brings its own prompt). */
231
+ prompt?: string,
222
232
  voice?: VoiceId,
223
233
  language?: Language,
224
234
  visemes?: boolean,
@@ -227,9 +237,15 @@ export class VoiceAgentClient {
227
237
  tools?: ToolDefinition[],
228
238
  serverUrl?: string,
229
239
  }) {
230
- this.apiKey = config.apiKey;
240
+ this.apiKey = config.apiKey || "";
241
+ this.authProvider = config.auth;
231
242
  this.agentId = config.agentId || "";
232
- this.prompt = config.prompt;
243
+ this.prompt = config.prompt ?? "";
244
+ // With agentId, only what the caller set explicitly is sent: the server applies voice, language and
245
+ // prompt on top of the agent it loaded, and a default prompt replaced the agent's own (with its
246
+ // knowledge base and evaluation rules), F1/en replaced its voice and language.
247
+ this.voiceExplicit = config.voice !== undefined;
248
+ this.languageExplicit = config.language !== undefined;
233
249
  // Default stays the Versa 1.x name on purpose: the server resolves F1 to the voice of the
234
250
  // call's own language family (esca_f1 for Spanish, en_f1 for English), which adapts better
235
251
  // than pinning one explicit voice here would.
@@ -254,6 +270,15 @@ export class VoiceAgentClient {
254
270
  */
255
271
  public async connect(customAudioManager?: AudioManager): Promise<boolean> {
256
272
  this.isUserDisconnect = false;
273
+ if (this.authProvider) {
274
+ try {
275
+ this.apiKey = await this.authProvider();
276
+ } catch (err) {
277
+ const error = new LokutorError('auth.missing_key', 'The auth callback failed to return a credential', { original: err, retryable: true });
278
+ this.emit('error', error);
279
+ throw error;
280
+ }
281
+ }
257
282
 
258
283
  if (this.enableAudio || customAudioManager) {
259
284
  if (customAudioManager) {
@@ -280,11 +305,11 @@ export class VoiceAgentClient {
280
305
  let url = this.serverUrl;
281
306
  if (this.apiKey) {
282
307
  const separator = url.includes('?') ? '&' : '?';
283
- url += `${separator}api_key=${this.apiKey}`;
308
+ url += `${separator}api_key=${encodeURIComponent(this.apiKey)}`;
284
309
  }
285
310
  if (this.agentId) {
286
311
  const separator = url.includes('?') ? '&' : '?';
287
- url += `${separator}agent_id=${this.agentId}`;
312
+ url += `${separator}agent_id=${encodeURIComponent(this.agentId)}`;
288
313
  }
289
314
 
290
315
  const redactedUrl = url.replace(/api_key=[^&]+/, 'api_key=***');
@@ -361,7 +386,7 @@ export class VoiceAgentClient {
361
386
  });
362
387
  console.error('❌ WebSocket error:', error.message);
363
388
  sdkTrace('ws.error', { code: error.code, message: error.message });
364
- if (this.onError) this.onError(error);
389
+ this.emit('error', error);
365
390
  if (!this.isConnected) {
366
391
  settle(() => reject(error));
367
392
  }
@@ -404,8 +429,8 @@ export class VoiceAgentClient {
404
429
  });
405
430
  console.log(`Session ended by the server (code ${event.code})`);
406
431
  this.emit('ended', { code: event.code, reason: event.reason || '' });
407
- if (this.onError) this.onError(ended);
408
- if (this.onStatus) this.onStatus('disconnected');
432
+ this.emit('error', ended);
433
+ this.emit('status', 'disconnected');
409
434
  return;
410
435
  }
411
436
 
@@ -416,7 +441,7 @@ export class VoiceAgentClient {
416
441
 
417
442
  console.warn(`Connection lost. Reconnecting in ${backoffDelay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
418
443
 
419
- if (this.onStatus) this.onStatus('reconnecting');
444
+ this.emit('status', 'reconnecting');
420
445
 
421
446
  if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
422
447
  this.reconnectTimer = setTimeout(() => {
@@ -427,16 +452,16 @@ export class VoiceAgentClient {
427
452
  }, backoffDelay);
428
453
  } else if (this.isUserDisconnect) {
429
454
  console.log('Disconnected');
430
- if (this.onStatus) this.onStatus('disconnected');
455
+ this.emit('status', 'disconnected');
431
456
  } else {
432
457
  console.log('Max reconnection attempts reached');
433
- if (this.onStatus) this.onStatus('disconnected');
458
+ this.emit('status', 'disconnected');
434
459
  }
435
460
  };
436
461
 
437
462
  } catch (err) {
438
463
  const error = err instanceof LokutorError ? err : new LokutorError('internal.error', 'Failed to create WebSocket connection', { original: err });
439
- if (this.onError) this.onError(error);
464
+ this.emit('error', error);
440
465
  settle(() => reject(error));
441
466
  }
442
467
  });
@@ -471,13 +496,14 @@ export class VoiceAgentClient {
471
496
  this.configConfirmed = false;
472
497
 
473
498
  // Send feature/config flags — order matches the test agent for consistency.
474
- this.ws.send(JSON.stringify({ type: 'voice', data: this.voice }));
475
- this.ws.send(JSON.stringify({ type: 'language', data: this.language }));
499
+ const agentSession = Boolean(this.agentId);
500
+ if (!agentSession || this.voiceExplicit) this.ws.send(JSON.stringify({ type: 'voice', data: this.voice }));
501
+ if (!agentSession || this.languageExplicit) this.ws.send(JSON.stringify({ type: 'language', data: this.language }));
476
502
  if (this.tools && this.tools.length > 0) {
477
503
  this.ws.send(JSON.stringify({ type: 'tools', data: this.tools }));
478
504
  }
479
505
  this.ws.send(JSON.stringify({ type: 'visemes', data: this.wantVisemes }));
480
- this.ws.send(JSON.stringify({ type: 'prompt', data: this.prompt }));
506
+ if (!agentSession || this.prompt) this.ws.send(JSON.stringify({ type: 'prompt', data: this.prompt }));
481
507
 
482
508
  // Inform the server of our sample rates for echo cancellation.
483
509
  this.ws.send(JSON.stringify({ type: 'rates', playback: 44100, input: 16000 }));
@@ -545,6 +571,12 @@ export class VoiceAgentClient {
545
571
  sdkTrace('server_rates', { playback: this.serverPlaybackRate, input: this.serverInputRate });
546
572
  break;
547
573
  case 'transcript':
574
+ // Interim recognition while the caller is still speaking: its own event, never history. The
575
+ // final line follows without "partial" (it used to be stored and reported as final every time).
576
+ if (msg.partial) {
577
+ this.emit('transcription_partial', msg.data);
578
+ break;
579
+ }
548
580
  const role = msg.role === 'user' ? 'user' : 'agent';
549
581
  // Store in history
550
582
  this.messages.push({
@@ -554,11 +586,9 @@ export class VoiceAgentClient {
554
586
  });
555
587
 
556
588
  if (msg.role === 'user') {
557
- if (this.onTranscription) this.onTranscription(msg.data);
558
589
  this.emit('transcription', msg.data);
559
590
  console.log(`💬 You: ${msg.data}`);
560
591
  } else {
561
- if (this.onResponse) this.onResponse(msg.data);
562
592
  this.emit('response', msg.data);
563
593
  console.log(`🤖 Agent: ${msg.data}`);
564
594
  }
@@ -580,7 +610,6 @@ export class VoiceAgentClient {
580
610
  if (msg.data === 'interrupted' && this.audioManager) {
581
611
  this.audioManager.stopPlayback();
582
612
  }
583
- if (this.onStatus) this.onStatus(msg.data);
584
613
  this.emit('status', msg.data);
585
614
  const icons: Record<string, string> = {
586
615
  'interrupted': '⚡',
@@ -633,11 +662,14 @@ export class VoiceAgentClient {
633
662
  break;
634
663
  }
635
664
 
636
- case 'tool_call':
637
- this.emit('tool_call', { name: msg.name, arguments: msg.arguments });
665
+ case 'tool_call': {
666
+ // Once, with the call_id submitToolResult needs. It was emitted twice (the first time
667
+ // without call_id), so a handler ran every tool twice and its first result was dropped.
668
+ const call: ToolCall = { name: msg.name, arguments: msg.arguments, call_id: msg.call_id };
638
669
  console.log(`🛠️ Tool Call: ${msg.name}(${msg.arguments})`);
639
- this.emit('tool_call', msg);
670
+ this.emit('tool_call', call);
640
671
  break;
672
+ }
641
673
  }
642
674
  } catch (e) {
643
675
  sdkTrace('ws.recv.parse_error', { preview: text?.slice(0, 120) });
@@ -765,7 +797,7 @@ export class VoiceAgentClient {
765
797
  * No authentication required.
766
798
  */
767
799
  static async fetchVoices(baseUrl?: string): Promise<VoiceInfo[]> {
768
- const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
800
+ const url = apiOrigin(baseUrl);
769
801
  const data = await fetchJson<{ voices: VoiceInfo[] }>(`${url}/voices`);
770
802
  return data.voices || [];
771
803
  }
@@ -775,7 +807,7 @@ export class VoiceAgentClient {
775
807
  * No authentication required.
776
808
  */
777
809
  static async fetchLanguages(baseUrl?: string): Promise<LanguageInfo[]> {
778
- const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
810
+ const url = apiOrigin(baseUrl);
779
811
  const data = await fetchJson<{ languages: LanguageInfo[] }>(`${url}/languages`);
780
812
  return data.languages || [];
781
813
  }
@@ -785,7 +817,7 @@ export class VoiceAgentClient {
785
817
  * No authentication required.
786
818
  */
787
819
  static async fetchModels(baseUrl?: string): Promise<ModelInfo[]> {
788
- const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
820
+ const url = apiOrigin(baseUrl);
789
821
  const data = await fetchJson<{ models: ModelInfo[] }>(`${url}/models`);
790
822
  return data.models || [];
791
823
  }
@@ -795,7 +827,7 @@ export class VoiceAgentClient {
795
827
  * No authentication required.
796
828
  */
797
829
  static async fetchConfig(baseUrl?: string): Promise<ServerConfig> {
798
- const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
830
+ const url = apiOrigin(baseUrl);
799
831
  return fetchJson<ServerConfig>(`${url}/config`);
800
832
  }
801
833
 
@@ -804,17 +836,17 @@ export class VoiceAgentClient {
804
836
  * No authentication required.
805
837
  */
806
838
  static async fetchStatus(baseUrl?: string): Promise<ServerStatus> {
807
- const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
839
+ const url = apiOrigin(baseUrl);
808
840
  return fetchJson<ServerStatus>(`${url}/status`);
809
841
  }
810
842
 
811
843
  /**
812
- * Fetch health/liveness status from the server.
813
- * No authentication required.
844
+ * Fetch readiness from the server (GET /ready: 200 once it can serve).
845
+ * No authentication required. (/health is the operator dashboard's and needs its password.)
814
846
  */
815
847
  static async fetchHealth(baseUrl?: string): Promise<HealthStatus> {
816
- const url = wsToHttp(baseUrl || DEFAULT_URLS.VOICE_AGENT);
817
- return fetchJson<HealthStatus>(`${url}/health`);
848
+ const url = apiOrigin(baseUrl);
849
+ return fetchJson<HealthStatus>(`${url}/ready`);
818
850
  }
819
851
 
820
852
  /**
@@ -841,6 +873,7 @@ export class VoiceAgentClient {
841
873
  */
842
874
  public updateVoice(voice: VoiceId) {
843
875
  this.voice = voice;
876
+ this.voiceExplicit = true;
844
877
  if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
845
878
  this.ws.send(JSON.stringify({ type: 'voice', data: voice }));
846
879
  console.log(`⚙️ Updated voice: ${voice}`);
@@ -852,6 +885,7 @@ export class VoiceAgentClient {
852
885
  */
853
886
  public updateLanguage(language: Language) {
854
887
  this.language = language;
888
+ this.languageExplicit = true;
855
889
  if (this.ws && this.ws.readyState === WebSocket.OPEN && this.isConnected) {
856
890
  this.ws.send(JSON.stringify({ type: 'language', data: language }));
857
891
  console.log(`⚙️ Updated language: ${language}`);
@@ -935,112 +969,133 @@ export class TTSClient {
935
969
  onTTFB?: (ms: number) => void;
936
970
  onError?: (error: any) => void;
937
971
  }): Promise<void> {
972
+ // Done when the server says so ("EOS"); a server error frame rejects. It used to resolve after 2 s
973
+ // without audio, so an error looked like success and a request whose first audio was queued for more
974
+ // than 2 s (the synthesis pool at its cap) was cut off empty.
938
975
  return new Promise((resolve, reject) => {
939
- let activityTimeout: any;
976
+ let timer: any;
940
977
  let ws: WebSocket;
941
978
  let startTime: number;
942
979
  let firstByteReceived = false;
943
-
944
- const refreshTimeout = () => {
945
- if (activityTimeout) clearTimeout(activityTimeout);
946
- activityTimeout = setTimeout(() => {
947
- console.log("⏱️ TTS synthesis reached inactivity timeout (2s) - resolving");
948
- if (ws) ws.close();
980
+ let finished = false;
981
+
982
+ const finish = (err?: unknown) => {
983
+ if (finished) return;
984
+ finished = true;
985
+ if (timer) clearTimeout(timer);
986
+ try { ws?.close(); } catch { /* already closed */ }
987
+ if (err) {
988
+ if (options.onError) options.onError(err);
989
+ reject(err);
990
+ } else {
949
991
  resolve();
950
- }, 2000);
992
+ }
993
+ };
994
+ // 30 s for the first audio, then 15 s of silence between chunks before giving up.
995
+ const arm = () => {
996
+ if (timer) clearTimeout(timer);
997
+ timer = setTimeout(() => finish(new LokutorError('internal.timeout' as ErrorCode,
998
+ firstByteReceived ? 'TTS stream stalled' : 'No audio within 30 s', { retryable: true })),
999
+ firstByteReceived ? 15000 : 30000);
951
1000
  };
952
1001
 
953
1002
  try {
954
1003
  let url = DEFAULT_URLS.TTS;
955
1004
  if (this.apiKey) {
956
1005
  const separator = url.includes('?') ? '&' : '?';
957
- url += `${separator}api_key=${this.apiKey}`;
1006
+ url += `${separator}api_key=${encodeURIComponent(this.apiKey)}`;
958
1007
  }
959
1008
 
960
1009
  ws = new WebSocket(url);
961
1010
  ws.binaryType = 'arraybuffer';
962
1011
 
963
1012
  ws.onopen = () => {
964
- refreshTimeout();
965
- const req = {
1013
+ arm();
1014
+ // speed and steps only when set: the server's defaults are what production is tuned for (the
1015
+ // SDK used to send speed 1.05 and steps 24 on every request).
1016
+ const req: Record<string, unknown> = {
966
1017
  text: options.text,
967
1018
  voice: options.voice || VoiceStyle.F1,
968
1019
  language: options.language || Language.ENGLISH,
969
- speed: options.speed || 1.05,
970
- steps: options.steps || 24,
971
1020
  visemes: options.visemes || false
972
1021
  };
1022
+ if (options.speed !== undefined) req.speed = options.speed;
1023
+ if (options.steps !== undefined) req.steps = options.steps;
973
1024
  ws.send(JSON.stringify(req));
974
1025
  startTime = nowMs();
975
1026
  };
976
1027
 
977
1028
  ws.onmessage = async (event) => {
978
- refreshTimeout();
979
1029
  if (event.data instanceof ArrayBuffer) {
980
1030
  if (!firstByteReceived) {
981
1031
  const ttfb = nowMs() - startTime;
982
1032
  if (options.onTTFB) options.onTTFB(ttfb);
983
1033
  firstByteReceived = true;
984
1034
  }
1035
+ arm();
985
1036
  if (options.onAudio) options.onAudio(new Uint8Array(event.data));
986
1037
  return;
987
1038
  }
988
1039
  const text = event.data.toString();
989
1040
  if (text === 'EOS') {
990
- if (activityTimeout) clearTimeout(activityTimeout);
991
- ws.close();
992
- resolve();
1041
+ finish();
993
1042
  return;
994
1043
  }
995
1044
 
1045
+ let msg: any;
996
1046
  try {
997
- const msg = JSON.parse(text);
998
-
999
- if (msg.type === 'audio' && msg.data) {
1000
- const audioBuffer = base64ToUint8Array(msg.data);
1001
- if (!firstByteReceived) {
1002
- const ttfb = nowMs() - startTime;
1003
- if (options.onTTFB) options.onTTFB(ttfb);
1004
- firstByteReceived = true;
1005
- }
1006
- if (options.onAudio) options.onAudio(audioBuffer);
1007
- return;
1008
- }
1047
+ msg = JSON.parse(text);
1048
+ } catch {
1049
+ return;
1050
+ }
1009
1051
 
1010
- if (msg.type === 'visemes' && Array.isArray(msg.data) && options.onVisemes) {
1011
- options.onVisemes(normalizeVisemes(msg.data));
1012
- return;
1013
- }
1052
+ if (msg.type === 'error') {
1053
+ const data = typeof msg.data === 'object' && msg.data ? msg.data : { message: msg.data };
1054
+ finish(new LokutorError((data.code ?? 'internal.error') as ErrorCode, data.message ?? 'TTS failed', {
1055
+ detail: data.detail,
1056
+ retryable: data.retryable ?? false,
1057
+ }));
1058
+ return;
1059
+ }
1014
1060
 
1015
- if (Array.isArray(msg) && options.onVisemes) {
1016
- options.onVisemes(normalizeVisemes(msg));
1017
- return;
1018
- }
1019
-
1020
- if (msg.type === 'eos') {
1021
- if (activityTimeout) clearTimeout(activityTimeout);
1022
- ws.close();
1023
- resolve();
1061
+ if (msg.type === 'audio' && msg.data) {
1062
+ const audioBuffer = base64ToUint8Array(msg.data);
1063
+ if (!firstByteReceived) {
1064
+ const ttfb = nowMs() - startTime;
1065
+ if (options.onTTFB) options.onTTFB(ttfb);
1066
+ firstByteReceived = true;
1024
1067
  }
1025
- } catch (e) {
1068
+ arm();
1069
+ if (options.onAudio) options.onAudio(audioBuffer);
1070
+ return;
1026
1071
  }
1027
- };
1028
1072
 
1029
- ws.onerror = (err) => {
1030
- if (activityTimeout) clearTimeout(activityTimeout);
1031
- if (options.onError) options.onError(err);
1032
- reject(err);
1033
- };
1073
+ if (msg.type === 'visemes' && Array.isArray(msg.data) && options.onVisemes) {
1074
+ options.onVisemes(normalizeVisemes(msg.data));
1075
+ return;
1076
+ }
1034
1077
 
1035
- ws.onclose = () => {
1036
- if (activityTimeout) clearTimeout(activityTimeout);
1037
- resolve();
1078
+ if (Array.isArray(msg) && options.onVisemes) {
1079
+ options.onVisemes(normalizeVisemes(msg));
1080
+ return;
1081
+ }
1082
+
1083
+ if (msg.type === 'eos') {
1084
+ finish();
1085
+ }
1038
1086
  };
1039
1087
 
1088
+ ws.onerror = (err) => finish(err);
1089
+
1090
+ ws.onclose = (ev) => {
1091
+ // A close before EOS is an error unless audio already arrived and the server closed cleanly.
1092
+ if (!finished) {
1093
+ if (firstByteReceived && ev.code === 1000) finish();
1094
+ else finish(new LokutorError('internal.error' as ErrorCode, `TTS socket closed (${ev.code}${ev.reason ? `: ${ev.reason}` : ''})`, { retryable: true }));
1095
+ }
1096
+ };
1040
1097
  } catch (err) {
1041
- if (activityTimeout) clearTimeout(activityTimeout);
1042
- if (options.onError) options.onError(err);
1043
- reject(err);
1098
+ finish(err);
1044
1099
  }
1045
1100
  });
1046
1101
  }
@@ -1170,7 +1225,7 @@ export class SpeechToTextClient {
1170
1225
  try {
1171
1226
  let url = this.serverUrl;
1172
1227
  const separator = url.includes('?') ? '&' : '?';
1173
- url += `${separator}api_key=${this.apiKey}`;
1228
+ url += `${separator}api_key=${encodeURIComponent(this.apiKey)}`;
1174
1229
 
1175
1230
  this.ws = new WebSocket(url);
1176
1231
 
@@ -1179,11 +1234,22 @@ export class SpeechToTextClient {
1179
1234
  this.onStatusChange?.('connected');
1180
1235
  this.ws!.send(JSON.stringify({ language: this.language || Language.ENGLISH, vad: this.vad }));
1181
1236
 
1182
- await this.audioManager!.startMicrophone((data) => {
1183
- if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) {
1184
- this.ws.send(data);
1185
- }
1186
- });
1237
+ // A denied or missing microphone rejects instead of leaving connect() pending forever (the
1238
+ // same hang 1.5.1 fixed in VoiceAgentClient).
1239
+ try {
1240
+ await this.audioManager!.startMicrophone((data) => {
1241
+ if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) {
1242
+ this.ws.send(data);
1243
+ }
1244
+ });
1245
+ } catch (err) {
1246
+ const error = err instanceof LokutorError ? err
1247
+ : new LokutorError('audio.microphone_unavailable' as ErrorCode, 'Could not start the microphone', { original: err });
1248
+ this.onError?.(error);
1249
+ try { this.ws?.close(); } catch { /* already closed */ }
1250
+ settle(() => reject(error));
1251
+ return;
1252
+ }
1187
1253
 
1188
1254
  settle(() => resolve(true));
1189
1255
  };
@@ -1199,7 +1265,10 @@ export class SpeechToTextClient {
1199
1265
  this.onPartialTranscript?.(msg.data ?? '');
1200
1266
  }
1201
1267
  } else if (msg.type === 'error') {
1202
- this.onError?.(new LokutorError('internal.error', msg.data ?? 'STT error'));
1268
+ const data = typeof msg.data === 'object' && msg.data ? msg.data : { message: msg.data };
1269
+ this.onError?.(new LokutorError((data.code ?? 'internal.error') as ErrorCode, data.message ?? 'STT error', {
1270
+ detail: data.detail, retryable: data.retryable ?? false,
1271
+ }));
1203
1272
  }
1204
1273
  } catch {
1205
1274
  // ignore malformed frames
package/src/types.ts CHANGED
@@ -4,13 +4,13 @@
4
4
  /**
5
5
  * Built-in voices.
6
6
  *
7
- * Versa 2.0 names each voice for the language its reference speaker was recorded in: `EN_*` are
8
- * English speakers, `ESCA_*` Spanish/Catalan. That is a guide to how a voice sounds, not a
9
- * restriction — the model is zero-shot, so any voice can speak any supported language.
7
+ * Use `F1`–`F5` / `M1`–`M5` (what GET /voices lists). Each is a slot: the server resolves it to the
8
+ * native speaker of the call's language in that slot, so `F1` in Spanish is a Spanish speaker and `F1`
9
+ * in English an English one.
10
10
  *
11
- * The bare `F1`–`M5` values are the Versa 1.x names. They still work: the server maps them onto
12
- * the matching Versa 2.0 voice, keeping the language family of the call. Prefer the explicit names
13
- * in new code.
11
+ * `EN_*` pin the English speakers regardless of language. `ESCA_*` is the retired Spanish/Catalan pack
12
+ * (its recordings measure as noisy in the top octave); do not use it in new code — `F1`–`M5` with
13
+ * `language: 'es'` gives the vetted Spanish voices.
14
14
  *
15
15
  * This enum is a convenience, not the full set — a cloned voice's id (`clone_…`) is also a valid
16
16
  * `voice`, which is why the config fields accept any string.
@@ -29,36 +29,36 @@ export enum VoiceStyle {
29
29
  EN_M5 = "en_m5",
30
30
 
31
31
  // Spanish / Catalan reference speakers
32
+ /** @deprecated Retired pack; use F1 with language "es". */
32
33
  ESCA_F1 = "esca_f1",
34
+ /** @deprecated Retired pack; use F2 with language "es". */
33
35
  ESCA_F2 = "esca_f2",
36
+ /** @deprecated Retired pack; use F3 with language "es". */
34
37
  ESCA_F3 = "esca_f3",
38
+ /** @deprecated Retired pack; use F4 with language "es". */
35
39
  ESCA_F4 = "esca_f4",
40
+ /** @deprecated Retired pack; use F5 with language "es". */
36
41
  ESCA_F5 = "esca_f5",
42
+ /** @deprecated Retired pack; use M1 with language "es". */
37
43
  ESCA_M1 = "esca_m1",
44
+ /** @deprecated Retired pack; use M2 with language "es". */
38
45
  ESCA_M2 = "esca_m2",
46
+ /** @deprecated Retired pack; use M3 with language "es". */
39
47
  ESCA_M3 = "esca_m3",
48
+ /** @deprecated Retired pack; use M4 with language "es". */
40
49
  ESCA_M4 = "esca_m4",
50
+ /** @deprecated Retired pack; use M5 with language "es". */
41
51
  ESCA_M5 = "esca_m5",
42
52
 
43
- /** @deprecated Versa 1.x name, mapped server-side. Use EN_F1 / ESCA_F1. */
44
53
  F1 = "F1",
45
- /** @deprecated Versa 1.x name, mapped server-side. */
46
54
  F2 = "F2",
47
- /** @deprecated Versa 1.x name, mapped server-side. */
48
55
  F3 = "F3",
49
- /** @deprecated Versa 1.x name, mapped server-side. */
50
56
  F4 = "F4",
51
- /** @deprecated Versa 1.x name, mapped server-side. */
52
57
  F5 = "F5",
53
- /** @deprecated Versa 1.x name, mapped server-side. */
54
58
  M1 = "M1",
55
- /** @deprecated Versa 1.x name, mapped server-side. */
56
59
  M2 = "M2",
57
- /** @deprecated Versa 1.x name, mapped server-side. */
58
60
  M3 = "M3",
59
- /** @deprecated Versa 1.x name, mapped server-side. */
60
61
  M4 = "M4",
61
- /** @deprecated Versa 1.x name, mapped server-side. */
62
62
  M5 = "M5",
63
63
  }
64
64
 
@@ -118,7 +118,14 @@ export const DEFAULT_URLS = {
118
118
  * SDK Configuration interface
119
119
  */
120
120
  export interface LokutorConfig {
121
- apiKey: string;
121
+ /** An API key, or a short-lived wst_ ticket from POST /ws-ticket. Optional when `auth` is given. */
122
+ apiKey?: string;
123
+ /**
124
+ * Called before every connect (and reconnect) for the credential to use. In a browser, have it ask
125
+ * your backend for a fresh ticket (POST https://api.lokutor.com/ws-ticket with your API key) so the
126
+ * key itself never reaches the page; a ticket lasts 10 minutes, so reconnects need a new one.
127
+ */
128
+ auth?: () => string | Promise<string>;
122
129
  agentId?: string;
123
130
  onTranscription?: (text: string) => void;
124
131
  onResponse?: (text: string) => void;
@@ -313,6 +320,8 @@ export interface ToolDefinition {
313
320
  export interface ToolCall {
314
321
  name: string;
315
322
  arguments: string;
323
+ /** Pass to submitToolResult(call_id, result). */
324
+ call_id: string;
316
325
  }
317
326
 
318
327
  /**
@@ -338,6 +347,11 @@ export type ErrorCode =
338
347
  | 'stt.language_not_supported'
339
348
  | 'agent.session_failed'
340
349
  | 'agent.provider_error'
350
+ | 'agent.not_found'
351
+ | 'billing.insufficient_balance'
352
+ | 'billing.topup_required'
353
+ | 'billing.limit_reached'
354
+ | 'billing.account_disputed'
341
355
  | 'session.ended'
342
356
  | 'audio.microphone_unavailable'
343
357
  | 'internal.error'
@@ -380,6 +394,11 @@ function isRetryableCode(code: ErrorCode): boolean {
380
394
  'validation.steps_out_of_range',
381
395
  'validation.invalid_request_format',
382
396
  'internal.cancelled',
397
+ 'agent.not_found',
398
+ 'billing.insufficient_balance',
399
+ 'billing.topup_required',
400
+ 'billing.limit_reached',
401
+ 'billing.account_disputed',
383
402
  ];
384
403
  return !fatal.includes(code);
385
404
  }