@shogo-ai/voice 1.10.0 → 2.4.2

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