@shogo-ai/voice 1.10.0 → 2.4.1

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 (68) hide show
  1. package/README.md +89 -0
  2. package/dist/{chunk-S3S2RNO6.js → chunk-BI6SL35K.js} +1 -184
  3. package/dist/chunk-CLF25CZC.js +987 -0
  4. package/dist/chunk-KMSL5PWK.js +540 -0
  5. package/dist/chunk-ME3SIZZU.js +581 -0
  6. package/dist/chunk-TQOBJMEK.js +17 -0
  7. package/dist/{chunk-T3T4EBWY.js → chunk-UH75UVM2.js} +6 -2
  8. package/dist/chunk-UW7VQ3S6.js +148 -0
  9. package/dist/chunk-XE4LLC7Z.js +1067 -0
  10. package/dist/chunk-ZAW6F3YT.js +172 -0
  11. package/dist/index.cjs +735 -25
  12. package/dist/index.d.cts +373 -3
  13. package/dist/index.d.ts +373 -3
  14. package/dist/index.js +59 -3
  15. package/dist/live/client.cjs +173 -0
  16. package/dist/live/client.d.cts +132 -0
  17. package/dist/live/client.d.ts +132 -0
  18. package/dist/live/client.js +9 -0
  19. package/dist/native/index.d.cts +5 -2
  20. package/dist/native/index.d.ts +5 -2
  21. package/dist/native/index.js +15 -11
  22. package/dist/{organicParticlesShaders-DIva0vBO.d.ts → organicParticlesShaders-seZq7jYX.d.cts} +1 -421
  23. package/dist/{organicParticlesShaders-DIva0vBO.d.cts → organicParticlesShaders-seZq7jYX.d.ts} +1 -421
  24. package/dist/particlesConfig-0YbdPOdY.d.cts +223 -0
  25. package/dist/particlesConfig-0YbdPOdY.d.ts +223 -0
  26. package/dist/react/conversation.cjs +789 -0
  27. package/dist/react/conversation.d.cts +134 -0
  28. package/dist/react/conversation.d.ts +134 -0
  29. package/dist/react/conversation.js +13 -0
  30. package/dist/react/index.cjs +374 -36
  31. package/dist/react/index.d.cts +10 -141
  32. package/dist/react/index.d.ts +10 -141
  33. package/dist/react/index.js +23 -770
  34. package/dist/react/visuals.cjs +1048 -0
  35. package/dist/react/visuals.d.cts +70 -0
  36. package/dist/react/visuals.d.ts +70 -0
  37. package/dist/react/visuals.js +10 -0
  38. package/dist/route/agent.cjs +1038 -85
  39. package/dist/route/agent.js +3 -3
  40. package/dist/route/audio-tags.cjs +1038 -85
  41. package/dist/route/audio-tags.js +3 -3
  42. package/dist/route/index.cjs +1042 -85
  43. package/dist/route/index.d.cts +19 -3
  44. package/dist/route/index.d.ts +19 -3
  45. package/dist/route/index.js +3 -3
  46. package/dist/route/music.cjs +1038 -85
  47. package/dist/route/music.js +3 -3
  48. package/dist/route/op.cjs +2118 -0
  49. package/dist/route/op.d.cts +43 -0
  50. package/dist/route/op.d.ts +43 -0
  51. package/dist/route/op.js +27 -0
  52. package/dist/route/signed-url.cjs +1038 -85
  53. package/dist/route/signed-url.js +3 -3
  54. package/dist/route/tts-preview.cjs +1038 -85
  55. package/dist/route/tts-preview.d.cts +6 -4
  56. package/dist/route/tts-preview.d.ts +6 -4
  57. package/dist/route/tts-preview.js +3 -3
  58. package/dist/server.cjs +1038 -85
  59. package/dist/server.d.cts +40 -8
  60. package/dist/server.d.ts +40 -8
  61. package/dist/server.js +2 -2
  62. package/dist/types-Bfhu-aQH.d.cts +199 -0
  63. package/dist/types-Bfhu-aQH.d.ts +199 -0
  64. package/dist/{types-W9y6eMj3.d.ts → types-CadjTXNH.d.cts} +193 -12
  65. package/dist/{types-W9y6eMj3.d.cts → types-CadjTXNH.d.ts} +193 -12
  66. package/package.json +21 -1
  67. package/dist/chunk-B4JC3DK2.js +0 -529
  68. package/dist/chunk-O362EJQR.js +0 -524
@@ -193,19 +193,74 @@ function composeAgentPrompt(basePrompt, options = {}) {
193
193
 
194
194
  // src/elevenlabs.ts
195
195
  var DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io";
196
- var CONVAI_SUPPORTED_TTS_MODELS = /* @__PURE__ */ new Set([
197
- "eleven_turbo_v2",
198
- "eleven_turbo_v2_5",
199
- "eleven_flash_v2",
200
- "eleven_flash_v2_5",
201
- "eleven_multilingual_v2"
202
- ]);
203
- var CONVAI_TTS_MODEL_FALLBACK = "eleven_turbo_v2_5";
196
+ var VOICE_TTS_MODELS = [
197
+ { id: "eleven_v4", label: "Eleven v4", realtime: false, maxChars: 1e4 },
198
+ { id: "eleven_v4_turbo", label: "Eleven v4 Turbo", realtime: true, maxChars: 1e4 },
199
+ { id: "eleven_v3", label: "Eleven v3", realtime: false, maxChars: 5e3 },
200
+ {
201
+ id: "eleven_v3_conversational",
202
+ label: "Eleven v3 Conversational",
203
+ realtime: true,
204
+ maxChars: 5e3
205
+ },
206
+ { id: "eleven_multilingual_v2", label: "Eleven Multilingual v2", realtime: false, maxChars: 1e4 },
207
+ { id: "eleven_flash_v2_5", label: "Eleven Flash v2.5", realtime: true, maxChars: 4e4 },
208
+ { id: "eleven_flash_v2", label: "Eleven Flash v2", realtime: true, maxChars: 3e4 },
209
+ { id: "eleven_turbo_v2_5", label: "Eleven Turbo v2.5", realtime: true, maxChars: 4e4 },
210
+ { id: "eleven_turbo_v2", label: "Eleven Turbo v2", realtime: true, maxChars: 3e4, deprecated: true }
211
+ ];
212
+ var VOICE_TTS_MODEL_IDS = VOICE_TTS_MODELS.map((m) => m.id);
213
+ var DEFAULT_TTS_MODEL = "eleven_v4";
214
+ var CONVAI_SUPPORTED_TTS_MODELS = new Set(
215
+ VOICE_TTS_MODELS.filter((m) => m.realtime).map((m) => m.id)
216
+ );
217
+ var CONVAI_TTS_MODEL_FALLBACK = "eleven_v4_turbo";
218
+ var TTS_FALLBACK_MODEL = "eleven_flash_v2_5";
219
+ function getVoiceTtsModel(modelId) {
220
+ if (!modelId) return void 0;
221
+ return VOICE_TTS_MODELS.find((m) => m.id === modelId);
222
+ }
223
+ var loggedDowngrades = /* @__PURE__ */ new Set();
224
+ function resolveConvaiTtsModelDetailed(requested) {
225
+ if (requested && CONVAI_SUPPORTED_TTS_MODELS.has(requested)) {
226
+ return { modelId: requested, requested, downgraded: false };
227
+ }
228
+ const known = getVoiceTtsModel(requested);
229
+ const reason = !requested ? void 0 : known ? `${requested} is not supported by the convai realtime runtime` : `unknown TTS model ${requested}`;
230
+ if (reason && !loggedDowngrades.has(requested)) {
231
+ loggedDowngrades.add(requested);
232
+ try {
233
+ console.warn(
234
+ `[voice] convai TTS model ${requested} is unavailable (${reason}); using ${CONVAI_TTS_MODEL_FALLBACK}`
235
+ );
236
+ } catch {
237
+ }
238
+ }
239
+ return {
240
+ modelId: CONVAI_TTS_MODEL_FALLBACK,
241
+ requested: requested ?? null,
242
+ downgraded: Boolean(requested),
243
+ ...reason ? { reason } : {}
244
+ };
245
+ }
204
246
  function resolveConvaiTtsModel(requested) {
205
- if (requested && CONVAI_SUPPORTED_TTS_MODELS.has(requested)) return requested;
206
- return CONVAI_TTS_MODEL_FALLBACK;
247
+ return resolveConvaiTtsModelDetailed(requested).modelId;
248
+ }
249
+ function isModelRelatedTtsFailure(status, body) {
250
+ if (status === 402) return true;
251
+ if (status !== 400 && status !== 403 && status !== 404 && status !== 422) return false;
252
+ return /model|plan|permission/i.test(body);
207
253
  }
208
254
  var DEFAULT_MUSIC_MODEL = "music_v1";
255
+ var VOICE_STS_MODEL_IDS = [
256
+ "eleven_multilingual_sts_v2",
257
+ "eleven_english_sts_v2"
258
+ ];
259
+ var DEFAULT_STS_MODEL = "eleven_multilingual_sts_v2";
260
+ var VOICE_SPEECH_TO_TEXT_MODEL_IDS = ["scribe_v1"];
261
+ var DEFAULT_SPEECH_TO_TEXT_MODEL = "scribe_v1";
262
+ var VOICE_SOUND_GENERATION_MODEL_IDS = ["eleven_text_to_sound_v2"];
263
+ var DEFAULT_SOUND_GENERATION_MODEL = "eleven_text_to_sound_v2";
209
264
  var ElevenLabsApiError = class extends Error {
210
265
  constructor(message, status, body) {
211
266
  super(message);
@@ -370,12 +425,18 @@ var ElevenLabsClient = class {
370
425
  return data.signed_url;
371
426
  }
372
427
  /**
373
- * One-shot TTS. Returns the raw audio bytes plus the model id that was
374
- * actually used (some models aren't available for every account, so we
375
- * fall back to `eleven_turbo_v2_5` on the first failure).
428
+ * One-shot TTS. Returns the raw audio bytes plus the model that actually
429
+ * produced them.
430
+ *
431
+ * The requested model is never swapped silently: we only retry on
432
+ * {@link TTS_FALLBACK_MODEL} when the failure is plausibly about
433
+ * the model (400/403/404/422 whose body mentions model/plan/permission,
434
+ * or 402 for an out-of-plan model). Auth (401), rate limit (429), 5xx and
435
+ * network errors are surfaced as-is. When a fallback does happen the
436
+ * result reports `requestedModel`, `fellBack` and `fallbackReason`.
376
437
  */
377
438
  async textToSpeech(params) {
378
- const modelId = params.modelId && params.modelId.length > 0 ? params.modelId : "eleven_v3";
439
+ const requestedModel = params.modelId && params.modelId.length > 0 ? params.modelId : DEFAULT_TTS_MODEL;
379
440
  const accept = params.accept ?? "audio/mpeg";
380
441
  const attempt = async (m) => {
381
442
  const res2 = await this.fetchImpl(
@@ -392,19 +453,39 @@ var ElevenLabsClient = class {
392
453
  );
393
454
  return res2;
394
455
  };
395
- let res = await attempt(modelId);
396
- let used = modelId;
397
- if (!res.ok && modelId !== "eleven_turbo_v2_5") {
398
- res = await attempt("eleven_turbo_v2_5");
399
- used = "eleven_turbo_v2_5";
400
- }
456
+ let res = await attempt(requestedModel);
457
+ let used = requestedModel;
458
+ let fellBack = false;
459
+ let fallbackReason;
401
460
  if (!res.ok) {
402
- const text = await res.text();
403
- throw new ElevenLabsApiError(`textToSpeech failed: ${res.status}`, res.status, text);
461
+ const body = await res.text();
462
+ if (requestedModel !== TTS_FALLBACK_MODEL && isModelRelatedTtsFailure(res.status, body)) {
463
+ fallbackReason = `elevenlabs ${res.status} for ${requestedModel}: ${body.slice(0, 200)}`;
464
+ res = await attempt(TTS_FALLBACK_MODEL);
465
+ used = TTS_FALLBACK_MODEL;
466
+ fellBack = true;
467
+ if (!res.ok) {
468
+ const retryBody = await res.text();
469
+ throw new ElevenLabsApiError(
470
+ `textToSpeech failed: ${res.status}`,
471
+ res.status,
472
+ retryBody
473
+ );
474
+ }
475
+ } else {
476
+ throw new ElevenLabsApiError(`textToSpeech failed: ${res.status}`, res.status, body);
477
+ }
404
478
  }
405
479
  const audio = await res.arrayBuffer();
406
480
  const contentType = res.headers.get("content-type") ?? accept;
407
- return { audio, modelId: used, contentType };
481
+ return {
482
+ audio,
483
+ modelId: used,
484
+ requestedModel,
485
+ fellBack,
486
+ ...fallbackReason ? { fallbackReason } : {},
487
+ contentType
488
+ };
408
489
  }
409
490
  /**
410
491
  * Low-level escape hatch for any ElevenLabs endpoint not covered by a
@@ -510,6 +591,119 @@ var ElevenLabsClient = class {
510
591
  }
511
592
  return { audio, contentType };
512
593
  }
594
+ /**
595
+ * Convert speech to speech — the "voice changer": keep the performance,
596
+ * swap the voice. `POST /v1/speech-to-speech/{voiceId}` with a multipart
597
+ * audio part. Returns the converted audio bytes.
598
+ *
599
+ * @example
600
+ * ```ts
601
+ * const { audio } = await el.speechToSpeech({
602
+ * voiceId: '21m00Tcm4TlvDq8ikWAM',
603
+ * audio: await fetch(myRecording).then((r) => r.blob()),
604
+ * modelId: 'eleven_multilingual_sts_v2',
605
+ * })
606
+ * ```
607
+ */
608
+ async speechToSpeech(params) {
609
+ if (!params.voiceId) throw new Error("speechToSpeech: voiceId is required");
610
+ if (!params.audio) throw new Error("speechToSpeech: audio is required");
611
+ const form = new FormData();
612
+ form.set("audio", params.audio, params.fileName ?? "audio");
613
+ form.set("model_id", params.modelId ?? DEFAULT_STS_MODEL);
614
+ if (params.voiceSettings) {
615
+ form.set("voice_settings", JSON.stringify(params.voiceSettings));
616
+ }
617
+ if (params.outputFormat) form.set("output_format", params.outputFormat);
618
+ if (params.fileFormat) form.set("file_format", params.fileFormat);
619
+ if (params.seed !== void 0) form.set("seed", String(params.seed));
620
+ if (params.removeBackgroundNoise !== void 0) {
621
+ form.set("remove_background_noise", String(params.removeBackgroundNoise));
622
+ }
623
+ const { audio, contentType } = await this.request({
624
+ method: "POST",
625
+ path: `/v1/speech-to-speech/${encodeURIComponent(params.voiceId)}`,
626
+ body: form,
627
+ accept: "audio/mpeg"
628
+ });
629
+ if (!audio) {
630
+ throw new ElevenLabsApiError(
631
+ "speechToSpeech: expected audio bytes but got a JSON response",
632
+ 500,
633
+ ""
634
+ );
635
+ }
636
+ return { audio, contentType };
637
+ }
638
+ /**
639
+ * Generate a sound effect from a text prompt. `POST /v1/sound-generation`.
640
+ *
641
+ * @example
642
+ * ```ts
643
+ * const { audio } = await el.soundEffects({ text: 'heavy wooden door creaking' })
644
+ * ```
645
+ */
646
+ async soundEffects(params) {
647
+ if (!params.text || !params.text.trim()) {
648
+ throw new Error("soundEffects: text is required");
649
+ }
650
+ const body = {
651
+ text: params.text,
652
+ model_id: params.modelId ?? DEFAULT_SOUND_GENERATION_MODEL
653
+ };
654
+ if (params.durationSeconds !== void 0) body.duration_seconds = params.durationSeconds;
655
+ if (params.promptInfluence !== void 0) body.prompt_influence = params.promptInfluence;
656
+ if (params.loop !== void 0) body.loop = params.loop;
657
+ const { audio, contentType } = await this.request({
658
+ method: "POST",
659
+ path: "/v1/sound-generation",
660
+ body,
661
+ accept: "audio/mpeg",
662
+ ...params.outputFormat ? { query: { output_format: params.outputFormat } } : {}
663
+ });
664
+ if (!audio) {
665
+ throw new ElevenLabsApiError(
666
+ "soundEffects: expected audio bytes but got a JSON response",
667
+ 500,
668
+ ""
669
+ );
670
+ }
671
+ return { audio, contentType };
672
+ }
673
+ /**
674
+ * Transcribe audio with Scribe. `POST /v1/speech-to-text` (multipart).
675
+ *
676
+ * @example
677
+ * ```ts
678
+ * const { text } = await el.transcribe({ file: recording })
679
+ * ```
680
+ */
681
+ async transcribe(params) {
682
+ if (!params.file) throw new Error("transcribe: file is required");
683
+ const form = new FormData();
684
+ form.set("file", params.file, params.fileName ?? "audio");
685
+ form.set("model_id", params.modelId ?? DEFAULT_SPEECH_TO_TEXT_MODEL);
686
+ if (params.languageCode) form.set("language_code", params.languageCode);
687
+ if (params.diarize !== void 0) form.set("diarize", String(params.diarize));
688
+ if (params.tagAudioEvents !== void 0) {
689
+ form.set("tag_audio_events", String(params.tagAudioEvents));
690
+ }
691
+ if (params.numSpeakers !== void 0) form.set("num_speakers", String(params.numSpeakers));
692
+ const { json: json2 } = await this.request({
693
+ method: "POST",
694
+ path: "/v1/speech-to-text",
695
+ body: form,
696
+ accept: "application/json"
697
+ });
698
+ if (!json2 || typeof json2 !== "object") {
699
+ throw new ElevenLabsApiError(
700
+ "transcribe: expected a JSON transcription but got audio",
701
+ 500,
702
+ ""
703
+ );
704
+ }
705
+ return json2;
706
+ }
513
707
  /** Probe whether a given voice id still exists on the caller's account. */
514
708
  async voiceExists(voiceId) {
515
709
  const res = await this.fetchImpl(
@@ -673,6 +867,312 @@ var MEMORY_CLIENT_TOOLS = [
673
867
  }
674
868
  ];
675
869
 
870
+ // src/ops.ts
871
+ var MAX_AUDIO_BYTES = 25 * 1024 * 1024;
872
+ var MAX_JSON_BYTES = 8 * 1024;
873
+ var MAX_TEXT_BYTES = 256 * 1024;
874
+ var VOICE_OPS = [
875
+ {
876
+ id: "text-to-speech",
877
+ method: "POST",
878
+ hostedPath: "/voice/op/text-to-speech",
879
+ legacyHostedPath: "/voice/tts",
880
+ upstreamPath: "/v1/text-to-speech/{voiceId}",
881
+ pathParams: ["voiceId"],
882
+ transport: "json-binary",
883
+ call: "textToSpeech",
884
+ permission: "project:read",
885
+ maxBodyBytes: MAX_TEXT_BYTES,
886
+ cost: { unit: "character", per: 1 },
887
+ models: VOICE_TTS_MODEL_IDS,
888
+ defaultModel: CONVAI_TTS_MODEL_FALLBACK,
889
+ defaultVoiceId: true,
890
+ forward: {
891
+ text: "text",
892
+ voiceId: "voice_id",
893
+ modelId: "model_id",
894
+ voiceSettings: "voice_settings"
895
+ },
896
+ required: ["text"],
897
+ responseHeaders: ["x-tts-model-used", "x-tts-model-requested", "warning"],
898
+ accept: "audio/mpeg",
899
+ errorLabel: "TTS failed"
900
+ },
901
+ {
902
+ id: "text-to-speech-with-timestamps",
903
+ method: "POST",
904
+ hostedPath: "/voice/op/text-to-speech-with-timestamps",
905
+ upstreamPath: "/v1/text-to-speech/{voiceId}/with-timestamps",
906
+ pathParams: ["voiceId"],
907
+ transport: "json-json",
908
+ permission: "project:read",
909
+ maxBodyBytes: MAX_TEXT_BYTES,
910
+ cost: { unit: "character", per: 1 },
911
+ models: VOICE_TTS_MODEL_IDS,
912
+ defaultModel: CONVAI_TTS_MODEL_FALLBACK,
913
+ defaultVoiceId: true,
914
+ forward: {
915
+ text: "text",
916
+ voiceId: "voice_id",
917
+ modelId: "model_id",
918
+ voiceSettings: "voice_settings",
919
+ outputFormat: "output_format"
920
+ },
921
+ required: ["text"],
922
+ accept: "application/json"
923
+ },
924
+ {
925
+ id: "speech-to-speech",
926
+ method: "POST",
927
+ hostedPath: "/voice/op/speech-to-speech",
928
+ upstreamPath: "/v1/speech-to-speech/{voiceId}",
929
+ pathParams: ["voiceId"],
930
+ transport: "multipart-binary",
931
+ permission: "project:read",
932
+ maxBodyBytes: MAX_AUDIO_BYTES,
933
+ cost: { unit: "second", per: 1 },
934
+ models: VOICE_STS_MODEL_IDS,
935
+ defaultModel: DEFAULT_STS_MODEL,
936
+ defaultVoiceId: true,
937
+ forward: {
938
+ audio: "audio",
939
+ voiceId: "voice_id",
940
+ modelId: "model_id",
941
+ voiceSettings: "voice_settings",
942
+ outputFormat: "output_format",
943
+ fileFormat: "file_format",
944
+ seed: "seed",
945
+ removeBackgroundNoise: "remove_background_noise"
946
+ },
947
+ required: ["audio"],
948
+ accept: "audio/mpeg"
949
+ },
950
+ {
951
+ id: "speech-to-text",
952
+ method: "POST",
953
+ hostedPath: "/voice/op/speech-to-text",
954
+ upstreamPath: "/v1/speech-to-text",
955
+ transport: "multipart-json",
956
+ permission: "project:read",
957
+ maxBodyBytes: MAX_AUDIO_BYTES,
958
+ cost: { unit: "second", per: 1 },
959
+ models: VOICE_SPEECH_TO_TEXT_MODEL_IDS,
960
+ defaultModel: DEFAULT_SPEECH_TO_TEXT_MODEL,
961
+ forward: {
962
+ file: "file",
963
+ modelId: "model_id",
964
+ languageCode: "language_code",
965
+ diarize: "diarize",
966
+ tagAudioEvents: "tag_audio_events",
967
+ numSpeakers: "num_speakers"
968
+ },
969
+ required: ["file"],
970
+ accept: "application/json"
971
+ },
972
+ {
973
+ id: "sound-generation",
974
+ method: "POST",
975
+ hostedPath: "/voice/op/sound-generation",
976
+ upstreamPath: "/v1/sound-generation",
977
+ transport: "json-binary",
978
+ permission: "project:read",
979
+ maxBodyBytes: MAX_JSON_BYTES,
980
+ cost: { unit: "second", per: 1 },
981
+ models: VOICE_SOUND_GENERATION_MODEL_IDS,
982
+ defaultModel: DEFAULT_SOUND_GENERATION_MODEL,
983
+ forward: {
984
+ text: "text",
985
+ durationSeconds: "duration_seconds",
986
+ promptInfluence: "prompt_influence",
987
+ loop: "loop",
988
+ modelId: "model_id"
989
+ },
990
+ // `output_format` is a query param on this endpoint, not a body field — the
991
+ // typed `soundEffects()` client sends it there, and a body field is
992
+ // silently ignored upstream.
993
+ queryForward: ["output_format"],
994
+ required: ["text"],
995
+ accept: "audio/mpeg"
996
+ },
997
+ {
998
+ id: "music",
999
+ method: "POST",
1000
+ hostedPath: "/voice/op/music",
1001
+ legacyHostedPath: "/voice/music",
1002
+ upstreamPath: "/v1/music",
1003
+ transport: "json-binary",
1004
+ call: "composeMusic",
1005
+ permission: "project:read",
1006
+ maxBodyBytes: MAX_JSON_BYTES,
1007
+ cost: { unit: "second", per: 1 },
1008
+ models: [DEFAULT_MUSIC_MODEL],
1009
+ defaultModel: DEFAULT_MUSIC_MODEL,
1010
+ forward: {
1011
+ prompt: "prompt",
1012
+ compositionPlan: "composition_plan",
1013
+ musicLengthMs: "music_length_ms",
1014
+ modelId: "model_id",
1015
+ forceInstrumental: "force_instrumental",
1016
+ outputFormat: "output_format"
1017
+ },
1018
+ accept: "audio/mpeg",
1019
+ errorLabel: "Music generation failed",
1020
+ validate: (fields) => {
1021
+ const hasPrompt = typeof fields.prompt === "string" && fields.prompt.trim().length > 0;
1022
+ const hasPlan = fields.compositionPlan != null && typeof fields.compositionPlan === "object";
1023
+ return hasPrompt === hasPlan ? "Provide exactly one of `prompt` or `compositionPlan`" : null;
1024
+ }
1025
+ },
1026
+ {
1027
+ id: "voices",
1028
+ method: "GET",
1029
+ hostedPath: "/voice/op/voices",
1030
+ upstreamPath: "/v1/voices",
1031
+ transport: "json-json",
1032
+ permission: "project:read",
1033
+ maxBodyBytes: 0,
1034
+ cost: { unit: "request", per: 1 },
1035
+ queryForward: ["page_size", "search", "voice_type", "category", "next_page_token"],
1036
+ accept: "application/json"
1037
+ },
1038
+ {
1039
+ id: "models",
1040
+ method: "GET",
1041
+ hostedPath: "/voice/op/models",
1042
+ upstreamPath: "/v1/models",
1043
+ transport: "json-json",
1044
+ permission: "project:read",
1045
+ maxBodyBytes: 0,
1046
+ cost: { unit: "request", per: 1 },
1047
+ accept: "application/json"
1048
+ }
1049
+ ];
1050
+ var VOICE_OP_IDS = VOICE_OPS.map((op) => op.id);
1051
+ function getVoiceOp(id) {
1052
+ if (!id) return void 0;
1053
+ return VOICE_OPS.find((op) => op.id === id);
1054
+ }
1055
+ function voiceOpForwardPath(op) {
1056
+ return op.legacyHostedPath ?? op.hostedPath;
1057
+ }
1058
+ function voiceOpReturnsAudio(op) {
1059
+ return op.transport === "json-binary" || op.transport === "multipart-binary";
1060
+ }
1061
+ function voiceOpUpstreamPath(op, params) {
1062
+ let path = op.upstreamPath;
1063
+ for (const name of op.pathParams ?? []) {
1064
+ const value = params[name];
1065
+ if (!value) {
1066
+ return { error: `Missing \`${name}\` (required path parameter for ${op.id})` };
1067
+ }
1068
+ path = path.split(`{${name}}`).join(encodeURIComponent(value));
1069
+ }
1070
+ return path;
1071
+ }
1072
+ var MODEL_FIELD = "modelId";
1073
+ function parseVoiceOpJsonBody(op, raw) {
1074
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
1075
+ return { ok: false, status: 400, error: "Invalid JSON body" };
1076
+ }
1077
+ const body = raw;
1078
+ const fields = {};
1079
+ for (const [callerField, upstreamField] of Object.entries(op.forward ?? {})) {
1080
+ if (upstreamField && body[callerField] !== void 0) fields[callerField] = body[callerField];
1081
+ }
1082
+ for (const name of op.required ?? []) {
1083
+ const value = fields[name];
1084
+ if (value === void 0 || value === null || typeof value === "string" && !value.trim()) {
1085
+ return { ok: false, status: 400, error: `Missing \`${name}\`` };
1086
+ }
1087
+ }
1088
+ const modelCheck = checkVoiceOpModel(op, fields);
1089
+ if ("error" in modelCheck) return { ok: false, status: 400, ...modelCheck };
1090
+ if (op.validate) {
1091
+ const message = op.validate(fields);
1092
+ if (message) return { ok: false, status: 400, error: message };
1093
+ }
1094
+ return { ok: true, fields };
1095
+ }
1096
+ function parseVoiceOpForm(op, form) {
1097
+ const fields = {};
1098
+ for (const callerField of Object.keys(op.forward ?? {})) {
1099
+ const value = form.get(callerField);
1100
+ if (value !== null) fields[callerField] = value;
1101
+ }
1102
+ for (const name of op.required ?? []) {
1103
+ const value = fields[name];
1104
+ if (!(value instanceof Blob)) {
1105
+ return {
1106
+ ok: false,
1107
+ status: 400,
1108
+ error: `Missing \`${name}\` file part`,
1109
+ detail: value === void 0 ? `send a multipart body with a \`${name}\` file part` : `\`${name}\` must be a file part, not a text field`
1110
+ };
1111
+ }
1112
+ if (value.size === 0) {
1113
+ return { ok: false, status: 400, error: `Empty \`${name}\` file part` };
1114
+ }
1115
+ }
1116
+ const modelCheck = checkVoiceOpModel(op, fields);
1117
+ if ("error" in modelCheck) return { ok: false, status: 400, ...modelCheck };
1118
+ const forward = op.forward ?? {};
1119
+ const pathParams = new Set(op.pathParams ?? []);
1120
+ const out = new FormData();
1121
+ for (const [callerField, upstreamField] of Object.entries(forward)) {
1122
+ if (pathParams.has(callerField)) continue;
1123
+ const value = fields[callerField];
1124
+ if (value === void 0 || value === null || value === "") continue;
1125
+ if (value instanceof Blob) {
1126
+ const name = value instanceof File ? value.name : callerField;
1127
+ out.set(upstreamField, value, name);
1128
+ } else if (typeof value === "string") {
1129
+ out.set(upstreamField, value);
1130
+ } else {
1131
+ out.set(upstreamField, JSON.stringify(value));
1132
+ }
1133
+ }
1134
+ const modelField = forward[MODEL_FIELD];
1135
+ if (op.defaultModel && modelField && out.get(modelField) === null) {
1136
+ out.set(modelField, op.defaultModel);
1137
+ }
1138
+ return { ok: true, form: out, fields };
1139
+ }
1140
+ function voiceOpUpstreamBody(op, fields) {
1141
+ const pathParams = new Set(op.pathParams ?? []);
1142
+ const body = {};
1143
+ for (const [callerField, upstreamField] of Object.entries(op.forward ?? {})) {
1144
+ if (pathParams.has(callerField)) continue;
1145
+ const value = fields[callerField];
1146
+ if (value === void 0 || value === null) continue;
1147
+ body[upstreamField] = value;
1148
+ }
1149
+ return body;
1150
+ }
1151
+ function voiceOpPathParams(op, fields, query) {
1152
+ const out = {};
1153
+ for (const name of op.pathParams ?? []) {
1154
+ const fromBody = fields[name];
1155
+ const value = typeof fromBody === "string" && fromBody.length > 0 ? fromBody : query.get(name);
1156
+ if (value) out[name] = value;
1157
+ }
1158
+ return out;
1159
+ }
1160
+ function checkVoiceOpModel(op, fields) {
1161
+ const requested = fields[MODEL_FIELD];
1162
+ if (requested === void 0 || requested === null || requested === "") return { ok: true };
1163
+ if (typeof requested !== "string") {
1164
+ return { error: `\`${MODEL_FIELD}\` must be a string` };
1165
+ }
1166
+ if (op.models && !op.models.includes(requested)) {
1167
+ return {
1168
+ error: "unknown_model",
1169
+ detail: `Unknown model '${requested}' for ${op.id}`,
1170
+ allowed: op.models
1171
+ };
1172
+ }
1173
+ return { ok: true };
1174
+ }
1175
+
676
1176
  // src/server.ts
677
1177
  function json(data, status = 200, extraHeaders = {}) {
678
1178
  return new Response(JSON.stringify(data), {
@@ -713,26 +1213,112 @@ function parseMusicBody(raw) {
713
1213
  }
714
1214
  return { params };
715
1215
  }
1216
+ var MISSING_API_HOST_MESSAGE = "createVoiceHandlers: runtime-token proxy mode needs SHOGO_API_URL (or SHOGO_CLOUD_URL) in env; refusing to guess an API host";
1217
+ function envApiUrl() {
1218
+ if (typeof process === "undefined" || !process.env) return void 0;
1219
+ return process.env.SHOGO_API_URL ?? process.env.SHOGO_CLOUD_URL;
1220
+ }
716
1221
  function resolveProxyOptions(config) {
1222
+ if (config.mode === "byo") {
1223
+ if (config.proxy) {
1224
+ throw new Error(
1225
+ "createVoiceHandlers: mode 'byo' cannot be combined with an explicit proxy"
1226
+ );
1227
+ }
1228
+ return null;
1229
+ }
1230
+ const envProxy = () => {
1231
+ if (config.mode === "proxy" && config.apiKey) {
1232
+ LOG_WARN_CACHE.apiKeyIgnored(config.logger);
1233
+ }
1234
+ if (typeof process === "undefined" || !process.env) return null;
1235
+ const runtimeToken = process.env.RUNTIME_AUTH_SECRET;
1236
+ const projectId = process.env.PROJECT_ID;
1237
+ if (!runtimeToken || !projectId) return null;
1238
+ return {
1239
+ runtimeToken,
1240
+ projectId,
1241
+ // Deliberately not required here: see `resolveApiBase`. Throwing at
1242
+ // construction made every local-only handler (400 validation, 405s,
1243
+ // the static audio-tags catalog) unusable on a host with a token but
1244
+ // no URL — which is exactly CI's env.
1245
+ apiUrl: envApiUrl() ?? "",
1246
+ ...config.fetch ? { fetch: config.fetch } : {}
1247
+ };
1248
+ };
717
1249
  if (config.proxy) {
718
1250
  if (!config.proxy.runtimeToken || !config.proxy.projectId || !config.proxy.apiUrl) {
719
1251
  throw new Error(
720
1252
  "createVoiceHandlers: proxy requires runtimeToken, projectId, and apiUrl"
721
1253
  );
722
1254
  }
1255
+ if (config.apiKey) LOG_WARN_CACHE.apiKeyIgnored(config.logger);
723
1256
  return config.proxy;
724
1257
  }
725
- if (typeof process === "undefined" || !process.env) return null;
726
- const runtimeToken = process.env.RUNTIME_AUTH_SECRET;
727
- const projectId = process.env.PROJECT_ID;
728
- if (!runtimeToken || !projectId) return null;
729
- const apiUrl = process.env.SHOGO_API_URL ?? process.env.SHOGO_CLOUD_URL ?? "http://localhost:8002";
730
- return {
731
- runtimeToken,
732
- projectId,
733
- apiUrl,
734
- ...config.fetch ? { fetch: config.fetch } : {}
735
- };
1258
+ if (config.mode === "proxy") return envProxy();
1259
+ if (config.apiKey) return null;
1260
+ return envProxy();
1261
+ }
1262
+ var LOG_WARN_CACHE = {
1263
+ warned: false,
1264
+ apiKeyIgnored(logger) {
1265
+ if (LOG_WARN_CACHE.warned) return;
1266
+ LOG_WARN_CACHE.warned = true;
1267
+ (logger ?? NOOP_LOGGER)(
1268
+ "warn",
1269
+ 'createVoiceHandlers: apiKey provided but runtime-token proxy mode is active; apiKey is ignored (pass mode: "byo" to use it)'
1270
+ );
1271
+ }
1272
+ };
1273
+ function resolveOpId(req, opId) {
1274
+ if (opId) return opId;
1275
+ try {
1276
+ const url = new URL(req.url);
1277
+ const fromQuery = url.searchParams.get("op");
1278
+ if (fromQuery) return fromQuery;
1279
+ const segments = url.pathname.split("/").filter(Boolean);
1280
+ const last = segments[segments.length - 1];
1281
+ if (last && segments[segments.length - 2] === "op") return last;
1282
+ return void 0;
1283
+ } catch {
1284
+ return void 0;
1285
+ }
1286
+ }
1287
+ async function readVoiceOpBody(op, req) {
1288
+ if (op.method === "GET") return { kind: "none" };
1289
+ if (op.transport === "multipart-binary" || op.transport === "multipart-json") {
1290
+ let form;
1291
+ try {
1292
+ form = await req.formData();
1293
+ } catch {
1294
+ return json({ error: "Invalid multipart body", detail: "send a multipart/form-data body" }, 400);
1295
+ }
1296
+ const parsed2 = parseVoiceOpForm(op, form);
1297
+ if (!parsed2.ok) {
1298
+ return json(
1299
+ {
1300
+ error: parsed2.error,
1301
+ ...parsed2.detail ? { detail: parsed2.detail } : {},
1302
+ ...parsed2.allowed ? { allowed: parsed2.allowed } : {}
1303
+ },
1304
+ parsed2.status
1305
+ );
1306
+ }
1307
+ return { kind: "form", form: parsed2.form, fields: parsed2.fields };
1308
+ }
1309
+ const raw = await readJson(req);
1310
+ const parsed = parseVoiceOpJsonBody(op, raw);
1311
+ if (!parsed.ok) {
1312
+ return json(
1313
+ {
1314
+ error: parsed.error,
1315
+ ...parsed.detail ? { detail: parsed.detail } : {},
1316
+ ...parsed.allowed ? { allowed: parsed.allowed } : {}
1317
+ },
1318
+ parsed.status
1319
+ );
1320
+ }
1321
+ return { kind: "json", fields: parsed.fields };
736
1322
  }
737
1323
  function createProxyHandlers(proxy, log) {
738
1324
  const fetchImpl = proxy.fetch ?? globalThis.fetch;
@@ -741,8 +1327,12 @@ function createProxyHandlers(proxy, log) {
741
1327
  "createVoiceHandlers proxy: global fetch is unavailable; pass proxy.fetch"
742
1328
  );
743
1329
  }
744
- const apiBase = proxy.apiUrl.replace(/\/+$/, "");
745
- function proxyUrl(path, forward) {
1330
+ function resolveApiBase() {
1331
+ const raw = proxy.apiUrl || envApiUrl();
1332
+ if (!raw) throw new Error(MISSING_API_HOST_MESSAGE);
1333
+ return raw.replace(/\/+$/, "");
1334
+ }
1335
+ function proxyUrl(base, path, forward) {
746
1336
  const tail = [`projectId=${encodeURIComponent(proxy.projectId)}`];
747
1337
  for (const [k, v] of Object.entries(forward)) {
748
1338
  if (typeof v === "string" && v.length > 0) {
@@ -750,11 +1340,12 @@ function createProxyHandlers(proxy, log) {
750
1340
  }
751
1341
  }
752
1342
  const sep = path.includes("?") ? "&" : "?";
753
- return `${apiBase}${path}${sep}${tail.join("&")}`;
1343
+ return `${base}${path}${sep}${tail.join("&")}`;
754
1344
  }
755
1345
  const FORWARDED_SIGNED_URL_PARAMS = ["agentName"];
756
1346
  async function doSignedUrl(req) {
757
1347
  if (req.method !== "GET") return json({ error: "Method Not Allowed" }, 405);
1348
+ const base = resolveApiBase();
758
1349
  try {
759
1350
  let incomingUrl = null;
760
1351
  try {
@@ -768,7 +1359,7 @@ function createProxyHandlers(proxy, log) {
768
1359
  if (v != null) forward[key] = v;
769
1360
  }
770
1361
  }
771
- const upstream = await fetchImpl(proxyUrl("/api/voice/signed-url", forward), {
1362
+ const upstream = await fetchImpl(proxyUrl(base, "/api/voice/signed-url", forward), {
772
1363
  method: "GET",
773
1364
  headers: { "x-runtime-token": proxy.runtimeToken },
774
1365
  credentials: "omit"
@@ -798,34 +1389,80 @@ function createProxyHandlers(proxy, log) {
798
1389
  }
799
1390
  });
800
1391
  }
801
- async function doMusic(req) {
802
- if (req.method !== "POST") return json({ error: "Method Not Allowed" }, 405);
803
- const raw = await readJson(req);
804
- const parsed = parseMusicBody(raw);
805
- if ("error" in parsed) return json({ error: parsed.error }, 400);
1392
+ async function doOp(req, opId) {
1393
+ const id = resolveOpId(req, opId);
1394
+ const op = getVoiceOp(id);
1395
+ if (!op) {
1396
+ return json(
1397
+ { error: "unknown_op", detail: `Unknown voice op '${id ?? ""}'`, allowed: VOICE_OP_IDS },
1398
+ 404
1399
+ );
1400
+ }
1401
+ if (req.method !== op.method) return json({ error: "Method Not Allowed" }, 405);
1402
+ const parsed = await readVoiceOpBody(op, req);
1403
+ if (parsed instanceof Response) return parsed;
1404
+ const base = resolveApiBase();
1405
+ const forward = {};
1406
+ let incomingUrl = null;
806
1407
  try {
807
- const upstream = await fetchImpl(proxyUrl("/api/voice/music", {}), {
808
- method: "POST",
809
- headers: {
810
- "x-runtime-token": proxy.runtimeToken,
811
- "content-type": "application/json",
812
- accept: "audio/mpeg"
813
- },
814
- body: JSON.stringify(parsed.params),
1408
+ incomingUrl = new URL(req.url);
1409
+ } catch {
1410
+ }
1411
+ if (incomingUrl) {
1412
+ for (const key of [...op.pathParams ?? [], ...op.queryForward ?? []]) {
1413
+ const value = incomingUrl.searchParams.get(key);
1414
+ if (value != null) forward[key] = value;
1415
+ }
1416
+ }
1417
+ if (parsed.kind === "form") {
1418
+ for (const name of op.pathParams ?? []) {
1419
+ const value = parsed.fields[name];
1420
+ if (typeof value === "string" && value.length > 0) forward[name] = value;
1421
+ }
1422
+ }
1423
+ const hostedPath = `/api${voiceOpForwardPath(op)}`;
1424
+ const headers = { "x-runtime-token": proxy.runtimeToken };
1425
+ let body;
1426
+ if (parsed.kind === "json") {
1427
+ headers["content-type"] = "application/json";
1428
+ body = JSON.stringify(parsed.fields);
1429
+ } else if (parsed.kind === "form") {
1430
+ body = parsed.form;
1431
+ }
1432
+ if (op.accept) headers.accept = op.accept;
1433
+ try {
1434
+ const upstream = await fetchImpl(proxyUrl(base, hostedPath, forward), {
1435
+ method: op.method,
1436
+ headers,
1437
+ ...body !== void 0 ? { body } : {},
815
1438
  credentials: "omit"
816
1439
  });
1440
+ if (upstream.status === 404) {
1441
+ return json(
1442
+ {
1443
+ error: "Not Implemented",
1444
+ detail: `this Shogo API has no voice routes (desktop without ELEVENLABS_API_KEY?) \u2014 ${hostedPath} returned 404 upstream`
1445
+ },
1446
+ 501
1447
+ );
1448
+ }
817
1449
  const buf = await upstream.arrayBuffer();
818
- const headers = {};
1450
+ const responseHeaders = {};
819
1451
  const ct = upstream.headers.get("content-type");
820
- if (ct) headers["content-type"] = ct;
821
- return new Response(buf, { status: upstream.status, headers });
1452
+ if (ct) responseHeaders["content-type"] = ct;
1453
+ for (const name of op.responseHeaders ?? []) {
1454
+ const value = upstream.headers.get(name);
1455
+ if (value) responseHeaders[name] = value;
1456
+ }
1457
+ return new Response(buf, { status: upstream.status, headers: responseHeaders });
822
1458
  } catch (err) {
823
- log("error", "proxy music failed", {
1459
+ log("error", `proxy ${op.id} failed`, {
824
1460
  error: err instanceof Error ? err.message : String(err)
825
1461
  });
826
- return json({ error: "music proxy failed" }, 502);
1462
+ return json({ error: `${op.id} proxy failed` }, 502);
827
1463
  }
828
1464
  }
1465
+ const doMusic = (req) => doOp(req, "music");
829
1466
  function notImplemented(label) {
830
1467
  return async () => json(
831
1468
  {
@@ -835,9 +1472,204 @@ function createProxyHandlers(proxy, log) {
835
1472
  501
836
1473
  );
837
1474
  }
1475
+ async function doTts(req) {
1476
+ if (req.method === "POST") {
1477
+ let raw = null;
1478
+ try {
1479
+ const parsed = await req.clone().json();
1480
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1481
+ raw = parsed;
1482
+ }
1483
+ } catch {
1484
+ }
1485
+ if (raw && (typeof raw.text !== "string" || raw.text.trim().length === 0)) {
1486
+ const sentTags = Array.isArray(raw.audioTags) && raw.audioTags.length > 0;
1487
+ if (sentTags) {
1488
+ const tags = normalizeAudioTags(raw.audioTags) ?? [];
1489
+ const characterName = typeof raw.characterName === "string" ? raw.characterName : void 0;
1490
+ const text = buildPreviewLine(tags, characterName ?? "your companion");
1491
+ return doOp(
1492
+ new Request(req.url, {
1493
+ method: "POST",
1494
+ headers: { "content-type": "application/json" },
1495
+ body: JSON.stringify({ ...raw, text })
1496
+ }),
1497
+ "text-to-speech"
1498
+ );
1499
+ }
1500
+ }
1501
+ }
1502
+ return doOp(req, "text-to-speech");
1503
+ }
1504
+ function createProxyElevenLabs() {
1505
+ const unsupported = (prop) => {
1506
+ throw new Error(
1507
+ `createVoiceHandlers: elevenLabs.${prop} is not available in runtime-token proxy mode (textToSpeech, composeMusic, speechToSpeech, soundEffects and transcribe are hosted); pass mode: 'byo' with an apiKey`
1508
+ );
1509
+ };
1510
+ const textToSpeech = async (params) => {
1511
+ const upstream = await fetchImpl(proxyUrl(resolveApiBase(), "/api/voice/tts", {}), {
1512
+ method: "POST",
1513
+ headers: {
1514
+ "x-runtime-token": proxy.runtimeToken,
1515
+ "content-type": "application/json",
1516
+ accept: params.accept ?? "audio/mpeg"
1517
+ },
1518
+ body: JSON.stringify({
1519
+ text: params.text,
1520
+ voiceId: params.voiceId,
1521
+ ...params.modelId ? { modelId: params.modelId } : {},
1522
+ ...params.voiceSettings ? { voiceSettings: params.voiceSettings } : {}
1523
+ }),
1524
+ credentials: "omit"
1525
+ });
1526
+ if (upstream.status === 404) {
1527
+ throw new Error(
1528
+ "this Shogo API has no voice routes (desktop without ELEVENLABS_API_KEY?) \u2014 /api/voice/tts returned 404 upstream"
1529
+ );
1530
+ }
1531
+ if (!upstream.ok) {
1532
+ throw new ElevenLabsApiError(
1533
+ `textToSpeech failed: ${upstream.status}`,
1534
+ upstream.status,
1535
+ await upstream.text()
1536
+ );
1537
+ }
1538
+ const requestedModel = params.modelId ?? DEFAULT_TTS_MODEL;
1539
+ const usedModel = upstream.headers.get("x-tts-model-used") ?? requestedModel;
1540
+ const requestedHeader = upstream.headers.get("x-tts-model-requested") ?? requestedModel;
1541
+ return {
1542
+ audio: await upstream.arrayBuffer(),
1543
+ modelId: usedModel,
1544
+ requestedModel: requestedHeader,
1545
+ fellBack: usedModel !== requestedHeader,
1546
+ contentType: upstream.headers.get("content-type") ?? params.accept ?? "audio/mpeg"
1547
+ };
1548
+ };
1549
+ const composeMusic = async (params) => {
1550
+ const upstream = await fetchImpl(proxyUrl(resolveApiBase(), "/api/voice/music", {}), {
1551
+ method: "POST",
1552
+ headers: {
1553
+ "x-runtime-token": proxy.runtimeToken,
1554
+ "content-type": "application/json",
1555
+ accept: "audio/mpeg"
1556
+ },
1557
+ body: JSON.stringify(params),
1558
+ credentials: "omit"
1559
+ });
1560
+ if (upstream.status === 404) {
1561
+ throw new Error(
1562
+ "this Shogo API has no voice routes (desktop without ELEVENLABS_API_KEY?) \u2014 /api/voice/music returned 404 upstream"
1563
+ );
1564
+ }
1565
+ if (!upstream.ok) {
1566
+ throw new ElevenLabsApiError(
1567
+ `composeMusic failed: ${upstream.status}`,
1568
+ upstream.status,
1569
+ await upstream.text()
1570
+ );
1571
+ }
1572
+ return {
1573
+ audio: await upstream.arrayBuffer(),
1574
+ contentType: upstream.headers.get("content-type") ?? "audio/mpeg"
1575
+ };
1576
+ };
1577
+ const opResponse = async (opId, req) => {
1578
+ const op = getVoiceOp(opId);
1579
+ const res = await doOp(req, opId);
1580
+ if (res.status === 404) {
1581
+ throw new Error(
1582
+ `this Shogo API has no voice routes (desktop without ELEVENLABS_API_KEY?) \u2014 /api${voiceOpForwardPath(op)} returned 404 upstream`
1583
+ );
1584
+ }
1585
+ if (!res.ok) {
1586
+ throw new ElevenLabsApiError(
1587
+ `${opId} failed: ${res.status}`,
1588
+ res.status,
1589
+ await res.text()
1590
+ );
1591
+ }
1592
+ return res;
1593
+ };
1594
+ const opAudio = async (opId, req) => {
1595
+ const res = await opResponse(opId, req);
1596
+ return {
1597
+ audio: await res.arrayBuffer(),
1598
+ contentType: res.headers.get("content-type") ?? "audio/mpeg"
1599
+ };
1600
+ };
1601
+ const speechToSpeech = (params) => {
1602
+ const form = new FormData();
1603
+ form.set("audio", params.audio, params.fileName ?? "audio");
1604
+ if (params.modelId) form.set("modelId", params.modelId);
1605
+ if (params.voiceSettings) form.set("voiceSettings", JSON.stringify(params.voiceSettings));
1606
+ if (params.outputFormat) form.set("outputFormat", params.outputFormat);
1607
+ if (params.fileFormat) form.set("fileFormat", params.fileFormat);
1608
+ if (params.seed !== void 0) form.set("seed", String(params.seed));
1609
+ if (params.removeBackgroundNoise !== void 0) {
1610
+ form.set("removeBackgroundNoise", String(params.removeBackgroundNoise));
1611
+ }
1612
+ const query = `?voiceId=${encodeURIComponent(params.voiceId)}`;
1613
+ return opAudio(
1614
+ "speech-to-speech",
1615
+ new Request(`http://voice.invalid/voice/op/speech-to-speech${query}`, {
1616
+ method: "POST",
1617
+ body: form
1618
+ })
1619
+ );
1620
+ };
1621
+ const soundEffects = (params) => opAudio(
1622
+ "sound-generation",
1623
+ new Request(
1624
+ // `output_format` is a query param on this endpoint (see the row's
1625
+ // `queryForward`); sending it in the body would be dropped at EL.
1626
+ `http://voice.invalid/voice/op/sound-generation${params.outputFormat ? `?output_format=${encodeURIComponent(params.outputFormat)}` : ""}`,
1627
+ {
1628
+ method: "POST",
1629
+ headers: { "content-type": "application/json" },
1630
+ body: JSON.stringify({
1631
+ text: params.text,
1632
+ ...params.durationSeconds !== void 0 ? { durationSeconds: params.durationSeconds } : {},
1633
+ ...params.promptInfluence !== void 0 ? { promptInfluence: params.promptInfluence } : {},
1634
+ ...params.loop !== void 0 ? { loop: params.loop } : {},
1635
+ ...params.modelId ? { modelId: params.modelId } : {}
1636
+ })
1637
+ }
1638
+ )
1639
+ );
1640
+ const transcribe = async (params) => {
1641
+ const form = new FormData();
1642
+ form.set("file", params.file, params.fileName ?? "audio");
1643
+ if (params.modelId) form.set("modelId", params.modelId);
1644
+ if (params.languageCode) form.set("languageCode", params.languageCode);
1645
+ if (params.diarize !== void 0) form.set("diarize", String(params.diarize));
1646
+ if (params.tagAudioEvents !== void 0) {
1647
+ form.set("tagAudioEvents", String(params.tagAudioEvents));
1648
+ }
1649
+ if (params.numSpeakers !== void 0) form.set("numSpeakers", String(params.numSpeakers));
1650
+ const res = await opResponse(
1651
+ "speech-to-text",
1652
+ new Request("http://voice.invalid/voice/op/speech-to-text", {
1653
+ method: "POST",
1654
+ body: form
1655
+ })
1656
+ );
1657
+ return await res.json();
1658
+ };
1659
+ return new Proxy({}, {
1660
+ get(_t, prop) {
1661
+ if (prop === "textToSpeech") return textToSpeech;
1662
+ if (prop === "composeMusic") return composeMusic;
1663
+ if (prop === "speechToSpeech") return speechToSpeech;
1664
+ if (prop === "soundEffects") return soundEffects;
1665
+ if (prop === "transcribe") return transcribe;
1666
+ return unsupported(String(prop));
1667
+ }
1668
+ });
1669
+ }
838
1670
  return {
839
1671
  signedUrl: doSignedUrl,
840
- tts: notImplemented("tts"),
1672
+ tts: doTts,
841
1673
  music: doMusic,
842
1674
  agent: {
843
1675
  create: notImplemented("agent.create"),
@@ -845,30 +1677,23 @@ function createProxyHandlers(proxy, log) {
845
1677
  delete: notImplemented("agent.delete")
846
1678
  },
847
1679
  audioTags: doAudioTags,
848
- // In proxy mode we don't own an EL client. Expose a throwing stub so
849
- // accidental use is loud instead of silently passing around a
850
- // half-configured client.
851
- elevenLabs: new Proxy({}, {
852
- get(_t, prop) {
853
- throw new Error(
854
- `createVoiceHandlers: elevenLabs.${String(prop)} is not available in runtime-token proxy mode`
855
- );
856
- }
857
- })
1680
+ op: doOp,
1681
+ // In proxy mode we don't own an EL client: media calls go through the
1682
+ // Shogo API, anything else is a loud error.
1683
+ elevenLabs: createProxyElevenLabs()
858
1684
  };
859
1685
  }
860
1686
  function createVoiceHandlers(config) {
861
1687
  const log = config.logger ?? NOOP_LOGGER;
862
1688
  const proxy = resolveProxyOptions(config);
863
1689
  if (proxy) {
864
- if (config.apiKey) {
865
- log("warn", "createVoiceHandlers: apiKey provided but runtime-token proxy mode is active; apiKey is ignored");
866
- }
867
1690
  return createProxyHandlers(proxy, log);
868
1691
  }
869
- if (!config.apiKey) throw new Error("createVoiceHandlers: apiKey is required");
870
- if (!config.getUser) throw new Error("createVoiceHandlers: getUser is required");
871
- if (!config.companionStore) throw new Error("createVoiceHandlers: companionStore is required");
1692
+ if (!config.apiKey) {
1693
+ throw new Error(
1694
+ config.mode === "proxy" ? 'createVoiceHandlers: mode "proxy" needs RUNTIME_AUTH_SECRET + PROJECT_ID (+ SHOGO_API_URL) in env' : "createVoiceHandlers: apiKey is required (or run in a pod with RUNTIME_AUTH_SECRET + PROJECT_ID for proxy mode)"
1695
+ );
1696
+ }
872
1697
  const getUser = config.getUser;
873
1698
  const companionStore = config.companionStore;
874
1699
  const includeMemoryTools = config.includeMemoryTools ?? true;
@@ -880,10 +1705,28 @@ function createVoiceHandlers(config) {
880
1705
  };
881
1706
  const el = new ElevenLabsClient(elConfig);
882
1707
  async function resolveUser(req) {
1708
+ if (!getUser) {
1709
+ return json(
1710
+ {
1711
+ error: "Voice handler misconfigured",
1712
+ detail: "signedUrl / agent.* need getUser (and companionStore); tts and music do not."
1713
+ },
1714
+ 500
1715
+ );
1716
+ }
883
1717
  const u = await getUser(req);
884
1718
  if (!u) return json({ error: "Unauthorized" }, 401);
885
1719
  return u;
886
1720
  }
1721
+ function missingCompanionStore() {
1722
+ return json(
1723
+ {
1724
+ error: "Voice handler misconfigured",
1725
+ detail: "companionStore is required for signedUrl / agent.*; tts and music do not need it."
1726
+ },
1727
+ 500
1728
+ );
1729
+ }
887
1730
  function handleElError(e, fallbackLabel) {
888
1731
  if (e instanceof ElevenLabsApiError) {
889
1732
  log("warn", `${fallbackLabel}: 11Labs ${e.status}`, { body: e.body });
@@ -897,6 +1740,7 @@ function createVoiceHandlers(config) {
897
1740
  if (req.method !== "GET") return json({ error: "Method Not Allowed" }, 405);
898
1741
  const u = await resolveUser(req);
899
1742
  if (u instanceof Response) return u;
1743
+ if (!companionStore) return missingCompanionStore();
900
1744
  const companion = await companionStore.findByUserId(u.id);
901
1745
  if (!companion || !companion.agentId) return json({ error: "No companion" }, 404);
902
1746
  const basePrompt = extractBasePrompt(companion.systemPrompt || "");
@@ -946,8 +1790,10 @@ function createVoiceHandlers(config) {
946
1790
  }
947
1791
  async function doTts(req) {
948
1792
  if (req.method !== "POST") return json({ error: "Method Not Allowed" }, 405);
949
- const u = await resolveUser(req);
950
- if (u instanceof Response) return u;
1793
+ if (getUser) {
1794
+ const u = await resolveUser(req);
1795
+ if (u instanceof Response) return u;
1796
+ }
951
1797
  const raw = await readJson(req);
952
1798
  if (raw === null || typeof raw !== "object") return json({ error: "Invalid JSON body" }, 400);
953
1799
  const body = raw;
@@ -962,27 +1808,37 @@ function createVoiceHandlers(config) {
962
1808
  text = buildPreviewLine(tags, body.characterName ?? "your companion").slice(0, 400);
963
1809
  }
964
1810
  try {
965
- const { audio, modelId, contentType } = await el.textToSpeech({
1811
+ const result = await el.textToSpeech({
966
1812
  voiceId,
967
1813
  text,
968
1814
  ...body.modelId ? { modelId: body.modelId } : {},
969
1815
  voiceSettings
970
1816
  });
971
- return new Response(audio, {
972
- headers: {
973
- "content-type": contentType,
974
- "cache-control": "private, max-age=3600",
975
- "x-tts-model-used": modelId
976
- }
977
- });
1817
+ const headers = {
1818
+ "content-type": result.contentType,
1819
+ "cache-control": "private, max-age=3600",
1820
+ "x-tts-model-used": result.modelId,
1821
+ "x-tts-model-requested": result.requestedModel
1822
+ };
1823
+ if (result.fellBack) {
1824
+ headers.warning = `199 - "model ${result.requestedModel} not available, used ${result.modelId}"`;
1825
+ log("warn", "tts fell back to another model", {
1826
+ requested: result.requestedModel,
1827
+ used: result.modelId,
1828
+ reason: result.fallbackReason
1829
+ });
1830
+ }
1831
+ return new Response(result.audio, { headers });
978
1832
  } catch (e) {
979
1833
  return handleElError(e, "TTS failed");
980
1834
  }
981
1835
  }
982
1836
  async function doMusic(req) {
983
1837
  if (req.method !== "POST") return json({ error: "Method Not Allowed" }, 405);
984
- const u = await resolveUser(req);
985
- if (u instanceof Response) return u;
1838
+ if (getUser) {
1839
+ const u = await resolveUser(req);
1840
+ if (u instanceof Response) return u;
1841
+ }
986
1842
  const raw = await readJson(req);
987
1843
  const parsed = parseMusicBody(raw);
988
1844
  if ("error" in parsed) return json({ error: parsed.error }, 400);
@@ -998,10 +1854,104 @@ function createVoiceHandlers(config) {
998
1854
  return handleElError(e, "Music generation failed");
999
1855
  }
1000
1856
  }
1857
+ async function doOp(req, opId) {
1858
+ const id = resolveOpId(req, opId);
1859
+ const op = getVoiceOp(id);
1860
+ if (!op) {
1861
+ return json(
1862
+ { error: "unknown_op", detail: `Unknown voice op '${id ?? ""}'`, allowed: VOICE_OP_IDS },
1863
+ 404
1864
+ );
1865
+ }
1866
+ if (req.method !== op.method) return json({ error: "Method Not Allowed" }, 405);
1867
+ const parsed = await readVoiceOpBody(op, req);
1868
+ if (parsed instanceof Response) return parsed;
1869
+ const fields = parsed.kind === "none" ? {} : parsed.fields;
1870
+ let query;
1871
+ try {
1872
+ query = new URL(req.url).searchParams;
1873
+ } catch {
1874
+ query = new URLSearchParams();
1875
+ }
1876
+ const pathParams = voiceOpPathParams(op, fields, query);
1877
+ const upstream = voiceOpUpstreamPath(op, pathParams);
1878
+ if (typeof upstream !== "string") {
1879
+ return json({ error: upstream.error, detail: `set it in the body or as ?${op.pathParams?.[0]}=` }, 400);
1880
+ }
1881
+ const forwardedQuery = {};
1882
+ for (const key of op.queryForward ?? []) {
1883
+ const value = query.get(key);
1884
+ if (value) forwardedQuery[key] = value;
1885
+ }
1886
+ if (op.defaultVoiceId && !pathParams.voiceId) {
1887
+ return json(
1888
+ {
1889
+ error: "Missing `voiceId`",
1890
+ detail: `BYO mode has no project default voice \u2014 pass voiceId (or ?voiceId=)`
1891
+ },
1892
+ 400
1893
+ );
1894
+ }
1895
+ try {
1896
+ if (op.call === "textToSpeech") {
1897
+ const result = await el.textToSpeech({
1898
+ voiceId: pathParams.voiceId ?? String(fields.voiceId ?? ""),
1899
+ text: String(fields.text ?? ""),
1900
+ ...typeof fields.modelId === "string" ? { modelId: fields.modelId } : {},
1901
+ ...fields.voiceSettings ? { voiceSettings: fields.voiceSettings } : {},
1902
+ ...op.accept ? { accept: op.accept } : {}
1903
+ });
1904
+ const headers2 = {
1905
+ "content-type": result.contentType,
1906
+ "cache-control": "private, max-age=3600",
1907
+ "x-tts-model-used": result.modelId,
1908
+ "x-tts-model-requested": result.requestedModel
1909
+ };
1910
+ if (result.fellBack) {
1911
+ headers2.warning = `199 - "model ${result.requestedModel} not available, used ${result.modelId}"`;
1912
+ }
1913
+ return new Response(result.audio, { headers: headers2 });
1914
+ }
1915
+ if (op.call === "composeMusic") {
1916
+ const { audio, contentType } = await el.composeMusic({
1917
+ ...typeof fields.prompt === "string" ? { prompt: fields.prompt } : {},
1918
+ ...fields.compositionPlan ? { compositionPlan: fields.compositionPlan } : {},
1919
+ ...typeof fields.musicLengthMs === "number" ? { musicLengthMs: fields.musicLengthMs } : {},
1920
+ ...typeof fields.modelId === "string" ? { modelId: fields.modelId } : {},
1921
+ ...typeof fields.forceInstrumental === "boolean" ? { forceInstrumental: fields.forceInstrumental } : {},
1922
+ ...typeof fields.outputFormat === "string" ? { outputFormat: fields.outputFormat } : {}
1923
+ });
1924
+ return new Response(audio, {
1925
+ headers: {
1926
+ "content-type": contentType,
1927
+ "cache-control": "private, max-age=3600"
1928
+ }
1929
+ });
1930
+ }
1931
+ const upstreamBody = voiceOpUpstreamBody(op, fields);
1932
+ if (op.defaultModel && upstreamBody.model_id === void 0) {
1933
+ upstreamBody.model_id = op.defaultModel;
1934
+ }
1935
+ const response = await el.request({
1936
+ method: op.method,
1937
+ path: upstream,
1938
+ ...parsed.kind === "form" ? { body: parsed.form } : parsed.kind === "json" ? { body: upstreamBody } : {},
1939
+ ...Object.keys(forwardedQuery).length ? { query: forwardedQuery } : {},
1940
+ ...op.accept ? { accept: op.accept } : {}
1941
+ });
1942
+ const headers = { "content-type": response.contentType };
1943
+ if (voiceOpReturnsAudio(op)) headers["cache-control"] = "private, max-age=3600";
1944
+ const body = response.audio ?? JSON.stringify(response.json ?? {});
1945
+ return new Response(body, { status: response.status, headers });
1946
+ } catch (e) {
1947
+ return handleElError(e, `${op.id} failed`);
1948
+ }
1949
+ }
1001
1950
  async function doAgentCreate(req) {
1002
1951
  if (req.method !== "POST") return json({ error: "Method Not Allowed" }, 405);
1003
1952
  const u = await resolveUser(req);
1004
1953
  if (u instanceof Response) return u;
1954
+ if (!companionStore) return missingCompanionStore();
1005
1955
  const existing = await companionStore.findByUserId(u.id);
1006
1956
  if (existing) return json({ error: "Companion already exists" }, 409);
1007
1957
  const raw = await readJson(req);
@@ -1052,6 +2002,7 @@ function createVoiceHandlers(config) {
1052
2002
  if (req.method !== "PATCH") return json({ error: "Method Not Allowed" }, 405);
1053
2003
  const u = await resolveUser(req);
1054
2004
  if (u instanceof Response) return u;
2005
+ if (!companionStore) return missingCompanionStore();
1055
2006
  const existing = await companionStore.findByUserId(u.id);
1056
2007
  if (!existing) return json({ error: "No companion" }, 404);
1057
2008
  const raw = await readJson(req);
@@ -1106,6 +2057,7 @@ function createVoiceHandlers(config) {
1106
2057
  if (req.method !== "DELETE") return json({ error: "Method Not Allowed" }, 405);
1107
2058
  const u = await resolveUser(req);
1108
2059
  if (u instanceof Response) return u;
2060
+ if (!companionStore) return missingCompanionStore();
1109
2061
  const existing = await companionStore.findByUserId(u.id);
1110
2062
  if (!existing) return json({ ok: true });
1111
2063
  if (existing.agentId) await el.deleteAgent(existing.agentId);
@@ -1135,6 +2087,7 @@ function createVoiceHandlers(config) {
1135
2087
  delete: doAgentDelete
1136
2088
  },
1137
2089
  audioTags: doAudioTags,
2090
+ op: doOp,
1138
2091
  elevenLabs: el
1139
2092
  };
1140
2093
  }