From 6e45f56b4d327763f8f6a9d36f5c474ff7c03cf9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 19 Sep 2026 20:42:13 +0200 Subject: [PATCH 01/11] feat(errors): give voice_not_enabled and gate_limit_reached their own exit codes voice_not_enabled exits 6 and gate_limit_reached exits 7 whatever their HTTP status, so scripts can tell a missing Voice Automation beta enrollment or a reached voice limit from other authorization and payment failures. Add the voice_asset_failed condition (exit 13) for audio that fails processing while a command waits for it. --- src/errors/conditions.ts | 2 ++ src/errors/present.ts | 18 ++++++++++++++++++ test/errors/present.test.ts | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/src/errors/conditions.ts b/src/errors/conditions.ts index e0763ecb..5e7bb5c8 100644 --- a/src/errors/conditions.ts +++ b/src/errors/conditions.ts @@ -5,6 +5,7 @@ export type CliCondition = | 'forwarding_failed' | 'partial_success' | 'authorization' + | 'voice_asset_failed' const EXIT_CODES: Readonly> = { action_required: 8, @@ -13,6 +14,7 @@ const EXIT_CODES: Readonly> = { partial_success: 12, stream_dropped: 10, stream_gap: 9, + voice_asset_failed: 13, } export class CliConditionError extends Error { diff --git a/src/errors/present.ts b/src/errors/present.ts index 80ef6573..320e57ee 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -81,9 +81,27 @@ export function presentError( } } +/** + * API error codes with their own exit code, whatever their HTTP status, so + * scripts can tell a missing beta enrollment or a reached limit apart from + * other authorization or payment failures. + */ +export const CODE_EXIT_CODES: ReadonlyMap< + string, + Pick +> = new Map>([ + ['voice_not_enabled', {category: 'authorization', exitCode: 6}], + ['gate_limit_reached', {category: 'payment_required', exitCode: 7}], +]) + function classifyError( error: unknown, ): Pick { + if (error instanceof PolymorfaError && error.code !== undefined) { + const mapped = CODE_EXIT_CODES.get(error.code) + if (mapped !== undefined) return mapped + } + if (error instanceof PolymorfaPaymentRequiredError) { return {category: 'payment_required', exitCode: 4} } diff --git a/test/errors/present.test.ts b/test/errors/present.test.ts index 3e62839d..e8da8718 100644 --- a/test/errors/present.test.ts +++ b/test/errors/present.test.ts @@ -1,8 +1,10 @@ import { PolymorfaAuthenticationError, + PolymorfaAuthorizationError, PolymorfaCancelledError, PolymorfaConfigurationError, PolymorfaConflictError, + PolymorfaPaymentRequiredError, } from '@polymorfa/sdk' import {describe, expect, test} from 'vitest' @@ -119,4 +121,36 @@ describe('presentError', () => { retryable: false, }) }) + + test('gives voice_not_enabled and gate_limit_reached their own exit codes', () => { + expect( + presentError( + new PolymorfaAuthorizationError('Not enrolled.', { + code: 'voice_not_enabled', + status: 403, + }), + ), + ).toMatchObject({ + category: 'authorization', + code: 'voice_not_enabled', + exitCode: 6, + }) + expect( + presentError( + new PolymorfaPaymentRequiredError('Limit reached.', { + code: 'gate_limit_reached', + status: 402, + }), + ), + ).toMatchObject({ + category: 'payment_required', + code: 'gate_limit_reached', + exitCode: 7, + }) + expect( + presentError( + new PolymorfaAuthorizationError('Denied.', {code: 'toString'}), + ), + ).toMatchObject({category: 'authorization', exitCode: 3}) + }) }) From ca2dea58a92914cd5c94d6d4b45a5536aaf41202 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 19 Sep 2026 20:42:13 +0200 Subject: [PATCH 02/11] feat(voice): audio library and provider credential commands (beta) Add voice audio upload|tts|list|view|update|delete|preview and voice credential add|list|view|verify|delete for the Voice Automation beta (calls.voice-automation), contract revision voice-audio-v1. - upload detects the content type from the extension and refuses other types, empty files and files over 16 MB before any request. The bytes go to the returned upload URL with only its headers and no credential; the asset is then completed. --wait polls until ready or failed. - credential add reads the key from the variable named by --key-env (default POLYMORFA_VOICE_PROVIDER_KEY) or piped stdin, never an argument, refuses a terminal, and registers the key for redaction. - delete commands confirm or take --yes. The pinned SDK (94bfd09) has no client.voice and git pins cannot install newer SDK commits since the SDK became a workspace, so src/sdk/voice.ts sends the documented requests through the organization client's raw transport, mirroring client.voice in polymorfa/sdks f0f3c07. --- package.json | 9 + src/commands/voice/audio/delete.ts | 39 ++ src/commands/voice/audio/list.ts | 48 ++ src/commands/voice/audio/preview.ts | 42 ++ src/commands/voice/audio/tts.ts | 111 ++++ src/commands/voice/audio/update.ts | 53 ++ src/commands/voice/audio/upload.ts | 79 +++ src/commands/voice/audio/view.ts | 22 + src/commands/voice/credential/add.ts | 57 ++ src/commands/voice/credential/delete.ts | 46 ++ src/commands/voice/credential/list.ts | 29 + src/commands/voice/credential/verify.ts | 29 + src/commands/voice/credential/view.ts | 29 + src/sdk/voice.ts | 496 ++++++++++++++++ src/services/voice.ts | 697 ++++++++++++++++++++++ test/commands/voice.test.ts | 736 ++++++++++++++++++++++++ test/services/voice.test.ts | 171 ++++++ 17 files changed, 2693 insertions(+) create mode 100644 src/commands/voice/audio/delete.ts create mode 100644 src/commands/voice/audio/list.ts create mode 100644 src/commands/voice/audio/preview.ts create mode 100644 src/commands/voice/audio/tts.ts create mode 100644 src/commands/voice/audio/update.ts create mode 100644 src/commands/voice/audio/upload.ts create mode 100644 src/commands/voice/audio/view.ts create mode 100644 src/commands/voice/credential/add.ts create mode 100644 src/commands/voice/credential/delete.ts create mode 100644 src/commands/voice/credential/list.ts create mode 100644 src/commands/voice/credential/verify.ts create mode 100644 src/commands/voice/credential/view.ts create mode 100644 src/sdk/voice.ts create mode 100644 src/services/voice.ts create mode 100644 test/commands/voice.test.ts create mode 100644 test/services/voice.test.ts diff --git a/package.json b/package.json index 55ae2b8b..a67f4ab5 100644 --- a/package.json +++ b/package.json @@ -209,6 +209,15 @@ "user": { "description": "Inspect Linked Device user identity data." }, + "voice": { + "description": "Voice Automation (beta): audio library and text-to-speech provider credentials." + }, + "voice:audio": { + "description": "Upload, synthesize, inspect, preview, and delete library audio (beta)." + }, + "voice:credential": { + "description": "Store, check, and delete your text-to-speech provider keys (beta)." + }, "widget": { "description": "Inspect and update saved QuickLink widget settings." }, diff --git a/src/commands/voice/audio/delete.ts b/src/commands/voice/audio/delete.ts new file mode 100644 index 00000000..359119a5 --- /dev/null +++ b/src/commands/voice/audio/delete.ts @@ -0,0 +1,39 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {confirmDangerous} from '../../../input/confirmation.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + deleteAudio, + renderDeleted, + validateVoiceId, +} from '../../../services/voice.js' + +export default class VoiceAudioDelete extends BaseCommand { + static override args = { + asset: Args.string({description: 'Audio asset ID.', required: true}), + } + + static override description = + 'Delete an audio asset permanently. Fails with voice_asset_in_use (exit code 4) while a campaign or flow uses it. Works after the Voice Automation beta is withdrawn. Requires a server API key with voice:manage.' + + static override flags = { + yes: Flags.boolean({ + description: 'Confirm permanent deletion without prompting.', + }), + } + + static override summary = 'Delete audio (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceAudioDelete) + const assetId = validateVoiceId(args.asset, 'asset') + await confirmDangerous({ + prompt: `Delete audio ${assetId} permanently?`, + yes: flags.yes, + }) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await deleteAudio(client, assetId) + this.writeValue(result, renderDeleted('audio', result.data), flags) + } +} diff --git a/src/commands/voice/audio/list.ts b/src/commands/voice/audio/list.ts new file mode 100644 index 00000000..fbd8d047 --- /dev/null +++ b/src/commands/voice/audio/list.ts @@ -0,0 +1,48 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient, VOICE_AUDIO_STATUSES} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + listAudio, + renderAudioList, + requireVoiceProject, +} from '../../../services/voice.js' + +export default class VoiceAudioList extends BaseCommand { + static override description = `List the project's audio library, newest first. Requires a server API key with voice:read. ${BETA_NOTICE}` + + static override examples = [ + '<%= config.bin %> voice audio list --project 018f0000-0000-7000-8000-000000000002', + '<%= config.bin %> voice audio list --status ready --limit 100 --project 018f0000-0000-7000-8000-000000000002', + ] + + static override flags = { + cursor: Flags.string({description: 'Continue from this opaque cursor.'}), + limit: Flags.integer({ + description: + 'Return at most this many assets (1 to 100; API default 50).', + max: 100, + min: 1, + }), + status: Flags.string({ + description: 'Only list assets with this status.', + options: [...VOICE_AUDIO_STATUSES], + }), + } + + static override summary = 'List audio (beta).' + + async run(): Promise { + const {flags} = await this.parse(VoiceAudioList) + const projectId = requireVoiceProject(flags.project) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await listAudio(client, { + projectId, + ...(flags.status === undefined ? {} : {status: flags.status}), + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + }) + this.writeValue(result, renderAudioList(result.data, result.page), flags) + } +} diff --git a/src/commands/voice/audio/preview.ts b/src/commands/voice/audio/preview.ts new file mode 100644 index 00000000..243fca14 --- /dev/null +++ b/src/commands/voice/audio/preview.ts @@ -0,0 +1,42 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + previewAudio, + renderPreview, +} from '../../../services/voice.js' + +export default class VoiceAudioPreview extends BaseCommand { + static override args = { + asset: Args.string({description: 'Audio asset ID.', required: true}), + } + + static override description = `Print a link to the transcoded audio (Opus in Ogg, 16 kHz mono). The link works for 5 minutes; treat it as a secret. --output downloads it instead of printing it. Fails with asset_not_ready (exit code 4) before the asset is ready. Requires a server API key with voice:read. ${BETA_NOTICE}` + + static override examples = [ + '<%= config.bin %> voice audio preview 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10', + '<%= config.bin %> voice audio preview 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --output greeting.ogg', + ] + + static override flags = { + force: Flags.boolean({description: 'Overwrite an existing output file.'}), + output: Flags.string({ + char: 'o', + description: 'Download the preview to this file.', + }), + } + + static override summary = 'Get a preview link for audio (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceAudioPreview) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await previewAudio(client, args.asset, { + force: flags.force, + output: flags.output, + }) + this.writeValue(result, renderPreview(result.data), flags) + } +} diff --git a/src/commands/voice/audio/tts.ts b/src/commands/voice/audio/tts.ts new file mode 100644 index 00000000..79e7d95e --- /dev/null +++ b/src/commands/voice/audio/tts.ts @@ -0,0 +1,111 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient, VOICE_PROVIDERS} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + DEFAULT_WAIT_TIMEOUT_SECONDS, + parseRetentionDays, + readTextFile, + renderAudioSubmitted, + requireVoiceProject, + synthesizeAudio, + validateName, + validateTtsText, + validateTtsVoice, + validateVoiceId, + waitForAudio, +} from '../../../services/voice.js' + +export default class VoiceAudioTts extends BaseCommand { + static override description = `Render text to speech into a new library asset. Without --credential, Polymorfa's managed key for the provider is used; with it, your stored provider credential for the same provider. Text is 1 to 5000 characters (4096 for openai). OpenAI voices: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse. ElevenLabs takes a voice ID. Models: elevenlabs eleven_multilingual_v2 (default), eleven_flash_v2_5, eleven_turbo_v2_5; openai gpt-4o-mini-tts (default), tts-1, tts-1-hd. Requires a server API key with voice:manage. ${BETA_NOTICE}` + + static override examples = [ + '<%= config.bin %> voice audio tts --name "Welcome" --text "Thanks for calling." --provider openai --voice alloy --project 018f0000-0000-7000-8000-000000000002', + '<%= config.bin %> voice audio tts --name "Menu" --text-file menu.txt --provider elevenlabs --voice 21m00Tcm4TlvDq8ikWAM --credential 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --wait --project 018f0000-0000-7000-8000-000000000002', + ] + + static override flags = { + credential: Flags.string({ + description: + 'Use this stored provider credential instead of the managed key.', + }), + model: Flags.string({ + description: "Provider model; the provider's default when omitted.", + }), + name: Flags.string({ + description: 'Name shown in the library (1 to 100 characters).', + required: true, + }), + provider: Flags.string({ + description: 'Text-to-speech provider.', + options: [...VOICE_PROVIDERS], + required: true, + }), + 'retention-days': Flags.string({ + description: + 'Delete the audio this many days after creation (1 to 3650). Omit to keep it until deleted.', + }), + text: Flags.string({ + description: 'Text to speak.', + exactlyOne: ['text', 'text-file'], + }), + 'text-file': Flags.string({ + description: 'Read the text from a UTF-8 file, or stdin (-).', + exactlyOne: ['text', 'text-file'], + }), + voice: Flags.string({ + description: 'OpenAI voice name or ElevenLabs voice ID.', + required: true, + }), + wait: Flags.boolean({ + description: + 'Wait until the audio is ready or failed. A failed asset exits with code 13.', + }), + 'wait-timeout': Flags.integer({ + default: DEFAULT_WAIT_TIMEOUT_SECONDS, + description: 'Stop waiting after this many seconds (exit code 5).', + min: 1, + }), + } + + static override summary = 'Create audio from text to speech (beta).' + + async run(): Promise { + const {flags} = await this.parse(VoiceAudioTts) + const projectId = requireVoiceProject(flags.project) + const provider = flags.provider + const name = validateName(flags.name) + const voiceId = validateTtsVoice(flags.voice, provider) + const credentialId = + flags.credential === undefined + ? undefined + : validateVoiceId(flags.credential, 'credential') + const retentionDays = + flags['retention-days'] === undefined + ? undefined + : (parseRetentionDays(flags['retention-days'], false) as number) + const text = validateTtsText( + flags.text ?? (await readTextFile(flags['text-file'] as string)), + provider, + ) + const model = flags.model?.trim() + const client = createVoiceClient(await this.platformEnvironment(flags)) + let asset = await synthesizeAudio(client, { + name, + projectId, + provider, + text, + voiceId, + ...(model === undefined || model.length === 0 ? {} : {model}), + ...(credentialId === undefined ? {} : {credentialId}), + ...(retentionDays === undefined ? {} : {retentionDays}), + }) + if (flags.wait) { + asset = await waitForAudio(client, asset, { + timeoutSeconds: flags['wait-timeout'], + }) + } + this.writeValue(asset, renderAudioSubmitted(asset.data), flags) + } +} diff --git a/src/commands/voice/audio/update.ts b/src/commands/voice/audio/update.ts new file mode 100644 index 00000000..5b46dbf1 --- /dev/null +++ b/src/commands/voice/audio/update.ts @@ -0,0 +1,53 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + parseRetentionDays, + renderAudio, + updateAudio, +} from '../../../services/voice.js' + +export default class VoiceAudioUpdate extends BaseCommand { + static override args = { + asset: Args.string({description: 'Audio asset ID.', required: true}), + } + + static override description = `Rename an audio asset or change how long it is kept. --expected-revision refuses the change with voice_asset_revision_conflict (exit code 4) if the asset changed. Requires a server API key with voice:manage. ${BETA_NOTICE}` + + static override examples = [ + '<%= config.bin %> voice audio update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --name "Greeting v2" --expected-revision 2', + '<%= config.bin %> voice audio update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --retention-days none', + ] + + static override flags = { + 'expected-revision': Flags.integer({ + description: + 'Refuse the change if the asset no longer has this revision.', + min: 1, + }), + name: Flags.string({description: 'New name (1 to 100 characters).'}), + 'retention-days': Flags.string({ + description: + 'Days to keep the audio after creation (1 to 3650), or none to keep it until deleted.', + }), + } + + static override summary = 'Change audio (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceAudioUpdate) + const retentionDays = + flags['retention-days'] === undefined + ? undefined + : parseRetentionDays(flags['retention-days'], true) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await updateAudio(client, args.asset, { + expectedRevision: flags['expected-revision'], + name: flags.name, + retentionDays, + }) + this.writeValue(result, renderAudio(result.data), flags) + } +} diff --git a/src/commands/voice/audio/upload.ts b/src/commands/voice/audio/upload.ts new file mode 100644 index 00000000..cb33a57c --- /dev/null +++ b/src/commands/voice/audio/upload.ts @@ -0,0 +1,79 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + DEFAULT_WAIT_TIMEOUT_SECONDS, + parseRetentionDays, + readUploadFile, + renderAudioSubmitted, + requireVoiceProject, + uploadAudio, + validateName, + waitForAudio, +} from '../../../services/voice.js' + +export default class VoiceAudioUpload extends BaseCommand { + static override args = { + file: Args.string({ + description: + 'Audio file: .mp3, .wav, .ogg, .opus, .m4a or .mp4, at most 16 MB.', + required: true, + }), + } + + static override description = `Upload a recording to the project's audio library. The content type comes from the file extension; other types and files over 16 MB are refused before anything is sent. The asset is returned while it is transcoded to 16 kHz mono; pass --wait to wait until it is ready or failed. Requires a server API key with voice:manage. ${BETA_NOTICE}` + + static override examples = [ + '<%= config.bin %> voice audio upload greeting.mp3 --name "Greeting" --project 018f0000-0000-7000-8000-000000000002', + '<%= config.bin %> voice audio upload hold.wav --name "Hold music" --retention-days 30 --wait --project 018f0000-0000-7000-8000-000000000002', + ] + + static override flags = { + name: Flags.string({ + description: 'Name shown in the library (1 to 100 characters).', + required: true, + }), + 'retention-days': Flags.string({ + description: + 'Delete the audio this many days after creation (1 to 3650). Omit to keep it until deleted.', + }), + wait: Flags.boolean({ + description: + 'Wait until the audio is ready or failed. A failed asset exits with code 13.', + }), + 'wait-timeout': Flags.integer({ + default: DEFAULT_WAIT_TIMEOUT_SECONDS, + description: 'Stop waiting after this many seconds (exit code 5).', + min: 1, + }), + } + + static override summary = 'Upload an audio file (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceAudioUpload) + const projectId = requireVoiceProject(flags.project) + const name = validateName(flags.name) + const retentionDays = + flags['retention-days'] === undefined + ? undefined + : (parseRetentionDays(flags['retention-days'], false) as number) + const file = await readUploadFile(args.file) + const client = createVoiceClient(await this.platformEnvironment(flags)) + let asset = await uploadAudio(client, { + bytes: file.bytes, + contentType: file.contentType, + name, + projectId, + ...(retentionDays === undefined ? {} : {retentionDays}), + }) + if (flags.wait) { + asset = await waitForAudio(client, asset, { + timeoutSeconds: flags['wait-timeout'], + }) + } + this.writeValue(asset, renderAudioSubmitted(asset.data), flags) + } +} diff --git a/src/commands/voice/audio/view.ts b/src/commands/voice/audio/view.ts new file mode 100644 index 00000000..b2986b86 --- /dev/null +++ b/src/commands/voice/audio/view.ts @@ -0,0 +1,22 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import {BETA_NOTICE, renderAudio, viewAudio} from '../../../services/voice.js' + +export default class VoiceAudioView extends BaseCommand { + static override args = { + asset: Args.string({description: 'Audio asset ID.', required: true}), + } + + static override description = `Show one audio asset, including its status, failure reason and revision. Requires a server API key with voice:read. ${BETA_NOTICE}` + + static override summary = 'Show audio (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceAudioView) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await viewAudio(client, args.asset) + this.writeValue(result, renderAudio(result.data), flags) + } +} diff --git a/src/commands/voice/credential/add.ts b/src/commands/voice/credential/add.ts new file mode 100644 index 00000000..be4bb33d --- /dev/null +++ b/src/commands/voice/credential/add.ts @@ -0,0 +1,57 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient, VOICE_PROVIDERS} from '../../../sdk/voice.js' +import { + addCredential, + BETA_NOTICE, + DEFAULT_PROVIDER_KEY_ENV, + readProviderKey, + renderCredential, +} from '../../../services/voice.js' + +export default class VoiceCredentialAdd extends BaseCommand { + static override description = `Store your own ElevenLabs or OpenAI key for text to speech. The key is read from the environment variable named by --key-env (default ${DEFAULT_PROVIDER_KEY_ENV}) or, when that is unset, from standard input; never from an argument. The provider checks the key first; a rejected key fails with provider_credential_invalid (exit code 2). Only the key's fingerprint is ever shown. With --project the credential belongs to that project; without it, every project of the team can use it. Requires a server API key with voice:manage. ${BETA_NOTICE}` + + static override examples = [ + `printf '%s' "$ELEVENLABS_API_KEY" | <%= config.bin %> voice credential add --provider elevenlabs --label "Studio key"`, + `${DEFAULT_PROVIDER_KEY_ENV}="$OPENAI_API_KEY" <%= config.bin %> voice credential add --provider openai --label "Support line" --project 018f0000-0000-7000-8000-000000000002`, + '<%= config.bin %> voice credential add --provider openai --label "CI" --key-env OPENAI_API_KEY', + ] + + static override flags = { + 'key-env': Flags.string({ + description: `Read the key from this environment variable (default ${DEFAULT_PROVIDER_KEY_ENV}; standard input when that is unset).`, + }), + label: Flags.string({ + description: 'Label shown in lists (1 to 100 characters).', + required: true, + }), + provider: Flags.string({ + description: 'Provider the key belongs to.', + options: [...VOICE_PROVIDERS], + required: true, + }), + } + + static override summary = 'Store a provider key (beta).' + + async run(): Promise { + const {flags} = await this.parse(VoiceCredentialAdd) + const apiKey = await readProviderKey({ + env: process.env, + keyEnv: flags['key-env'], + stdin: process.stdin, + }) + this.registerSecret(apiKey) + const projectId = flags.project?.trim() + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await addCredential(client, { + apiKey, + label: flags.label, + provider: flags.provider, + ...(projectId === undefined || projectId.length === 0 ? {} : {projectId}), + }) + this.writeValue(result, renderCredential(result.data), flags) + } +} diff --git a/src/commands/voice/credential/delete.ts b/src/commands/voice/credential/delete.ts new file mode 100644 index 00000000..865477ca --- /dev/null +++ b/src/commands/voice/credential/delete.ts @@ -0,0 +1,46 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {confirmDangerous} from '../../../input/confirmation.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + deleteCredential, + renderDeleted, + validateVoiceId, +} from '../../../services/voice.js' + +export default class VoiceCredentialDelete extends BaseCommand { + static override args = { + credential: Args.string({ + description: 'Provider credential ID.', + required: true, + }), + } + + static override description = + 'Delete a stored provider key. Text to speech that names it then fails; audio already created is kept. Works after the Voice Automation beta is withdrawn. Requires a server API key with voice:manage.' + + static override flags = { + yes: Flags.boolean({ + description: 'Confirm permanent deletion without prompting.', + }), + } + + static override summary = 'Delete a provider credential (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceCredentialDelete) + const credentialId = validateVoiceId(args.credential, 'credential') + await confirmDangerous({ + prompt: `Delete provider credential ${credentialId}?`, + yes: flags.yes, + }) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await deleteCredential(client, credentialId) + this.writeValue( + result, + renderDeleted('provider credential', result.data), + flags, + ) + } +} diff --git a/src/commands/voice/credential/list.ts b/src/commands/voice/credential/list.ts new file mode 100644 index 00000000..ad4e4973 --- /dev/null +++ b/src/commands/voice/credential/list.ts @@ -0,0 +1,29 @@ +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + listCredentials, + renderCredentials, +} from '../../../services/voice.js' + +export default class VoiceCredentialList extends BaseCommand { + static override description = `List the team-wide provider credentials and, with --project, that project's own. Keys are never returned; each credential shows its fingerprint and status. Requires a server API key with voice:read. ${BETA_NOTICE}` + + static override examples = [ + '<%= config.bin %> voice credential list', + '<%= config.bin %> voice credential list --project 018f0000-0000-7000-8000-000000000002', + ] + + static override summary = 'List provider credentials (beta).' + + async run(): Promise { + const {flags} = await this.parse(VoiceCredentialList) + const projectId = flags.project?.trim() + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await listCredentials( + client, + projectId === undefined || projectId.length === 0 ? undefined : projectId, + ) + this.writeValue(result, renderCredentials(result.data), flags) + } +} diff --git a/src/commands/voice/credential/verify.ts b/src/commands/voice/credential/verify.ts new file mode 100644 index 00000000..71de1c7d --- /dev/null +++ b/src/commands/voice/credential/verify.ts @@ -0,0 +1,29 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + renderCredential, + verifyCredential, +} from '../../../services/voice.js' + +export default class VoiceCredentialVerify extends BaseCommand { + static override args = { + credential: Args.string({ + description: 'Provider credential ID.', + required: true, + }), + } + + static override description = `Check the stored key with the provider again and show the updated status. A rejected key fails with provider_credential_invalid (exit code 2); an unreachable provider with provider_unavailable (exit code 5). Requires a server API key with voice:manage. ${BETA_NOTICE}` + + static override summary = 'Check a provider credential again (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceCredentialVerify) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await verifyCredential(client, args.credential) + this.writeValue(result, renderCredential(result.data), flags) + } +} diff --git a/src/commands/voice/credential/view.ts b/src/commands/voice/credential/view.ts new file mode 100644 index 00000000..53b3ad6b --- /dev/null +++ b/src/commands/voice/credential/view.ts @@ -0,0 +1,29 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {createVoiceClient} from '../../../sdk/voice.js' +import { + BETA_NOTICE, + renderCredential, + viewCredential, +} from '../../../services/voice.js' + +export default class VoiceCredentialView extends BaseCommand { + static override args = { + credential: Args.string({ + description: 'Provider credential ID.', + required: true, + }), + } + + static override description = `Show one provider credential: its provider, label, key fingerprint, status and last error. The key is never returned. Requires a server API key with voice:read. ${BETA_NOTICE}` + + static override summary = 'Show a provider credential (beta).' + + async run(): Promise { + const {args, flags} = await this.parse(VoiceCredentialView) + const client = createVoiceClient(await this.platformEnvironment(flags)) + const result = await viewCredential(client, args.credential) + this.writeValue(result, renderCredential(result.data), flags) + } +} diff --git a/src/sdk/voice.ts b/src/sdk/voice.ts new file mode 100644 index 00000000..c8e9fe66 --- /dev/null +++ b/src/sdk/voice.ts @@ -0,0 +1,496 @@ +import { + PolymorfaAuthorizationError, + PolymorfaConnectionError, + PolymorfaError, + PolymorfaNotFoundError, + PolymorfaServerError, + PolymorfaTimeoutError, + PolymorfaValidationError, + type ApiResponse, + type QueryValue, + type ResponseMetadata, +} from '@polymorfa/sdk' + +import type {ResolvedEnvironment} from '../context/environment.js' +import {createPlatformClient} from './client.js' + +// Voice Automation (beta) audio library and provider credentials, contract +// revision voice-audio-v1. The pinned SDK predates `client.voice`, and git +// pins can no longer install newer SDK commits, so this adapter sends the +// documented requests through the organization client's raw transport. The +// SDK keeps credential, retry and error mapping. The types and paths mirror +// `client.voice` in polymorfa/sdks f0f3c0719ce44226aa6af896c33a69c71ef934eb; +// replace this module with that resource once a release that includes it is +// pinned. + +export const VOICE_AUDIO_STATUSES = [ + 'pending_upload', + 'uploaded', + 'transcoding', + 'ready', + 'failed', +] as const +export type VoiceAudioStatus = (typeof VOICE_AUDIO_STATUSES)[number] + +export const VOICE_PROVIDERS = ['elevenlabs', 'openai'] as const +export type VoiceProvider = (typeof VOICE_PROVIDERS)[number] + +/** Largest upload the API accepts: 16 MiB. */ +export const VOICE_AUDIO_MAX_UPLOAD_BYTES = 16_777_216 + +export interface VoiceAudioTts { + readonly provider: string + readonly voiceId: string + readonly model: string + readonly text: string + readonly characters: number + readonly keySource: string + readonly credentialId: string | null +} + +export interface VoiceAudioAsset { + readonly id: string + readonly projectId: string + readonly name: string + readonly source: string + readonly status: string + readonly failureReason: string | null + readonly originalFormat: string | null + readonly originalContentType: string | null + readonly sizeBytes: number | null + readonly durationMs: number | null + readonly contentSha256: string | null + readonly tts: VoiceAudioTts | null + readonly retentionDays: number | null + readonly expiresAt: string | null + readonly inUseCount: number + readonly revision: number + readonly createdAt: string + readonly updatedAt: string + readonly readyAt: string | null +} + +/** Where to send the file. The URL is valid for 5 minutes; treat it as a secret. */ +export interface VoiceAudioUpload { + readonly url: string + readonly method: string + readonly headers: Readonly> + readonly maxBytes: number + readonly expiresAt: string +} + +export interface VoiceAudioUploadCreated { + readonly asset: VoiceAudioAsset + readonly upload: VoiceAudioUpload +} + +/** A 5-minute link to Opus-in-Ogg audio (16 kHz mono). */ +export interface VoiceAudioPreview { + readonly url: string + readonly contentType: string + readonly expiresAt: string +} + +export interface VoiceProviderCredential { + readonly id: string + readonly projectId: string | null + readonly provider: string + readonly label: string + readonly keyFingerprint: string + readonly status: string + readonly verifiedAt: string | null + readonly lastError: string | null + readonly revision: number + readonly createdAt: string + readonly updatedAt: string +} + +export interface VoiceResourceDeleted { + readonly id: string + readonly deleted: true +} + +export interface VoiceAudioPage { + readonly items: readonly VoiceAudioAsset[] + readonly nextCursor: string | null + readonly hasMore: boolean +} + +export interface ListVoiceAudioParams { + readonly projectId: string + readonly status?: string + readonly cursor?: string + readonly limit?: number +} + +export interface CreateVoiceAudioUploadInput { + readonly projectId: string + readonly name: string + readonly contentType: string + readonly sizeBytes: number + readonly retentionDays?: number +} + +export interface SynthesizeVoiceAudioInput { + readonly projectId: string + readonly name: string + readonly text: string + readonly provider: string + readonly voiceId: string + readonly model?: string + readonly credentialId?: string + readonly retentionDays?: number +} + +export interface UpdateVoiceAudioInput { + readonly expectedRevision?: number + readonly name?: string + readonly retentionDays?: number | null +} + +export interface CreateVoiceProviderCredentialInput { + readonly provider: string + readonly label: string + /** Write-only. Never returned, logged or echoed. */ + readonly apiKey: string + /** A project, or omitted for a credential every project of the team can use. */ + readonly projectId?: string +} + +export interface VoiceClient { + listAudio(params: ListVoiceAudioParams): Promise> + createAudioUpload( + input: CreateVoiceAudioUploadInput, + ): Promise> + /** Sends bytes to an upload URL with only its headers and no credential. */ + sendToUploadUrl( + upload: VoiceAudioUpload, + body: Uint8Array, + ): Promise + completeAudio(assetId: string): Promise> + synthesizeAudio( + input: SynthesizeVoiceAudioInput, + ): Promise> + retrieveAudio(assetId: string): Promise> + updateAudio( + assetId: string, + input: UpdateVoiceAudioInput, + ): Promise> + deleteAudio(assetId: string): Promise> + previewAudio(assetId: string): Promise> + /** Downloads a preview URL without a credential. */ + downloadPreview( + preview: VoiceAudioPreview, + maxBytes: number, + ): Promise + listCredentials( + projectId?: string, + ): Promise> + createCredential( + input: CreateVoiceProviderCredentialInput, + ): Promise> + retrieveCredential( + credentialId: string, + ): Promise> + verifyCredential( + credentialId: string, + ): Promise> + deleteCredential( + credentialId: string, + ): Promise> +} + +const AUDIO_PATH = '/platform/voice/audio' +const CREDENTIALS_PATH = '/platform/voice/provider-credentials' +const DEFAULT_BASE_URL = 'https://api.polymorfa.com' +const DEFAULT_TRANSFER_TIMEOUT_MS = 120_000 + +interface DataEnvelope { + readonly data: T + readonly page?: { + readonly nextCursor?: string | null + readonly hasMore?: boolean + } +} + +export function createVoiceClient( + environment: ResolvedEnvironment, + fetchImplementation: typeof fetch = fetch, +): VoiceClient { + const raw = createPlatformClient(environment).raw + const baseUrl = environment.baseUrl ?? DEFAULT_BASE_URL + const transferTimeoutMs = environment.timeoutMs ?? DEFAULT_TRANSFER_TIMEOUT_MS + + async function data( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + path: string, + options: { + readonly body?: unknown + readonly query?: Readonly> + } = {}, + ): Promise> { + const response = await raw.request>({ + method, + path, + ...(options.body === undefined ? {} : {body: options.body}), + ...(options.query === undefined ? {} : {query: options.query}), + }) + return {data: envelopeData(response.data), metadata: response.metadata} + } + + async function transfer( + url: URL, + init: RequestInit, + label: string, + ): Promise { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), transferTimeoutMs) + try { + return await fetchImplementation(url, { + ...init, + redirect: 'error', + signal: controller.signal, + }) + } catch (error) { + if (controller.signal.aborted) { + throw new PolymorfaTimeoutError( + `The ${label} did not finish within ${transferTimeoutMs}ms.`, + {cause: error, code: 'request_timeout'}, + ) + } + // Never include the URL: it is a short-lived capability. + throw new PolymorfaConnectionError( + `The ${label} could not reach its URL.`, + {cause: error, code: 'connection_error'}, + ) + } finally { + clearTimeout(timer) + } + } + + return { + async listAudio(params) { + const response = await raw.request>({ + method: 'GET', + path: AUDIO_PATH, + query: { + projectId: params.projectId, + ...(params.status === undefined ? {} : {status: params.status}), + ...(params.cursor === undefined ? {} : {cursor: params.cursor}), + ...(params.limit === undefined ? {} : {limit: params.limit}), + }, + }) + const items = envelopeData(response.data) + if (!Array.isArray(items)) throw malformed() + const page = response.data.page + return { + data: { + hasMore: page?.hasMore === true, + items, + nextCursor: + typeof page?.nextCursor === 'string' ? page.nextCursor : null, + }, + metadata: response.metadata, + } + }, + createAudioUpload: (input) => + data('POST', AUDIO_PATH, {body: input}), + async sendToUploadUrl(upload, body) { + const url = capabilityUrl(baseUrl, upload.url, 'upload') + const headers = new Headers() + for (const [name, value] of Object.entries(upload.headers)) { + if (name.toLowerCase() === 'authorization') continue + headers.set(name, value) + } + const response = await transfer( + url, + { + body: body as unknown as BodyInit, + headers, + method: upload.method === 'PUT' ? 'PUT' : 'POST', + }, + 'upload', + ) + const metadata = transferMetadata(response) + if (!response.ok) { + throw transferError(response, await safeBody(response), 'upload') + } + return metadata + }, + completeAudio: (assetId) => + data('POST', `${assetPath(assetId)}/complete`), + synthesizeAudio: (input) => + data('POST', `${AUDIO_PATH}/tts`, {body: input}), + retrieveAudio: (assetId) => + data('GET', assetPath(assetId)), + updateAudio: (assetId, input) => + data('PATCH', assetPath(assetId), {body: input}), + deleteAudio: (assetId) => + data('DELETE', assetPath(assetId)), + previewAudio: (assetId) => + data('GET', `${assetPath(assetId)}/preview`), + async downloadPreview(preview, maxBytes) { + const url = capabilityUrl(baseUrl, preview.url, 'preview') + const response = await transfer(url, {method: 'GET'}, 'preview download') + if (!response.ok) { + throw transferError( + response, + await safeBody(response), + 'preview download', + ) + } + const declared = Number(response.headers.get('content-length')) + if (Number.isFinite(declared) && declared > maxBytes) { + await response.body?.cancel() + throw tooLargePreview(maxBytes) + } + const bytes = new Uint8Array(await response.arrayBuffer()) + if (bytes.byteLength > maxBytes) throw tooLargePreview(maxBytes) + return bytes + }, + listCredentials: (projectId) => + data('GET', CREDENTIALS_PATH, { + query: projectId === undefined ? {} : {projectId}, + }), + createCredential: (input) => + data('POST', CREDENTIALS_PATH, {body: input}), + retrieveCredential: (credentialId) => + data('GET', credentialPath(credentialId)), + verifyCredential: (credentialId) => + data( + 'POST', + `${credentialPath(credentialId)}/verify`, + ), + deleteCredential: (credentialId) => + data('DELETE', credentialPath(credentialId)), + } +} + +function assetPath(assetId: string): string { + return `${AUDIO_PATH}/${encodeURIComponent(assetId)}` +} + +function credentialPath(credentialId: string): string { + return `${CREDENTIALS_PATH}/${encodeURIComponent(credentialId)}` +} + +function envelopeData(envelope: DataEnvelope): T { + if ( + typeof envelope !== 'object' || + envelope === null || + !('data' in envelope) + ) { + throw malformed() + } + return envelope.data +} + +function malformed(): PolymorfaServerError { + return new PolymorfaServerError( + 'The API returned a voice response without data.', + {code: 'invalid_response'}, + ) +} + +/** + * Resolve an upload or preview URL. It must use HTTPS (or HTTP on loopback) + * and carry no credentials, matching the SDK's upload rules. + */ +function capabilityUrl(baseUrl: string, value: string, label: string): URL { + let url: URL + try { + url = new URL(value, `${baseUrl.replace(/\/+$/, '')}/`) + } catch { + throw new PolymorfaValidationError( + `The API returned an invalid ${label} URL.`, + {code: `invalid_${label}_url`}, + ) + } + const loopback = + url.hostname === 'localhost' || + url.hostname === '127.0.0.1' || + url.hostname === '[::1]' + if ( + url.username !== '' || + url.password !== '' || + !(url.protocol === 'https:' || (url.protocol === 'http:' && loopback)) + ) { + throw new PolymorfaValidationError( + `The ${label} URL must use HTTPS and must not contain credentials.`, + {code: `invalid_${label}_url`}, + ) + } + return url +} + +function transferMetadata(response: Response): ResponseMetadata { + const requestId = response.headers.get('x-request-id') ?? undefined + return { + attempts: 1, + headers: {}, + status: response.status, + ...(requestId === undefined ? {} : {requestId}), + } +} + +async function safeBody(response: Response): Promise { + try { + const text = await response.text() + if (text.length === 0) return undefined + try { + return JSON.parse(text) as unknown + } catch { + return undefined + } + } catch { + return undefined + } +} + +function transferError( + response: Response, + body: unknown, + label: string, +): PolymorfaError { + const bodyCode = + typeof body === 'object' && + body !== null && + typeof (body as {error?: {code?: unknown}}).error?.code === 'string' + ? (body as {error: {code: string}}).error.code + : undefined + const status = response.status + const options = { + status, + ...(bodyCode === undefined + ? { + code: + status === 413 ? 'too_large' : `${label.replace(' ', '_')}_failed`, + } + : {code: bodyCode}), + } + const message = + status === 413 + ? `The ${label} was refused: the file is larger than 16 MB.` + : status === 415 + ? `The ${label} was refused: the content type does not match the upload.` + : `The ${label} failed with status ${status}.` + if (status === 400 || status === 413 || status === 415 || status === 422) { + return new PolymorfaValidationError(message, options) + } + if (status === 401 || status === 403) { + return new PolymorfaAuthorizationError( + `${message} The link may have expired; request a new one.`, + options, + ) + } + if (status === 404) return new PolymorfaNotFoundError(message, options) + if (status >= 500) return new PolymorfaServerError(message, options) + return new PolymorfaError(message, options) +} + +function tooLargePreview(maxBytes: number): PolymorfaError { + return new PolymorfaServerError( + `The preview is larger than ${maxBytes} bytes; nothing was written.`, + {code: 'preview_too_large'}, + ) +} diff --git a/src/services/voice.ts b/src/services/voice.ts new file mode 100644 index 00000000..af61cfab --- /dev/null +++ b/src/services/voice.ts @@ -0,0 +1,697 @@ +import {open, readFile, stat, writeFile} from 'node:fs/promises' +import {extname, resolve} from 'node:path' +import type {Readable} from 'node:stream' + +import { + PolymorfaConfigurationError, + PolymorfaConflictError, + PolymorfaTimeoutError, + type ResponseMetadata, +} from '@polymorfa/sdk' + +import {CliConditionError} from '../errors/conditions.js' +import {readSecretFromStdin} from '../input/stdin.js' +import { + VOICE_AUDIO_MAX_UPLOAD_BYTES, + type VoiceAudioAsset, + type VoiceAudioPreview, + type VoiceClient, + type VoiceProviderCredential, + type VoiceResourceDeleted, +} from '../sdk/voice.js' + +export interface VoiceResult { + readonly data: T + readonly metadata: { + readonly apiVersion?: string + readonly attempts: number + readonly requestId?: string + readonly status: number + } +} + +export interface VoiceListResult extends VoiceResult { + readonly page: {readonly hasMore: boolean; readonly nextCursor: string | null} +} + +export const BETA_NOTICE = + 'Voice Automation is a beta (calls.voice-automation) for enrolled teams only; other teams get voice_not_enabled (exit code 6).' + +/** File extensions the CLI uploads, and the content type it sends for each. */ +export const UPLOAD_CONTENT_TYPES: Readonly> = { + '.m4a': 'audio/x-m4a', + '.mp3': 'audio/mpeg', + '.mp4': 'audio/mp4', + '.ogg': 'audio/ogg', + '.opus': 'audio/ogg', + '.wav': 'audio/wav', +} + +export const DEFAULT_PROVIDER_KEY_ENV = 'POLYMORFA_VOICE_PROVIDER_KEY' +export const DEFAULT_WAIT_TIMEOUT_SECONDS = 120 +export const WAIT_INTERVAL_MS = 2_000 +export const MAX_PREVIEW_BYTES = 32 * 1024 * 1024 + +const MAX_NAME_LENGTH = 100 +const MAX_TTS_TEXT = 5_000 +const MAX_OPENAI_TTS_TEXT = 4_096 +const MAX_TEXT_FILE_BYTES = 64 * 1024 +const MAX_PROVIDER_KEY_LENGTH = 512 +const ELEVENLABS_VOICE_ID = /^[A-Za-z0-9]{1,64}$/ +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +// ── Validation ───────────────────────────────────────────────────────────── + +export function requireVoiceProject(projectId?: string): string { + if (projectId === undefined || projectId.trim().length === 0) { + throw new PolymorfaConfigurationError( + 'Provide --project with the project ID that owns the audio library.', + 'project', + ) + } + return projectId.trim() +} + +export function validateVoiceId(value: string, label: string): string { + const trimmed = value.trim() + if (!UUID.test(trimmed)) { + throw new PolymorfaConfigurationError(`${label} must be a UUID.`, label) + } + return trimmed +} + +export function validateName(name: string): string { + const trimmed = name.trim() + if (trimmed.length === 0 || trimmed.length > MAX_NAME_LENGTH) { + throw new PolymorfaConfigurationError( + `--name must be 1 to ${MAX_NAME_LENGTH} characters.`, + 'name', + ) + } + return trimmed +} + +/** Parse `--retention-days`: a whole number 1 to 3650, or `none` when allowed. */ +export function parseRetentionDays( + value: string, + allowNone: boolean, +): number | null { + const trimmed = value.trim().toLowerCase() + if (allowNone && trimmed === 'none') return null + const days = /^\d+$/.test(trimmed) ? Number(trimmed) : Number.NaN + if (!Number.isInteger(days) || days < 1 || days > 3650) { + throw new PolymorfaConfigurationError( + allowNone + ? '--retention-days must be a whole number from 1 to 3650, or none to keep the audio until it is deleted.' + : '--retention-days must be a whole number from 1 to 3650.', + 'retention-days', + ) + } + return days +} + +export function uploadContentType(path: string): string { + const extension = extname(path).toLowerCase() + const contentType = UPLOAD_CONTENT_TYPES[extension] + if (contentType === undefined) { + throw new PolymorfaConfigurationError( + `Unsupported audio file type ${extension === '' ? '(no extension)' : extension}. Upload .mp3, .wav, .ogg, .opus, .m4a or .mp4.`, + 'file', + ) + } + return contentType +} + +/** Read an upload after checking its type and size, before any request. */ +export async function readUploadFile(path: string): Promise<{ + readonly bytes: Uint8Array + readonly contentType: string +}> { + const contentType = uploadContentType(path) + const absolute = resolve(path) + const info = await stat(absolute).catch((error: unknown) => { + throw new PolymorfaConfigurationError( + `Cannot read ${path}: ${nodeErrorCode(error) ?? 'unknown error'}.`, + 'file', + ) + }) + if (!info.isFile()) { + throw new PolymorfaConfigurationError(`${path} is not a file.`, 'file') + } + assertUploadSize(info.size, path) + const bytes = new Uint8Array(await readFile(absolute)) + // The file may have changed between stat and read. + assertUploadSize(bytes.byteLength, path) + return {bytes, contentType} +} + +function assertUploadSize(size: number, path: string): void { + if (size === 0) { + throw new PolymorfaConfigurationError(`${path} is empty.`, 'file') + } + if (size > VOICE_AUDIO_MAX_UPLOAD_BYTES) { + throw new PolymorfaConfigurationError( + `${path} is ${size} bytes; audio uploads are limited to 16 MB (${VOICE_AUDIO_MAX_UPLOAD_BYTES} bytes).`, + 'file', + ) + } +} + +export function validateTtsText(text: string, provider: string): string { + const limit = provider === 'openai' ? MAX_OPENAI_TTS_TEXT : MAX_TTS_TEXT + if (text.trim().length === 0 || text.length > limit) { + throw new PolymorfaConfigurationError( + `The text must be 1 to ${limit} characters for ${provider}.`, + 'text', + ) + } + return text +} + +export function validateTtsVoice(voice: string, provider: string): string { + const trimmed = voice.trim() + if (provider === 'elevenlabs' && !ELEVENLABS_VOICE_ID.test(trimmed)) { + throw new PolymorfaConfigurationError( + '--voice must be an ElevenLabs voice ID: 1 to 64 letters and digits.', + 'voice', + ) + } + if (trimmed.length === 0) { + throw new PolymorfaConfigurationError('--voice is required.', 'voice') + } + return trimmed +} + +/** Read `--text-file`: a path, or `-` for standard input. */ +export async function readTextFile( + path: string, + input: Readable = process.stdin, +): Promise { + if (path === '-') { + const chunks: Buffer[] = [] + let bytes = 0 + for await (const chunk of input) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)) + bytes += buffer.byteLength + if (bytes > MAX_TEXT_FILE_BYTES) throw textTooLarge() + chunks.push(buffer) + } + return Buffer.concat(chunks) + .toString('utf8') + .replace(/\r?\n$/, '') + } + const handle = await open(resolve(path), 'r').catch((error: unknown) => { + throw new PolymorfaConfigurationError( + `Cannot read ${path}: ${nodeErrorCode(error) ?? 'unknown error'}.`, + 'text-file', + ) + }) + try { + const info = await handle.stat() + if (info.size > MAX_TEXT_FILE_BYTES) throw textTooLarge() + return (await handle.readFile('utf8')).replace(/\r?\n$/, '') + } finally { + await handle.close() + } +} + +function textTooLarge(): PolymorfaConfigurationError { + return new PolymorfaConfigurationError( + '--text-file exceeded 64 KiB.', + 'text-file', + ) +} + +// ── Provider key input ───────────────────────────────────────────────────── + +export interface ProviderKeySource { + readonly env: Readonly> + /** Name of the environment variable given with --key-env, if any. */ + readonly keyEnv?: string | undefined + readonly stdin: Readable & {readonly isTTY?: boolean} +} + +/** + * Read a provider key without ever taking it from a command argument. The + * environment variable wins when it is set (by default + * POLYMORFA_VOICE_PROVIDER_KEY); otherwise the key is read from piped + * standard input. An explicitly named variable that is empty is an error, and + * a terminal is refused so the key is never echoed. + */ +export async function readProviderKey( + source: ProviderKeySource, +): Promise { + const name = source.keyEnv ?? DEFAULT_PROVIDER_KEY_ENV + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + throw new PolymorfaConfigurationError( + '--key-env must name an environment variable.', + 'key-env', + ) + } + const fromEnv = source.env[name]?.trim() + let key: string + if (fromEnv !== undefined && fromEnv.length > 0) { + key = fromEnv + } else if (source.keyEnv !== undefined) { + throw new PolymorfaConfigurationError( + `Environment variable ${name} is not set.`, + 'key-env', + ) + } else if (source.stdin.isTTY === true) { + throw new PolymorfaConfigurationError( + `Pipe the provider key on standard input or set ${DEFAULT_PROVIDER_KEY_ENV}; the key is never read from an argument or typed at a visible prompt.`, + 'key', + ) + } else { + key = await readSecretFromStdin(source.stdin) + } + if (key.length > MAX_PROVIDER_KEY_LENGTH) { + throw new PolymorfaConfigurationError( + `The provider key must be 1 to ${MAX_PROVIDER_KEY_LENGTH} characters.`, + 'key', + ) + } + return key +} + +// ── Operations ───────────────────────────────────────────────────────────── + +export interface WaitOptions { + readonly intervalMs?: number + readonly sleep?: (milliseconds: number) => Promise + readonly timeoutSeconds: number +} + +export async function uploadAudio( + client: VoiceClient, + input: { + readonly bytes: Uint8Array + readonly contentType: string + readonly name: string + readonly projectId: string + readonly retentionDays?: number + }, +): Promise> { + const created = await client.createAudioUpload({ + contentType: input.contentType, + name: input.name, + projectId: input.projectId, + sizeBytes: input.bytes.byteLength, + ...(input.retentionDays === undefined + ? {} + : {retentionDays: input.retentionDays}), + }) + await client.sendToUploadUrl(created.data.upload, input.bytes) + const completed = await client.completeAudio(created.data.asset.id) + return result(completed.data, completed.metadata) +} + +export async function synthesizeAudio( + client: VoiceClient, + input: Parameters[0], +): Promise> { + const response = await client.synthesizeAudio(input) + return result(response.data, response.metadata) +} + +/** + * Read the asset until it is ready or failed. A failed asset exits with + * voice_asset_failed (exit code 13) so scripts can tell it from success. + */ +export async function waitForAudio( + client: VoiceClient, + asset: VoiceResult, + options: WaitOptions, +): Promise> { + const intervalMs = options.intervalMs ?? WAIT_INTERVAL_MS + const sleep = options.sleep ?? defaultSleep + const deadline = Date.now() + options.timeoutSeconds * 1000 + let current = asset + // Like the SDK's waitUntilReady: read first, then sleep between reads. + while (current.data.status !== 'ready' && current.data.status !== 'failed') { + const response = await client.retrieveAudio(current.data.id) + current = result(response.data, response.metadata) + if (current.data.status === 'ready' || current.data.status === 'failed') { + break + } + const remaining = deadline - Date.now() + if (remaining <= 0) { + throw new PolymorfaTimeoutError( + `Audio ${current.data.id} was still ${current.data.status} after ${options.timeoutSeconds}s. Check it later with polymorfa voice audio view ${current.data.id}.`, + {code: 'request_timeout'}, + ) + } + await sleep(Math.min(intervalMs, remaining)) + } + if (current.data.status === 'failed') { + throw new CliConditionError( + 'voice_asset_failed', + `Audio ${current.data.id} failed: ${current.data.failureReason ?? 'unknown reason'}.`, + ) + } + return current +} + +export async function listAudio( + client: VoiceClient, + params: Parameters[0], +): Promise> { + const response = await client.listAudio(params) + return listResult(response.data, response.metadata) +} + +export async function viewAudio( + client: VoiceClient, + assetId: string, +): Promise> { + const response = await client.retrieveAudio(validateVoiceId(assetId, 'asset')) + return result(response.data, response.metadata) +} + +export async function updateAudio( + client: VoiceClient, + assetId: string, + input: { + readonly expectedRevision?: number | undefined + readonly name?: string | undefined + readonly retentionDays?: number | null | undefined + }, +): Promise> { + if (input.name === undefined && input.retentionDays === undefined) { + throw new PolymorfaConfigurationError( + 'Provide --name or --retention-days with the change to make.', + 'name', + ) + } + const response = await client.updateAudio(validateVoiceId(assetId, 'asset'), { + ...(input.expectedRevision === undefined + ? {} + : {expectedRevision: input.expectedRevision}), + ...(input.name === undefined ? {} : {name: validateName(input.name)}), + ...(input.retentionDays === undefined + ? {} + : {retentionDays: input.retentionDays}), + }) + return result(response.data, response.metadata) +} + +export async function deleteAudio( + client: VoiceClient, + assetId: string, +): Promise> { + const response = await client.deleteAudio(assetId) + return result(response.data, response.metadata) +} + +export interface PreviewResult { + readonly preview: VoiceAudioPreview + readonly download?: {readonly bytes: number; readonly path: string} +} + +export async function previewAudio( + client: VoiceClient, + assetId: string, + options: { + readonly force?: boolean + readonly output?: string | undefined + } = {}, +): Promise> { + const id = validateVoiceId(assetId, 'asset') + const output = + options.output === undefined ? undefined : resolve(options.output) + if (output !== undefined && options.force !== true) { + await rejectExistingOutput(output) + } + const response = await client.previewAudio(id) + if (output === undefined) { + return result({preview: response.data}, response.metadata) + } + const bytes = await client.downloadPreview(response.data, MAX_PREVIEW_BYTES) + try { + await writeFile(output, bytes, {flag: options.force === true ? 'w' : 'wx'}) + } catch (error) { + if (nodeErrorCode(error) === 'EEXIST') throw outputConflict(output) + throw error + } + return result( + {download: {bytes: bytes.byteLength, path: output}, preview: response.data}, + response.metadata, + ) +} + +export async function addCredential( + client: VoiceClient, + input: { + readonly apiKey: string + readonly label: string + readonly projectId?: string | undefined + readonly provider: string + }, +): Promise> { + const response = await client.createCredential({ + apiKey: input.apiKey, + label: validateLabel(input.label), + provider: input.provider, + ...(input.projectId === undefined ? {} : {projectId: input.projectId}), + }) + return result(response.data, response.metadata) +} + +export async function listCredentials( + client: VoiceClient, + projectId?: string, +): Promise> { + const response = await client.listCredentials(projectId) + return result(response.data, response.metadata) +} + +export async function viewCredential( + client: VoiceClient, + credentialId: string, +): Promise> { + const response = await client.retrieveCredential( + validateVoiceId(credentialId, 'credential'), + ) + return result(response.data, response.metadata) +} + +export async function verifyCredential( + client: VoiceClient, + credentialId: string, +): Promise> { + const response = await client.verifyCredential( + validateVoiceId(credentialId, 'credential'), + ) + return result(response.data, response.metadata) +} + +export async function deleteCredential( + client: VoiceClient, + credentialId: string, +): Promise> { + const response = await client.deleteCredential(credentialId) + return result(response.data, response.metadata) +} + +function validateLabel(label: string): string { + const trimmed = label.trim() + if (trimmed.length === 0 || trimmed.length > MAX_NAME_LENGTH) { + throw new PolymorfaConfigurationError( + `--label must be 1 to ${MAX_NAME_LENGTH} characters.`, + 'label', + ) + } + return trimmed +} + +// ── Rendering ────────────────────────────────────────────────────────────── + +export function renderAudioList( + items: readonly VoiceAudioAsset[], + page: VoiceListResult['page'], +): string { + const rows = items.map((asset) => + [ + asset.id, + asset.name, + asset.source, + asset.status, + duration(asset.durationMs), + asset.createdAt, + ].join('\t'), + ) + if (rows.length === 0) rows.push('No audio found.') + if (page.hasMore && page.nextCursor !== null) { + rows.push(`More results: --cursor ${page.nextCursor}`) + } + return rows.join('\n') +} + +export function renderAudio(asset: VoiceAudioAsset): string { + const lines = [ + `Audio\t${asset.id}`, + `Name\t${asset.name}`, + `Project\t${asset.projectId}`, + `Source\t${asset.source}`, + `Status\t${asset.status}`, + ] + if (asset.failureReason !== null) { + lines.push(`Failure reason\t${asset.failureReason}`) + } + lines.push( + `Duration\t${duration(asset.durationMs)}`, + `Original format\t${asset.originalFormat ?? '-'}`, + `Original size\t${asset.sizeBytes === null ? '-' : `${asset.sizeBytes} bytes`}`, + ) + if (asset.contentSha256 !== null) { + lines.push(`Content SHA-256\t${asset.contentSha256}`) + } + if (asset.tts !== null) { + lines.push( + `TTS provider\t${asset.tts.provider}`, + `TTS voice\t${asset.tts.voiceId}`, + `TTS model\t${asset.tts.model}`, + `TTS characters\t${asset.tts.characters}`, + `TTS key\t${ + asset.tts.credentialId === null + ? asset.tts.keySource + : `${asset.tts.keySource} (${asset.tts.credentialId})` + }`, + ) + } + lines.push( + `Retention\t${ + asset.retentionDays === null + ? 'kept until deleted' + : `${asset.retentionDays} days (expires ${asset.expiresAt ?? '-'})` + }`, + `In use\t${asset.inUseCount}`, + `Revision\t${asset.revision}`, + `Created\t${asset.createdAt}`, + `Updated\t${asset.updatedAt}`, + ) + if (asset.readyAt !== null) lines.push(`Ready\t${asset.readyAt}`) + return lines.join('\n') +} + +export function renderAudioSubmitted(asset: VoiceAudioAsset): string { + if (asset.status === 'ready' || asset.status === 'failed') { + return renderAudio(asset) + } + return `${renderAudio(asset)}\nProcessing continues; run polymorfa voice audio view ${asset.id} or pass --wait.` +} + +export function renderPreview(preview: PreviewResult): string { + if (preview.download !== undefined) { + return `Saved ${preview.download.bytes} bytes of ${preview.preview.contentType} to ${preview.download.path}.` + } + return [ + preview.preview.url, + `Expires ${preview.preview.expiresAt} (${preview.preview.contentType}). Treat the link as a secret.`, + ].join('\n') +} + +export function renderCredentials( + credentials: readonly VoiceProviderCredential[], +): string { + if (credentials.length === 0) return 'No provider credentials found.' + return credentials + .map((credential) => + [ + credential.id, + credential.provider, + credential.label, + credential.projectId ?? 'team-wide', + credential.status, + credential.keyFingerprint, + ].join('\t'), + ) + .join('\n') +} + +export function renderCredential(credential: VoiceProviderCredential): string { + return [ + `Credential\t${credential.id}`, + `Provider\t${credential.provider}`, + `Label\t${credential.label}`, + `Project\t${credential.projectId ?? 'team-wide'}`, + `Key fingerprint\t${credential.keyFingerprint}`, + `Status\t${credential.status}`, + `Last error\t${credential.lastError ?? '-'}`, + `Verified\t${credential.verifiedAt ?? '-'}`, + `Revision\t${credential.revision}`, + `Updated\t${credential.updatedAt}`, + ].join('\n') +} + +export function renderDeleted( + kind: 'audio' | 'provider credential', + deleted: VoiceResourceDeleted, +): string { + return `Deleted ${kind} ${deleted.id}.` +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function duration(durationMs: number | null): string { + if (durationMs === null) return '-' + return `${(durationMs / 1000).toFixed(1)}s` +} + +async function rejectExistingOutput(output: string): Promise { + try { + await stat(output) + } catch (error) { + if (nodeErrorCode(error) === 'ENOENT') return + throw error + } + throw outputConflict(output) +} + +function outputConflict(output: string): PolymorfaConflictError { + return new PolymorfaConflictError( + `Output file ${output} already exists. Pass --force to overwrite it.`, + ) +} + +function nodeErrorCode(error: unknown): string | undefined { + return (error as {code?: string} | undefined)?.code +} + +function defaultSleep(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + +function metadataOf( + metadata: ResponseMetadata, +): VoiceResult['metadata'] { + return { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + } +} + +function result(data: T, metadata: ResponseMetadata): VoiceResult { + return {data, metadata: metadataOf(metadata)} +} + +function listResult( + page: { + readonly hasMore: boolean + readonly items: readonly T[] + readonly nextCursor: string | null + }, + metadata: ResponseMetadata, +): VoiceListResult { + return { + data: page.items, + metadata: metadataOf(metadata), + page: {hasMore: page.hasMore, nextCursor: page.nextCursor}, + } +} diff --git a/test/commands/voice.test.ts b/test/commands/voice.test.ts new file mode 100644 index 00000000..3d4d8f39 --- /dev/null +++ b/test/commands/voice.test.ts @@ -0,0 +1,736 @@ +import {runCommand} from '@oclif/test' +import {mkdtemp, readFile, rm, truncate, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {Readable} from 'node:stream' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpSequenceServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const PROVIDER_KEY = 'sk-provider-secret-0123456789' +const PROJECT = '018f0000-0000-7000-8000-000000000002' +const ASSET_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' +const CREDENTIAL_ID = '7a2d3e4f-5b6c-4d7e-8f90-1a2b3c4d5e6f' + +const asset = { + contentSha256: null, + createdAt: '2026-09-19T10:00:00.000Z', + durationMs: null, + expiresAt: null, + failureReason: null, + id: ASSET_ID, + inUseCount: 0, + name: 'Greeting', + originalContentType: 'audio/mpeg', + originalFormat: 'mp3', + projectId: PROJECT, + readyAt: null, + retentionDays: null, + revision: 1, + sizeBytes: 4, + source: 'upload', + status: 'transcoding', + tts: null, + updatedAt: '2026-09-19T10:00:00.000Z', +} + +const readyAsset = { + ...asset, + contentSha256: 'ab'.repeat(32), + durationMs: 12_345, + readyAt: '2026-09-19T10:00:05.000Z', + revision: 2, + status: 'ready', +} + +const credential = { + createdAt: '2026-09-19T10:00:00.000Z', + id: CREDENTIAL_ID, + keyFingerprint: '1a2b3c4d', + label: 'Studio key', + lastError: null, + projectId: null, + provider: 'elevenlabs', + revision: 1, + status: 'valid', + updatedAt: '2026-09-19T10:00:00.000Z', + verifiedAt: '2026-09-19T10:00:00.000Z', +} + +function ok(data: unknown, status = 200) { + return {body: {data, success: true}, status} +} + +function failure(status: number, code: string, message: string) { + return {body: {error: {code, message}, success: false}, status} +} + +describe('voice audio commands', () => { + let server: TestHttpServer | undefined + let directory: string | undefined + + afterEach(async () => { + await server?.close() + server = undefined + if (directory !== undefined) + await rm(directory, {force: true, recursive: true}) + directory = undefined + }) + + test('uploads a file without the credential, completes it, and waits until ready', async () => { + server = await startHttpSequenceServer([ + ok( + { + asset: {...asset, status: 'pending_upload'}, + upload: { + expiresAt: '2026-09-19T10:05:00.000Z', + headers: {'Content-Type': 'audio/mpeg'}, + maxBytes: 16_777_216, + method: 'POST', + url: '/upload/capability-token', + }, + }, + 201, + ), + {body: {storageId: 'storage_1'}, status: 201}, + ok(asset), + ok(readyAsset), + ]) + directory = await mkdtemp(join(tmpdir(), 'polymorfa-voice-')) + const file = join(directory, 'greeting.MP3') + await writeFile(file, Buffer.from([0x49, 0x44, 0x33, 0x04])) + + const uploaded = await apiKey(() => + runCommand( + `voice audio upload ${file} --name Greeting --retention-days 30 --wait --project ${PROJECT}`, + ), + ) + + expect(uploaded.error, uploaded.stderr).toBeUndefined() + expect(uploaded.stdout).toContain('Status\tready') + expect(uploaded.stdout).toContain('Duration\t12.3s') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'POST /platform/voice/audio', + 'POST /upload/capability-token', + `POST /platform/voice/audio/${ASSET_ID}/complete`, + `GET /platform/voice/audio/${ASSET_ID}`, + ]) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + contentType: 'audio/mpeg', + name: 'Greeting', + projectId: PROJECT, + retentionDays: 30, + sizeBytes: 4, + }) + const upload = server.requests[1] + expect(upload?.headers.authorization).toBeUndefined() + expect(upload?.headers['content-type']).toBe('audio/mpeg') + expect(Buffer.from(upload?.body ?? '', 'utf8')).toHaveLength(4) + expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${API_KEY}`) + }) + + test('detects the content type from each supported extension', async () => { + const types: Record = { + 'a.m4a': 'audio/x-m4a', + 'a.mp4': 'audio/mp4', + 'a.ogg': 'audio/ogg', + 'a.opus': 'audio/ogg', + 'a.wav': 'audio/wav', + } + directory = await mkdtemp(join(tmpdir(), 'polymorfa-voice-')) + for (const [name, contentType] of Object.entries(types)) { + server = await startHttpSequenceServer([ + failure(403, 'voice_not_enabled', 'Voice Automation is not enabled.'), + ]) + const file = join(directory, name) + await writeFile(file, 'RIFF') + await apiKey(() => + runCommand( + `voice audio upload ${file} --name Clip --project ${PROJECT}`, + ), + ) + expect( + (JSON.parse(server.requests[0]?.body ?? '') as {contentType: string}) + .contentType, + ).toBe(contentType) + await server.close() + server = undefined + } + }) + + test('refuses unsupported, empty, and oversized files before calling the API', async () => { + server = await startHttpSequenceServer([ok(asset)]) + directory = await mkdtemp(join(tmpdir(), 'polymorfa-voice-')) + const flac = join(directory, 'clip.flac') + await writeFile(flac, 'fLaC') + const empty = join(directory, 'empty.wav') + await writeFile(empty, '') + const large = join(directory, 'large.wav') + await writeFile(large, '') + await truncate(large, 16_777_217) + const limit = join(directory, 'limit.wav') + await writeFile(limit, '') + await truncate(limit, 16_777_216) + + const unsupported = await apiKey(() => + runCommand(`voice audio upload ${flac} --name Clip --project ${PROJECT}`), + ) + const emptied = await apiKey(() => + runCommand( + `voice audio upload ${empty} --name Clip --project ${PROJECT}`, + ), + ) + const oversized = await apiKey(() => + runCommand( + `voice audio upload ${large} --name Clip --project ${PROJECT} --json`, + ), + ) + const missingProject = await apiKey(() => + runCommand(`voice audio upload ${limit} --name Clip`), + ) + const badRetention = await apiKey(() => + runCommand( + `voice audio upload ${limit} --name Clip --retention-days 0 --project ${PROJECT}`, + ), + ) + + expect(exitCode(unsupported.error)).toBe(2) + expect(unsupported.stderr).toContain('Unsupported audio file type .flac') + expect(exitCode(emptied.error)).toBe(2) + expect(exitCode(oversized.error)).toBe(2) + expect( + (JSON.parse(oversized.stderr) as {error: {message: string}}).error + .message, + ).toContain('limited to 16 MB') + expect(exitCode(missingProject.error)).toBe(2) + expect(exitCode(badRetention.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + test('exits 13 when the awaited audio fails processing', async () => { + server = await startHttpSequenceServer([ + ok({...asset, source: 'tts', status: 'transcoding'}, 201), + ok({ + ...asset, + failureReason: 'tts_failed', + source: 'tts', + status: 'failed', + }), + ]) + + const failed = await apiKey(() => + runCommand( + `voice audio tts --name Welcome --text Hello --provider openai --voice alloy --wait --project ${PROJECT} --json`, + ), + ) + + expect(exitCode(failed.error)).toBe(13) + expect(JSON.parse(failed.stderr)).toMatchObject({ + error: { + code: 'voice_asset_failed', + message: expect.stringContaining('tts_failed') as unknown, + }, + }) + }) + + test('maps voice_not_enabled and gate_limit_reached to distinct exit codes', async () => { + server = await startHttpSequenceServer([ + failure( + 403, + 'voice_not_enabled', + 'Voice Automation is not enabled for this team.', + ), + failure(402, 'gate_limit_reached', 'The voice audio limit is reached.'), + failure(403, 'permission_denied', 'Denied.'), + failure(402, 'payment_required', 'Add credit.'), + ]) + + const notEnabled = await apiKey(() => + runCommand( + `voice audio tts --name A --text Hi --provider openai --voice alloy --project ${PROJECT} --json`, + ), + ) + const limited = await apiKey(() => + runCommand( + `voice audio tts --name A --text Hi --provider openai --voice alloy --project ${PROJECT}`, + ), + ) + const denied = await apiKey(() => + runCommand( + `voice audio tts --name A --text Hi --provider openai --voice alloy --project ${PROJECT}`, + ), + ) + const unpaid = await apiKey(() => + runCommand( + `voice audio tts --name A --text Hi --provider openai --voice alloy --project ${PROJECT}`, + ), + ) + + expect(exitCode(notEnabled.error)).toBe(6) + expect(JSON.parse(notEnabled.stderr)).toMatchObject({ + error: {category: 'authorization', code: 'voice_not_enabled'}, + }) + expect(exitCode(limited.error)).toBe(7) + expect(limited.stderr).toContain('The voice audio limit is reached.') + expect(exitCode(denied.error)).toBe(3) + expect(exitCode(unpaid.error)).toBe(4) + }) + + test('synthesizes text from a file with a stored credential', async () => { + server = await startHttpSequenceServer([ + ok( + { + ...asset, + source: 'tts', + tts: { + characters: 11, + credentialId: CREDENTIAL_ID, + keySource: 'customer', + model: 'eleven_flash_v2_5', + provider: 'elevenlabs', + text: 'Hello there', + voiceId: 'voice123', + }, + }, + 201, + ), + ]) + directory = await mkdtemp(join(tmpdir(), 'polymorfa-voice-')) + const text = join(directory, 'menu.txt') + await writeFile(text, 'Hello there\n') + + const created = await apiKey(() => + runCommand( + `voice audio tts --name Menu --text-file ${text} --provider elevenlabs --voice voice123 --model eleven_flash_v2_5 --credential ${CREDENTIAL_ID} --retention-days 7 --project ${PROJECT}`, + ), + ) + const badVoice = await apiKey(() => + runCommand( + `voice audio tts --name Menu --text Hi --provider elevenlabs --voice bad-voice! --project ${PROJECT}`, + ), + ) + const tooLong = await apiKey(() => + runCommand( + `voice audio tts --name Menu --text ${'a'.repeat(4097)} --provider openai --voice alloy --project ${PROJECT}`, + ), + ) + const both = await apiKey(() => + runCommand( + `voice audio tts --name Menu --text Hi --text-file ${text} --provider openai --voice alloy --project ${PROJECT}`, + ), + ) + const badProvider = await apiKey(() => + runCommand( + `voice audio tts --name Menu --text Hi --provider azure --voice alloy --project ${PROJECT}`, + ), + ) + + expect(created.error, created.stderr).toBeUndefined() + expect(created.stdout).toContain('TTS key\tcustomer') + expect(created.stdout).toContain('pass --wait') + expect(server.requests[0]?.url).toBe('/platform/voice/audio/tts') + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + credentialId: CREDENTIAL_ID, + model: 'eleven_flash_v2_5', + name: 'Menu', + projectId: PROJECT, + provider: 'elevenlabs', + retentionDays: 7, + text: 'Hello there', + voiceId: 'voice123', + }) + expect(exitCode(badVoice.error)).toBe(2) + expect(exitCode(tooLong.error)).toBe(2) + expect(exitCode(both.error)).toBe(2) + expect(exitCode(badProvider.error)).toBe(2) + expect(server.requests).toHaveLength(1) + }) + + test('lists with filters and shows the next cursor', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: [readyAsset], + page: {hasMore: true, nextCursor: 'cursor_2'}, + success: true, + }, + }, + { + body: { + data: [], + page: {hasMore: false, nextCursor: null}, + success: true, + }, + }, + ]) + + const listed = await apiKey(() => + runCommand( + `voice audio list --status ready --limit 10 --cursor cursor_1 --project ${PROJECT}`, + ), + ) + const json = await apiKey(() => + runCommand(`voice audio list --project ${PROJECT} --json`), + ) + const badStatus = await apiKey(() => + runCommand(`voice audio list --status done --project ${PROJECT}`), + ) + + expect(listed.error, listed.stderr).toBeUndefined() + expect(listed.stdout).toBe( + `${ASSET_ID}\tGreeting\tupload\tready\t12.3s\t2026-09-19T10:00:00.000Z\nMore results: --cursor cursor_2\n`, + ) + expect(server.requests[0]?.url).toBe( + `/platform/voice/audio?projectId=${PROJECT}&status=ready&cursor=cursor_1&limit=10`, + ) + expect(JSON.parse(json.stdout)).toMatchObject({ + data: [], + page: {hasMore: false, nextCursor: null}, + }) + expect(exitCode(badStatus.error)).toBe(2) + expect(server.requests).toHaveLength(2) + }) + + test('views and updates with a revision guard', async () => { + server = await startHttpSequenceServer([ + ok(readyAsset), + ok({...readyAsset, name: 'Greeting v2', revision: 3}), + ok({...readyAsset, retentionDays: null, revision: 4}), + failure(409, 'voice_asset_revision_conflict', 'The asset changed.'), + ]) + + const viewed = await apiKey(() => + runCommand(`voice audio view ${ASSET_ID}`), + ) + const renamed = await apiKey(() => + runCommand( + `voice audio update ${ASSET_ID} --name "Greeting v2" --expected-revision 2`, + ), + ) + const forever = await apiKey(() => + runCommand(`voice audio update ${ASSET_ID} --retention-days none`), + ) + const stale = await apiKey(() => + runCommand( + `voice audio update ${ASSET_ID} --retention-days 5 --expected-revision 2`, + ), + ) + const nothing = await apiKey(() => + runCommand(`voice audio update ${ASSET_ID} --expected-revision 2`), + ) + const badId = await apiKey(() => runCommand('voice audio view nope')) + + expect(viewed.stdout).toContain('Content SHA-256\t' + 'ab'.repeat(32)) + expect(viewed.stdout).toContain('Retention\tkept until deleted') + expect(renamed.stdout).toContain('Name\tGreeting v2') + expect(server.requests.map(({body, method}) => [method, body])).toEqual([ + ['GET', ''], + ['PATCH', JSON.stringify({expectedRevision: 2, name: 'Greeting v2'})], + ['PATCH', JSON.stringify({retentionDays: null})], + ['PATCH', JSON.stringify({expectedRevision: 2, retentionDays: 5})], + ]) + expect(forever.error).toBeUndefined() + expect(exitCode(stale.error)).toBe(4) + expect(stale.stderr).toContain('The asset changed.') + expect(exitCode(nothing.error)).toBe(2) + expect(exitCode(badId.error)).toBe(2) + }) + + test('requires confirmation to delete and reports audio in use', async () => { + server = await startHttpSequenceServer([ + ok({deleted: true, id: ASSET_ID}), + failure(409, 'voice_asset_in_use', 'A campaign uses this audio.'), + ]) + + const unconfirmed = await apiKey(() => + runCommand(`voice audio delete ${ASSET_ID}`), + ) + const deleted = await apiKey(() => + runCommand(`voice audio delete ${ASSET_ID} --yes`), + ) + const inUse = await apiKey(() => + runCommand(`voice audio delete ${ASSET_ID} --yes --json`), + ) + + expect(exitCode(unconfirmed.error)).toBe(2) + expect(deleted.stdout).toBe(`Deleted audio ${ASSET_ID}.\n`) + expect(exitCode(inUse.error)).toBe(4) + expect(JSON.parse(inUse.stderr)).toMatchObject({ + error: {code: 'voice_asset_in_use'}, + }) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `DELETE /platform/voice/audio/${ASSET_ID}`, + `DELETE /platform/voice/audio/${ASSET_ID}`, + ]) + }) + + test('prints the preview link or downloads it without the credential', async () => { + const preview = { + contentType: 'audio/ogg', + expiresAt: '2026-09-19T10:05:00.000Z', + url: '/previews/signed-capability', + } + server = await startHttpSequenceServer([ + ok(preview), + ok(preview), + {rawBody: Buffer.from('OggS-audio')}, + failure(409, 'asset_not_ready', 'The audio is not ready.'), + ]) + directory = await mkdtemp(join(tmpdir(), 'polymorfa-voice-')) + const output = join(directory, 'greeting.ogg') + + const printed = await apiKey(() => + runCommand(`voice audio preview ${ASSET_ID}`), + ) + const downloaded = await apiKey(() => + runCommand(`voice audio preview ${ASSET_ID} --output ${output}`), + ) + const exists = await apiKey(() => + runCommand(`voice audio preview ${ASSET_ID} --output ${output}`), + ) + const notReady = await apiKey(() => + runCommand(`voice audio preview ${ASSET_ID}`), + ) + + expect(printed.stdout).toContain('/previews/signed-capability') + expect(printed.stdout).toContain('Treat the link as a secret.') + expect(downloaded.error, downloaded.stderr).toBeUndefined() + expect(downloaded.stdout).toContain('Saved 10 bytes of audio/ogg') + expect(await readFile(output, 'utf8')).toBe('OggS-audio') + expect(server.requests[2]?.url).toBe('/previews/signed-capability') + expect(server.requests[2]?.headers.authorization).toBeUndefined() + expect(exitCode(exists.error)).toBe(4) + expect(exitCode(notReady.error)).toBe(4) + expect(notReady.stderr).toContain('The audio is not ready.') + // The existing output file is refused before any request. + expect( + server.requests.slice(0, 4).map(({method, url}) => `${method} ${url}`), + ).toEqual([ + `GET /platform/voice/audio/${ASSET_ID}/preview`, + `GET /platform/voice/audio/${ASSET_ID}/preview`, + 'GET /previews/signed-capability', + `GET /platform/voice/audio/${ASSET_ID}/preview`, + ]) + }) + + async function apiKey(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_VOICE_PROVIDER_KEY: undefined, + }, + run, + ) + } +}) + +describe('voice credential commands', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('adds a key from the default environment variable and never prints it', async () => { + server = await startHttpSequenceServer([ok(credential, 201)]) + + const added = await apiKey( + () => + runCommand( + 'voice credential add --provider elevenlabs --label "Studio key" --json', + ), + {POLYMORFA_VOICE_PROVIDER_KEY: PROVIDER_KEY}, + ) + + expect(added.error, added.stderr).toBeUndefined() + expect(JSON.parse(added.stdout)).toMatchObject({ + data: {keyFingerprint: '1a2b3c4d', projectId: null}, + }) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + apiKey: PROVIDER_KEY, + label: 'Studio key', + provider: 'elevenlabs', + }) + expect(added.stdout).not.toContain(PROVIDER_KEY) + expect(added.stderr).not.toContain(PROVIDER_KEY) + }) + + test('adds a project key from a named variable or standard input', async () => { + server = await startHttpSequenceServer([ + ok({...credential, projectId: PROJECT, provider: 'openai'}, 201), + ok({...credential, projectId: PROJECT, provider: 'openai'}, 201), + ]) + + const fromNamed = await apiKey( + () => + runCommand( + `voice credential add --provider openai --label Support --key-env OPENAI_TEST_KEY --project ${PROJECT}`, + ), + {OPENAI_TEST_KEY: PROVIDER_KEY}, + ) + const fromStdin = await apiKey(() => + withStdin(`${PROVIDER_KEY}\n`, () => + runCommand( + `voice credential add --provider openai --label Support --project ${PROJECT}`, + ), + ), + ) + const missingNamed = await apiKey(() => + runCommand( + 'voice credential add --provider openai --label Support --key-env OPENAI_TEST_KEY', + ), + ) + const keyFlag = await apiKey(() => + runCommand( + `voice credential add --provider openai --label Support --key ${PROVIDER_KEY}`, + ), + ) + + expect(fromNamed.error, fromNamed.stderr).toBeUndefined() + expect(fromNamed.stdout).toContain(`Project\t${PROJECT}`) + expect(fromStdin.error, fromStdin.stderr).toBeUndefined() + for (const request of server.requests) { + expect(JSON.parse(request.body)).toEqual({ + apiKey: PROVIDER_KEY, + label: 'Support', + projectId: PROJECT, + provider: 'openai', + }) + } + expect(exitCode(missingNamed.error)).toBe(2) + expect(missingNamed.stderr).toContain('OPENAI_TEST_KEY is not set') + expect(exitCode(keyFlag.error)).toBe(2) + expect(server.requests).toHaveLength(2) + for (const output of [fromNamed, fromStdin, missingNamed]) { + expect(output.stdout).not.toContain(PROVIDER_KEY) + expect(output.stderr).not.toContain(PROVIDER_KEY) + } + }) + + test('redacts the key from a rejected credential error', async () => { + server = await startHttpSequenceServer([ + failure( + 422, + 'provider_credential_invalid', + `The provider rejected ${PROVIDER_KEY}.`, + ), + failure(403, 'voice_not_enabled', 'Voice Automation is not enabled.'), + ]) + + const rejected = await apiKey( + () => + runCommand('voice credential add --provider openai --label Bad --json'), + {POLYMORFA_VOICE_PROVIDER_KEY: PROVIDER_KEY}, + ) + const notEnabled = await apiKey( + () => runCommand('voice credential add --provider openai --label Bad'), + {POLYMORFA_VOICE_PROVIDER_KEY: PROVIDER_KEY}, + ) + + expect(exitCode(rejected.error)).toBe(2) + expect(JSON.parse(rejected.stderr)).toMatchObject({ + error: {code: 'provider_credential_invalid'}, + }) + expect(rejected.stderr).toContain('[REDACTED]') + expect(rejected.stderr).not.toContain(PROVIDER_KEY) + expect(exitCode(notEnabled.error)).toBe(6) + }) + + test('lists, views, verifies, and deletes credentials', async () => { + server = await startHttpSequenceServer([ + ok([credential, {...credential, id: ASSET_ID, projectId: PROJECT}]), + ok(credential), + ok({...credential, lastError: 'quota_exceeded', status: 'invalid'}), + failure(503, 'provider_unavailable', 'The provider did not answer.'), + ok({deleted: true, id: CREDENTIAL_ID}), + ]) + + const listed = await apiKey(() => + runCommand(`voice credential list --project ${PROJECT}`), + ) + const viewed = await apiKey(() => + runCommand(`voice credential view ${CREDENTIAL_ID}`), + ) + const verified = await apiKey(() => + runCommand(`voice credential verify ${CREDENTIAL_ID}`), + ) + const unavailable = await apiKey(() => + runCommand(`voice credential verify ${CREDENTIAL_ID}`), + ) + const unconfirmed = await apiKey(() => + runCommand(`voice credential delete ${CREDENTIAL_ID}`), + ) + const deleted = await apiKey(() => + runCommand(`voice credential delete ${CREDENTIAL_ID} --yes`), + ) + + expect(listed.stdout).toBe( + [ + `${CREDENTIAL_ID}\televenlabs\tStudio key\tteam-wide\tvalid\t1a2b3c4d`, + `${ASSET_ID}\televenlabs\tStudio key\t${PROJECT}\tvalid\t1a2b3c4d`, + '', + ].join('\n'), + ) + expect(viewed.stdout).toContain('Key fingerprint\t1a2b3c4d') + expect(verified.stdout).toContain('Status\tinvalid') + expect(verified.stdout).toContain('Last error\tquota_exceeded') + expect(exitCode(unavailable.error)).toBe(5) + expect(exitCode(unconfirmed.error)).toBe(2) + expect(deleted.stdout).toBe( + `Deleted provider credential ${CREDENTIAL_ID}.\n`, + ) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `GET /platform/voice/provider-credentials?projectId=${PROJECT}`, + `GET /platform/voice/provider-credentials/${CREDENTIAL_ID}`, + `POST /platform/voice/provider-credentials/${CREDENTIAL_ID}/verify`, + `POST /platform/voice/provider-credentials/${CREDENTIAL_ID}/verify`, + `DELETE /platform/voice/provider-credentials/${CREDENTIAL_ID}`, + ]) + }) + + async function apiKey( + run: () => Promise, + extra: Readonly> = {}, + ): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + OPENAI_TEST_KEY: undefined, + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_VOICE_PROVIDER_KEY: undefined, + ...extra, + }, + run, + ) + } +}) + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +} + +async function withStdin(value: string, run: () => Promise): Promise { + const descriptor = Object.getOwnPropertyDescriptor(process, 'stdin') + Object.defineProperty(process, 'stdin', { + configurable: true, + value: Readable.from([value]), + }) + try { + return await run() + } finally { + if (descriptor !== undefined) + Object.defineProperty(process, 'stdin', descriptor) + } +} diff --git a/test/services/voice.test.ts b/test/services/voice.test.ts new file mode 100644 index 00000000..f2fddc79 --- /dev/null +++ b/test/services/voice.test.ts @@ -0,0 +1,171 @@ +import {PassThrough, Readable} from 'node:stream' +import {describe, expect, test, vi} from 'vitest' + +import {createVoiceClient, type VoiceClient} from '../../src/sdk/voice.js' +import { + readProviderKey, + uploadContentType, + waitForAudio, + type VoiceResult, +} from '../../src/services/voice.js' +import type {VoiceAudioAsset} from '../../src/sdk/voice.js' + +const KEY = 'sk-provider-secret-0123456789' + +function stdin(value: string | undefined, isTTY = false) { + const stream = ( + value === undefined ? new PassThrough() : Readable.from([value]) + ) as Readable & {isTTY?: boolean} + stream.isTTY = isTTY + return stream +} + +describe('readProviderKey', () => { + test('prefers the default environment variable over standard input', async () => { + await expect( + readProviderKey({ + env: {POLYMORFA_VOICE_PROVIDER_KEY: ` ${KEY} `}, + stdin: stdin('other'), + }), + ).resolves.toBe(KEY) + }) + + test('reads piped standard input when no variable is set', async () => { + await expect( + readProviderKey({env: {}, stdin: stdin(`${KEY}\n`)}), + ).resolves.toBe(KEY) + }) + + test('refuses a terminal so the key is never echoed', async () => { + await expect( + readProviderKey({env: {}, stdin: stdin(undefined, true)}), + ).rejects.toThrow(/never read from an argument/) + }) + + test('refuses an unset named variable, a bad name, and an oversized key', async () => { + await expect( + readProviderKey({env: {}, keyEnv: 'MY_KEY', stdin: stdin(KEY)}), + ).rejects.toThrow('Environment variable MY_KEY is not set.') + await expect( + readProviderKey({env: {}, keyEnv: 'not a name', stdin: stdin(KEY)}), + ).rejects.toThrow('--key-env must name an environment variable.') + await expect( + readProviderKey({ + env: {MY_KEY: 'k'.repeat(513)}, + keyEnv: 'MY_KEY', + stdin: stdin(KEY), + }), + ).rejects.toThrow('1 to 512 characters') + }) +}) + +describe('uploadContentType', () => { + test('maps extensions case-insensitively and refuses others', () => { + expect(uploadContentType('a.MP3')).toBe('audio/mpeg') + expect(uploadContentType('a.opus')).toBe('audio/ogg') + expect(() => uploadContentType('a.aac')).toThrow('Unsupported audio file') + expect(() => uploadContentType('noext')).toThrow('(no extension)') + }) +}) + +describe('waitForAudio', () => { + const asset = { + id: '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10', + status: 'transcoding', + } as VoiceAudioAsset + const submitted: VoiceResult = { + data: asset, + metadata: {attempts: 1, status: 201}, + } + + test('times out with request_timeout while the asset keeps processing', async () => { + const retrieveAudio = vi.fn(() => + Promise.resolve({ + data: asset, + metadata: {attempts: 1, headers: {}, status: 200}, + }), + ) + const client = {retrieveAudio} as unknown as VoiceClient + let now = 0 + vi.spyOn(Date, 'now').mockImplementation(() => now) + try { + await expect( + waitForAudio(client, submitted, { + sleep: (milliseconds) => { + now += milliseconds + return Promise.resolve() + }, + timeoutSeconds: 5, + }), + ).rejects.toMatchObject({code: 'request_timeout'}) + expect(retrieveAudio).toHaveBeenCalledTimes(4) + } finally { + vi.restoreAllMocks() + } + }) +}) + +describe('createVoiceClient', () => { + const environment = { + baseUrl: 'https://api.example.test', + credential: { + type: 'apiKey' as const, + value: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + surface: 'platform' as const, + } + const upload = { + expiresAt: '2026-09-19T10:05:00.000Z', + headers: {Authorization: 'Bearer leak', 'Content-Type': 'audio/wav'}, + maxBytes: 16_777_216, + method: 'POST', + url: 'https://uploads.example.test/u/token', + } + + test('sends only the upload headers, never a credential', async () => { + const fetch = vi.fn<(url: URL, init: RequestInit) => Promise>( + () => Promise.resolve(new Response('{"storageId":"s"}', {status: 201})), + ) + const client = createVoiceClient( + environment, + fetch as typeof globalThis.fetch, + ) + + await client.sendToUploadUrl(upload, new Uint8Array([1, 2])) + + const [url, init] = fetch.mock.calls[0] ?? [] + expect(String(url)).toBe('https://uploads.example.test/u/token') + const headers = new Headers(init?.headers) + expect(headers.get('authorization')).toBeNull() + expect(headers.get('content-type')).toBe('audio/wav') + expect(init?.redirect).toBe('error') + }) + + test('refuses plain HTTP upload URLs and maps 413 and 415 without the URL', async () => { + const fetch = vi.fn(() => Promise.resolve(new Response('', {status: 413}))) + const client = createVoiceClient(environment, fetch) + + await expect( + client.sendToUploadUrl( + {...upload, url: 'http://uploads.example.test/u/token'}, + new Uint8Array([1]), + ), + ).rejects.toMatchObject({code: 'invalid_upload_url'}) + expect(fetch).not.toHaveBeenCalled() + + const tooLarge = client.sendToUploadUrl(upload, new Uint8Array([1])) + await expect(tooLarge).rejects.toMatchObject({ + code: 'too_large', + status: 413, + }) + await expect(tooLarge).rejects.not.toThrow(/token/) + + fetch.mockImplementationOnce(() => + Promise.resolve(new Response('', {status: 415})), + ) + await expect( + client.sendToUploadUrl(upload, new Uint8Array([1])), + ).rejects.toThrow('content type does not match') + }) +}) From e03082495ab5c980e3c5d51164ef02c312135257 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 19 Sep 2026 20:43:53 +0200 Subject: [PATCH 03/11] docs(voice): document the Voice Automation beta commands and exit codes README section with the audio library and provider credential commands, key input rules and voice exit codes (6 voice_not_enabled, 7 gate_limit_reached, 13 voice_asset_failed); command research for contract voice-audio-v1 and the SDK pin situation; a skill note on beta errors and provider key handling. --- README.md | 89 ++++++++++++++++++++ docs/command-research/voice.md | 146 +++++++++++++++++++++++++++++++++ skills/polymorfa/SKILL.md | 2 + 3 files changed, 237 insertions(+) create mode 100644 docs/command-research/voice.md diff --git a/README.md b/README.md index 3d206bde..7169b8c2 100644 --- a/README.md +++ b/README.md @@ -569,6 +569,95 @@ passwords never appear in the command line. `create` refuses a body that names exits with code 4 when the trunk changed; `delete` exits with code 4 while a session routes calls to the trunk. +## Voice Automation (beta) + +Voice Automation is a beta (`calls.voice-automation`) for enrolled teams only. +For other teams, every change except delete fails with `voice_not_enabled`. +Reads, previews and deletes keep working after a team leaves the beta. The +commands require an organization server API key with `voice:read` (lists, +views, previews) or `voice:manage` (changes). + +### Audio library + +The audio library holds recordings and text-to-speech renders for calls. The +API transcodes each asset to 16 kHz mono; an asset is `transcoding` until it is +`ready` or `failed`. + +```shell +polymorfa voice audio upload greeting.mp3 --name Greeting \ + --project 018f0000-0000-7000-8000-000000000002 --wait +polymorfa voice audio tts --name Welcome --text "Thanks for calling." \ + --provider openai --voice alloy \ + --project 018f0000-0000-7000-8000-000000000002 +polymorfa voice audio list --status ready \ + --project 018f0000-0000-7000-8000-000000000002 +polymorfa voice audio view 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 +polymorfa voice audio update 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 \ + --name "Greeting v2" --retention-days none --expected-revision 2 +polymorfa voice audio preview 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 \ + --output greeting.ogg +polymorfa voice audio delete 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes +``` + +- `upload` takes `.mp3`, `.wav`, `.ogg`, `.opus`, `.m4a` and `.mp4` files up to + 16 MB and sets the content type from the extension. Other types, empty files + and larger files fail with exit code 2 before any request is sent. The file + goes to a short-lived upload URL that carries no credential. +- `tts` reads the text from `--text` or `--text-file` (`-` for stdin): 1 to + 5000 characters, or 4096 for `openai`. `--voice` is an OpenAI voice name or + an ElevenLabs voice ID. `--model` defaults to the provider's default model. + Without `--credential`, the render uses Polymorfa's managed key for the + provider. +- `upload --wait` and `tts --wait` wait until the asset is `ready` (exit 0) or + `failed` (exit 13, with the failure reason). `--wait-timeout` defaults to 120 + seconds and exits with code 5 when reached. +- `--retention-days` deletes the asset that many days after creation (1 to + 3650). `update --retention-days none` keeps it until you delete it. +- `preview` prints a link to the transcoded audio (Opus in Ogg). The link works + for 5 minutes; treat it as a secret. `--output` downloads it instead and + refuses an existing file unless `--force` is given. +- `delete` asks for confirmation, or takes `--yes`, and exits with code 4 while + a campaign or flow uses the asset. + +### Provider credentials + +Store your own ElevenLabs or OpenAI key to render text to speech with it. The +CLI never takes the key from an argument. It reads the environment variable +named by `--key-env` (default `POLYMORFA_VOICE_PROVIDER_KEY`) or, when that is +unset, piped standard input, and it refuses to read the key from a terminal: + +```shell +printf '%s' "$ELEVENLABS_API_KEY" | \ + polymorfa voice credential add --provider elevenlabs --label "Studio key" +polymorfa voice credential add --provider openai --label "Support line" \ + --key-env OPENAI_API_KEY --project 018f0000-0000-7000-8000-000000000002 +polymorfa voice credential list --project 018f0000-0000-7000-8000-000000000002 +polymorfa voice credential view 7a2d3e4f-5b6c-4d7e-8f90-1a2b3c4d5e6f +polymorfa voice credential verify 7a2d3e4f-5b6c-4d7e-8f90-1a2b3c4d5e6f +polymorfa voice credential delete 7a2d3e4f-5b6c-4d7e-8f90-1a2b3c4d5e6f --yes +``` + +Without `--project`, the credential is available to every project of the team. +Output shows only the key's fingerprint (the first 8 hex characters of its +SHA-256). The provider checks the key when it is added and on `verify`; a +rejected key fails with `provider_credential_invalid` (exit code 2). + +### Voice exit codes + +| Exit code | Error code | Meaning | +| --------- | ------------------------------------------------------------------------ | ------------------------------------------------------- | +| 6 | `voice_not_enabled` | The team is not enrolled in the Voice Automation beta. | +| 7 | `gate_limit_reached` | A voice limit is reached. | +| 13 | `voice_asset_failed` | Audio awaited with `--wait` failed processing. | +| 4 | `voice_asset_in_use`, `voice_asset_revision_conflict`, `asset_not_ready` | The asset is in use, changed, or not ready. | +| 2 | `provider_credential_invalid`, `invalid_parameter` | The provider rejected the key, or the input is invalid. | +| 5 | `provider_unavailable`, `voice_unavailable` | The provider or voice service did not answer. | + +Other errors keep the standard exit codes: 2 for invalid input, 3 for +authentication and authorization, 4 for conflicts, missing resources and other +payment errors, and 5 for network, rate-limit, server and timeout failures. +With `--json`, stderr carries the error code in `error.code`. + ## Session call rejection and stable identities `call reject` is a separate command for the Linked Device session route. It diff --git a/docs/command-research/voice.md b/docs/command-research/voice.md new file mode 100644 index 00000000..1df949b7 --- /dev/null +++ b/docs/command-research/voice.md @@ -0,0 +1,146 @@ +# Voice Automation command research + +- **Status:** Implemented against contract revision `voice-audio-v1`, + unpublished, beta (`calls.voice-automation`) +- **Reviewed:** 2026-09-19 +- **API contract:** polymorfa/polymorfa draft PR #227, branch + `t3code/voice-audio-library`, revision `voice-audio-v1` +- **SDK reference:** `client.voice` in polymorfa/sdks draft PR #279 at + `f0f3c0719ce44226aa6af896c33a69c71ef934eb` +- **SDK pin:** unchanged, `polymorfa/sdks` commit + `94bfd09825817f842434e206e6a77adb5cc0c414` (see + [SDK dependency](#sdk-dependency)) + +## Sources reviewed + +- The voice audio library and provider credential contract, revision + `voice-audio-v1`: routes, schemas, closed enums, error codes and webhook + events. +- Design section 18.2 (CLI) of the Voice Automation specification: command + tree, key input rule, and distinct exit codes for `gate_limit_reached` and + `voice_not_enabled`. +- `packages/typescript/src/platform/voice.ts` and the upload URL transport in + polymorfa/sdks `f0f3c07`. + +## Shipped command tree + +```text +voice + audio + upload --name --project [--retention-days] [--wait] [--wait-timeout] + tts --name (--text | --text-file) --provider elevenlabs|openai --voice + [--model] [--credential] [--retention-days] --project [--wait] + list --project [--status] [--limit] [--cursor] + view + update [--name] [--retention-days ] [--expected-revision] + delete [--yes] + preview [--output ] [--force] + credential + add --provider --label [--project] [--key-env] + list [--project] + view + verify + delete [--yes] +``` + +Campaigns, flows, agents, usage and gates (design section 18.2) belong to later +Voice Automation phases and are not part of this revision. + +## Operation mapping + +| Command | Operation | SDK `client.voice` (f0f3c07) | +| ------------------------- | ---------------------------------------------------------- | ------------------------------ | +| `voice audio upload` | `POST /platform/voice/audio`, upload URL, `.../complete` | `audio.upload` | +| `voice audio tts` | `POST /platform/voice/audio/tts` | `audio.synthesize` | +| `voice audio list` | `GET /platform/voice/audio` | `audio.list` | +| `voice audio view` | `GET /platform/voice/audio/{id}` | `audio.retrieve` | +| `voice audio update` | `PATCH /platform/voice/audio/{id}` | `audio.update` | +| `voice audio delete` | `DELETE /platform/voice/audio/{id}` | `audio.delete` | +| `voice audio preview` | `GET /platform/voice/audio/{id}/preview` | `audio.previewUrl` | +| `--wait` | `GET /platform/voice/audio/{id}` until `ready` or `failed` | `audio.waitUntilReady` | +| `voice credential add` | `POST /platform/voice/provider-credentials` | `providerCredentials.create` | +| `voice credential list` | `GET /platform/voice/provider-credentials` | `providerCredentials.list` | +| `voice credential view` | `GET /platform/voice/provider-credentials/{id}` | `providerCredentials.retrieve` | +| `voice credential verify` | `POST /platform/voice/provider-credentials/{id}/verify` | `providerCredentials.verify` | +| `voice credential delete` | `DELETE /platform/voice/provider-credentials/{id}` | `providerCredentials.delete` | + +## SDK dependency + +The pinned SDK has no `client.voice`. Since polymorfa/sdks `4f54b26` the SDK +lives in the `packages/typescript` workspace of a private workspace root, so a +`github:polymorfa/sdks#` pin installs the root, not `@polymorfa/sdk`, and +cannot take `f0f3c07`. No `@polymorfa/sdk` prerelease is published yet (see +the draft move to npm dev prereleases in this repository). + +Until a release with `client.voice` can be pinned, `src/sdk/voice.ts` sends the +documented requests through the organization client's raw transport, the same approach as the open +call retention change. The SDK still resolves credentials, retries and +maps errors. The adapter mirrors the SDK's upload rules: the upload and preview +URLs must be HTTPS (HTTP only on loopback) without embedded credentials, the +request carries only the returned headers and never `Authorization`, +redirects are refused, and error messages never include the URL. Replace the +adapter with `client.voice` when the SDK pin moves. + +## Credential rules + +- The commands resolve Platform credentials: an organization server API key + (`POLYMORFA_API_KEY` or an API-key profile). Client tokens fail before any + request. The API also accepts project tokens; the CLI does not use them for + these commands, matching `sip-trunk`. +- `audio list`, `upload` and `tts` require `--project`. `credential add` and + `credential list` take an optional `--project`; without it, `add` creates a + team-wide credential and `list` returns only team-wide credentials. +- Provider keys are never read from an argument. `credential add` reads the + variable named by `--key-env` (default `POLYMORFA_VOICE_PROVIDER_KEY`); when + the default variable is unset it reads piped standard input (64 KiB limit) + and refuses a terminal, because the repository has no hidden-input prompt. + An explicitly named but unset variable is an error; it never falls back to + stdin. The key is registered for redaction, so it is replaced with + `[REDACTED]` if an error message ever contains it. + +## Command decisions + +- **Local refusals.** Upload type (from the extension, case-insensitive), + empty files, files over 16,777,216 bytes, names over 100 characters, + retention outside 1 to 3650, TTS text length (5000, or 4096 for `openai`), + ElevenLabs voice IDs (`^[A-Za-z0-9]{1,64}$`), non-UUID IDs and oversized keys + (over 512 characters) fail with exit code 2 before any request. OpenAI voice + and model names are left to the API so new values work without a CLI + release. +- **Content types.** `.mp3` sends `audio/mpeg`, `.wav` `audio/wav`, `.ogg` and + `.opus` `audio/ogg`, `.m4a` `audio/x-m4a`, `.mp4` `audio/mp4`. +- **Waiting.** `--wait` reads the asset first and then every 2 seconds, like the + SDK's `waitUntilReady`. `failed` exits with `voice_asset_failed` (13); + reaching `--wait-timeout` exits with `request_timeout` (5). The upload request + itself is not retried, matching the SDK. +- **Preview.** Without `--output` the command prints the 5-minute URL. With it, + the file is downloaded without a credential, limited to 32 MiB, and an + existing file is refused unless `--force` is given. +- **Confirmation.** Both deletes use the shared confirmation prompt, or + `--yes` in noninteractive use. +- **No idempotency flags.** The contract defines no idempotency behavior for + these routes. + +## Exit codes + +| Exit code | Code | +| --------- | ------------------------------------------------------------------------ | +| 6 | `voice_not_enabled` (any status) | +| 7 | `gate_limit_reached` (any status) | +| 13 | `voice_asset_failed` (CLI condition for `--wait`) | +| 4 | `voice_asset_in_use`, `voice_asset_revision_conflict`, `asset_not_ready` | +| 2 | `provider_credential_invalid`, `invalid_parameter`, local refusals | +| 5 | `provider_unavailable`, `voice_unavailable`, `request_timeout` | + +`src/errors/present.ts` maps the two voice codes before the status-based +classification, so they keep their exit code even if the API changes the HTTP +status. + +## Explicit gaps + +- This revision adds no command for the `voice.asset_ready` and + `voice.asset_failed` webhook events; they arrive through the existing + webhook, event and listener commands. +- No project-token support in these commands (see Credential rules). +- The CLI cannot release until `@polymorfa/sdk` with `client.voice` is + published and pinned, and the API revision is deployed. diff --git a/skills/polymorfa/SKILL.md b/skills/polymorfa/SKILL.md index d7d00122..aca175fb 100644 --- a/skills/polymorfa/SKILL.md +++ b/skills/polymorfa/SKILL.md @@ -16,6 +16,8 @@ Use `polymorfa --help` and `polymorfa --help` as the installed command a Organization API keys and exact-project tokens are durable principals. Browser client tokens and listener credentials are not general CLI credentials. Do not print, log, persist in project files, or pass credentials in command arguments. +Voice Automation commands (`polymorfa voice audio`, `polymorfa voice credential`) are a beta for enrolled teams; `voice_not_enabled` (exit 6) means the team is not enrolled and `gate_limit_reached` (exit 7) means a voice limit is reached, so do not retry either. Pass a provider key to `polymorfa voice credential add` only through standard input or the environment variable named by `--key-env`, never as an argument. + Use `polymorfa quicklink create --project PROJECT --input request.json` with an organization API key for standard number pairing. Treat the returned hosted URL as credential material; do not request direct session QR payloads or pairing codes. ## Choose an interface From 8771fdeb8fff8eb96454ee9368d66bbda8824953 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 12:16:18 +0200 Subject: [PATCH 04/11] fix(voice): keep transfer timeouts active through body reads and bound preview downloads while streaming --- src/sdk/voice.ts | 150 +++++++++++++++++------ test/services/voice.test.ts | 229 ++++++++++++++++++++++++++++++++++++ 2 files changed, 346 insertions(+), 33 deletions(-) diff --git a/src/sdk/voice.ts b/src/sdk/voice.ts index c8e9fe66..27ba69ef 100644 --- a/src/sdk/voice.ts +++ b/src/sdk/voice.ts @@ -238,19 +238,26 @@ export function createVoiceClient( return {data: envelopeData(response.data), metadata: response.metadata} } - async function transfer( + /** + * Sends one credential-free request and consumes its response inside the + * same timed section, so a server that sends headers and then stalls on the + * body still hits the transfer timeout. + */ + async function transfer( url: URL, init: RequestInit, label: string, - ): Promise { + consume: (response: Response, signal: AbortSignal) => Promise, + ): Promise { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), transferTimeoutMs) try { - return await fetchImplementation(url, { + const response = await fetchImplementation(url, { ...init, redirect: 'error', signal: controller.signal, }) + return await consume(response, controller.signal) } catch (error) { if (controller.signal.aborted) { throw new PolymorfaTimeoutError( @@ -258,6 +265,7 @@ export function createVoiceClient( {cause: error, code: 'request_timeout'}, ) } + if (error instanceof PolymorfaError) throw error // Never include the URL: it is a short-lived capability. throw new PolymorfaConnectionError( `The ${label} could not reach its URL.`, @@ -302,7 +310,7 @@ export function createVoiceClient( if (name.toLowerCase() === 'authorization') continue headers.set(name, value) } - const response = await transfer( + return transfer( url, { body: body as unknown as BodyInit, @@ -310,12 +318,18 @@ export function createVoiceClient( method: upload.method === 'PUT' ? 'PUT' : 'POST', }, 'upload', + async (response, signal) => { + if (!response.ok) { + throw transferError( + response, + await safeBody(response, signal), + 'upload', + ) + } + await discardBody(response) + return transferMetadata(response) + }, ) - const metadata = transferMetadata(response) - if (!response.ok) { - throw transferError(response, await safeBody(response), 'upload') - } - return metadata }, completeAudio: (assetId) => data('POST', `${assetPath(assetId)}/complete`), @@ -331,22 +345,30 @@ export function createVoiceClient( data('GET', `${assetPath(assetId)}/preview`), async downloadPreview(preview, maxBytes) { const url = capabilityUrl(baseUrl, preview.url, 'preview') - const response = await transfer(url, {method: 'GET'}, 'preview download') - if (!response.ok) { - throw transferError( - response, - await safeBody(response), - 'preview download', - ) - } - const declared = Number(response.headers.get('content-length')) - if (Number.isFinite(declared) && declared > maxBytes) { - await response.body?.cancel() - throw tooLargePreview(maxBytes) - } - const bytes = new Uint8Array(await response.arrayBuffer()) - if (bytes.byteLength > maxBytes) throw tooLargePreview(maxBytes) - return bytes + return transfer( + url, + {method: 'GET'}, + 'preview download', + async (response, signal) => { + if (!response.ok) { + throw transferError( + response, + await safeBody(response, signal), + 'preview download', + ) + } + const declared = Number(response.headers.get('content-length')) + if (Number.isFinite(declared) && declared > maxBytes) { + await discardBody(response) + throw tooLargePreview(maxBytes) + } + // Content-Length can be missing, understated or describe a + // compressed body, so the limit is enforced on the bytes read. + const bytes = await readBody(response, signal, maxBytes) + if (bytes === undefined) throw tooLargePreview(maxBytes) + return bytes + }, + ) }, listCredentials: (projectId) => data('GET', CREDENTIALS_PATH, { @@ -433,20 +455,82 @@ function transferMetadata(response: Response): ResponseMetadata { } } -async function safeBody(response: Response): Promise { +/** Error bodies are only inspected for an error code. */ +const MAX_ERROR_BODY_BYTES = 65_536 + +async function safeBody( + response: Response, + signal: AbortSignal, +): Promise { + let bytes: Uint8Array | undefined try { - const text = await response.text() - if (text.length === 0) return undefined - try { - return JSON.parse(text) as unknown - } catch { - return undefined - } + bytes = await readBody(response, signal, MAX_ERROR_BODY_BYTES) + } catch (error) { + // A timeout must surface as a timeout, not as the status error. + if (signal.aborted) throw error + return undefined + } + if (bytes === undefined || bytes.byteLength === 0) return undefined + try { + return JSON.parse(new TextDecoder().decode(bytes)) as unknown } catch { return undefined } } +/** + * Reads a response body incrementally. Returns undefined, after cancelling + * the stream, as soon as more than maxBytes arrive. Aborting the signal + * cancels the stream and rejects, even when the body source ignores it. + */ +async function readBody( + response: Response, + signal: AbortSignal, + maxBytes: number, +): Promise { + const body = response.body + if (body === null) return new Uint8Array() + const reader = body.getReader() + const onAbort = () => { + reader.cancel(signal.reason).catch(() => undefined) + } + signal.addEventListener('abort', onAbort, {once: true}) + const chunks: Uint8Array[] = [] + let total = 0 + try { + for (;;) { + if (signal.aborted) throw abortReason(signal) + const {done, value} = await reader.read() + // A cancelled read resolves as done; never return a truncated body. + if (signal.aborted) throw abortReason(signal) + if (done) break + total += value.byteLength + if (total > maxBytes) { + await reader.cancel().catch(() => undefined) + return undefined + } + chunks.push(value) + } + } finally { + signal.removeEventListener('abort', onAbort) + } + const bytes = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + return bytes +} + +async function discardBody(response: Response): Promise { + await response.body?.cancel().catch(() => undefined) +} + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new Error('The transfer was aborted.') +} + function transferError( response: Response, body: unknown, diff --git a/test/services/voice.test.ts b/test/services/voice.test.ts index f2fddc79..724c1d74 100644 --- a/test/services/voice.test.ts +++ b/test/services/voice.test.ts @@ -1,14 +1,19 @@ +import {readdir, rm} from 'node:fs/promises' +import {join} from 'node:path' import {PassThrough, Readable} from 'node:stream' import {describe, expect, test, vi} from 'vitest' import {createVoiceClient, type VoiceClient} from '../../src/sdk/voice.js' import { + MAX_PREVIEW_BYTES, + previewAudio, readProviderKey, uploadContentType, waitForAudio, type VoiceResult, } from '../../src/services/voice.js' import type {VoiceAudioAsset} from '../../src/sdk/voice.js' +import {temporaryDirectory} from '../support/fs.js' const KEY = 'sk-provider-secret-0123456789' @@ -169,3 +174,227 @@ describe('createVoiceClient', () => { ).rejects.toThrow('content type does not match') }) }) + +describe('voice transfers', () => { + const ASSET_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' + const preview = { + contentType: 'audio/ogg', + expiresAt: '2026-09-19T10:05:00.000Z', + url: 'https://previews.example.test/p/token', + } + const upload = { + expiresAt: '2026-09-19T10:05:00.000Z', + headers: {'Content-Type': 'audio/wav'}, + maxBytes: 16_777_216, + method: 'PUT', + url: 'https://uploads.example.test/u/token', + } + + function environment(timeoutMs?: number) { + return { + baseUrl: 'https://api.example.test', + credential: { + type: 'apiKey' as const, + value: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + surface: 'platform' as const, + ...(timeoutMs === undefined ? {} : {timeoutMs}), + } + } + + /** Headers arrive at once; the body never produces a byte. */ + function stalledBody() { + const cancel = vi.fn() + const body = new ReadableStream({ + cancel, + pull: () => new Promise(() => undefined), + }) + return {body, cancel} + } + + /** An endless body with no Content-Length that counts what it produced. */ + function endlessBody(chunkBytes: number) { + const chunk = new Uint8Array(chunkBytes) + const state = {cancelled: false, produced: 0} + const body = new ReadableStream({ + cancel() { + state.cancelled = true + }, + pull(controller) { + state.produced += chunk.byteLength + controller.enqueue(chunk) + }, + }) + return {body, state} + } + + // The mocked fetch ignores the abort signal, so these tests prove the + // client cancels the body itself rather than relying on the transport. + function fetchReturning(response: () => Response) { + return vi.fn(() => Promise.resolve(response())) as typeof fetch + } + + test('times out a preview whose body stalls after the headers', async () => { + const {body, cancel} = stalledBody() + const client = createVoiceClient( + environment(50), + fetchReturning(() => new Response(body, {status: 200})), + ) + const started = Date.now() + + const download = client.downloadPreview(preview, 1024) + + await expect(download).rejects.toMatchObject({code: 'request_timeout'}) + await expect(download).rejects.toThrow( + 'The preview download did not finish within 50ms.', + ) + await expect(download).rejects.not.toThrow(/token/) + expect(Date.now() - started).toBeLessThan(2_000) + expect(cancel).toHaveBeenCalled() + }) + + test('times out an error response whose body stalls after the headers', async () => { + const preview500 = stalledBody() + const upload500 = stalledBody() + const previewClient = createVoiceClient( + environment(50), + fetchReturning(() => new Response(preview500.body, {status: 500})), + ) + const uploadClient = createVoiceClient( + environment(50), + fetchReturning(() => new Response(upload500.body, {status: 403})), + ) + + await expect( + previewClient.downloadPreview(preview, 1024), + ).rejects.toMatchObject({code: 'request_timeout'}) + await expect( + uploadClient.sendToUploadUrl(upload, new Uint8Array([1])), + ).rejects.toMatchObject({code: 'request_timeout'}) + expect(preview500.cancel).toHaveBeenCalled() + expect(upload500.cancel).toHaveBeenCalled() + }) + + test('still reports the status and body code when the error body arrives', async () => { + const client = createVoiceClient( + environment(50), + fetchReturning( + () => new Response('{"error":{"code":"link_expired"}}', {status: 403}), + ), + ) + + await expect( + client.sendToUploadUrl(upload, new Uint8Array([1])), + ).rejects.toMatchObject({code: 'link_expired', status: 403}) + }) + + test('stops reading a preview without Content-Length once it passes the limit', async () => { + const maxBytes = 10_000 + const chunkBytes = 1_000 + const {body, state} = endlessBody(chunkBytes) + const client = createVoiceClient( + environment(5_000), + fetchReturning(() => new Response(body, {status: 200})), + ) + + await expect( + client.downloadPreview(preview, maxBytes), + ).rejects.toMatchObject({code: 'preview_too_large'}) + expect(state.cancelled).toBe(true) + // The reader stops on the first chunk past the limit; the stream may + // have queued one more chunk ahead of the reader. + expect(state.produced).toBeGreaterThan(maxBytes) + expect(state.produced).toBeLessThanOrEqual(maxBytes + 2 * chunkBytes) + }) + + test('refuses a declared oversize preview before reading it', async () => { + const {body, state} = endlessBody(1_000) + const client = createVoiceClient( + environment(5_000), + fetchReturning( + () => + new Response(body, { + headers: {'content-length': '10001'}, + status: 200, + }), + ), + ) + + await expect(client.downloadPreview(preview, 10_000)).rejects.toMatchObject( + {code: 'preview_too_large'}, + ) + expect(state.cancelled).toBe(true) + expect(state.produced).toBeLessThanOrEqual(1_000) + }) + + test('returns a preview at exactly the limit', async () => { + const client = createVoiceClient( + environment(5_000), + fetchReturning(() => new Response(new Uint8Array(1_000), {status: 200})), + ) + + await expect(client.downloadPreview(preview, 1_000)).resolves.toHaveLength( + 1_000, + ) + }) + + function previewClient(fetchImplementation: typeof fetch, timeoutMs: number) { + const client = createVoiceClient( + environment(timeoutMs), + fetchImplementation, + ) + return { + ...client, + previewAudio: () => + Promise.resolve({ + data: preview, + metadata: {attempts: 1, headers: {}, status: 200}, + }), + } satisfies VoiceClient + } + + test('writes no output file when an unbounded preview is refused', async () => { + const directory = await temporaryDirectory() + try { + const {body, state} = endlessBody(1_048_576) + const client = previewClient( + fetchReturning(() => new Response(body, {status: 200})), + 30_000, + ) + + await expect( + previewAudio(client, ASSET_ID, { + output: join(directory, 'preview.ogg'), + }), + ).rejects.toMatchObject({code: 'preview_too_large'}) + expect(state.cancelled).toBe(true) + expect(state.produced).toBeLessThanOrEqual( + MAX_PREVIEW_BYTES + 2 * 1_048_576, + ) + await expect(readdir(directory)).resolves.toEqual([]) + } finally { + await rm(directory, {force: true, recursive: true}) + } + }) + + test('writes no output file when the preview body times out', async () => { + const directory = await temporaryDirectory() + try { + const {body} = stalledBody() + const client = previewClient( + fetchReturning(() => new Response(body, {status: 200})), + 50, + ) + + await expect( + previewAudio(client, ASSET_ID, { + output: join(directory, 'preview.ogg'), + }), + ).rejects.toMatchObject({code: 'request_timeout'}) + await expect(readdir(directory)).resolves.toEqual([]) + } finally { + await rm(directory, {force: true, recursive: true}) + } + }) +}) From fa67e71e647e6cf60daacce3249c6637a1981b41 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 15:10:11 +0200 Subject: [PATCH 05/11] fix(voice): bound status polls by the wait deadline --- README.md | 4 +- docs/command-research/voice.md | 19 ++-- src/sdk/voice.ts | 12 ++- src/services/voice.ts | 34 ++++-- test/commands/voice-wait-timeout.test.ts | 129 +++++++++++++++++++++++ test/services/voice.test.ts | 24 ++++- 6 files changed, 201 insertions(+), 21 deletions(-) create mode 100644 test/commands/voice-wait-timeout.test.ts diff --git a/README.md b/README.md index a634b407..4a78f26e 100644 --- a/README.md +++ b/README.md @@ -745,7 +745,9 @@ polymorfa voice audio delete 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes provider. - `upload --wait` and `tts --wait` wait until the asset is `ready` (exit 0) or `failed` (exit 13, with the failure reason). `--wait-timeout` defaults to 120 - seconds and exits with code 5 when reached. + seconds and exits with code 5 when reached, including when a status request + stalls. This budget starts after upload or TTS submission and covers status + requests, response bodies, and retries. - `--retention-days` deletes the asset that many days after creation (1 to 3650). `update --retention-days none` keeps it until you delete it. - `preview` prints a link to the transcoded audio (Opus in Ogg). The link works diff --git a/docs/command-research/voice.md b/docs/command-research/voice.md index 1df949b7..6c8145d7 100644 --- a/docs/command-research/voice.md +++ b/docs/command-research/voice.md @@ -7,8 +7,8 @@ `t3code/voice-audio-library`, revision `voice-audio-v1` - **SDK reference:** `client.voice` in polymorfa/sdks draft PR #279 at `f0f3c0719ce44226aa6af896c33a69c71ef934eb` -- **SDK pin:** unchanged, `polymorfa/sdks` commit - `94bfd09825817f842434e206e6a77adb5cc0c414` (see +- **SDK pin:** `@polymorfa/sdk@0.1.0-dev.20260922102711`, inherited from + CLI `dev` (see [SDK dependency](#sdk-dependency)) ## Sources reviewed @@ -22,7 +22,7 @@ - `packages/typescript/src/platform/voice.ts` and the upload URL transport in polymorfa/sdks `f0f3c07`. -## Shipped command tree +## Command tree ```text voice @@ -69,8 +69,10 @@ Voice Automation phases and are not part of this revision. The pinned SDK has no `client.voice`. Since polymorfa/sdks `4f54b26` the SDK lives in the `packages/typescript` workspace of a private workspace root, so a `github:polymorfa/sdks#` pin installs the root, not `@polymorfa/sdk`, and -cannot take `f0f3c07`. No `@polymorfa/sdk` prerelease is published yet (see -the draft move to npm dev prereleases in this repository). +cannot take `f0f3c07`. CLI `dev` now pins an npm development prerelease, +`0.1.0-dev.20260922102711`, which still has no `client.voice`. API PR #227 and +SDK PR #279 must merge before this CLI PR can pin the exact resulting SDK +prerelease and replace the raw adapter with the typed resource. Until a release with `client.voice` can be pinned, `src/sdk/voice.ts` sends the documented requests through the organization client's raw transport, the same approach as the open @@ -111,8 +113,11 @@ adapter with `client.voice` when the SDK pin moves. `.opus` `audio/ogg`, `.m4a` `audio/x-m4a`, `.mp4` `audio/mp4`. - **Waiting.** `--wait` reads the asset first and then every 2 seconds, like the SDK's `waitUntilReady`. `failed` exits with `voice_asset_failed` (13); - reaching `--wait-timeout` exits with `request_timeout` (5). The upload request - itself is not retried, matching the SDK. + reaching `--wait-timeout` exits with `request_timeout` (5). The budget begins + after upload or TTS submission. Each status request receives an abort signal + for the remaining budget, including body reads and SDK retries; no poll + starts after the deadline and a result received after it is not returned. + The upload request itself is not retried, matching the SDK. - **Preview.** Without `--output` the command prints the 5-minute URL. With it, the file is downloaded without a credential, limited to 32 MiB, and an existing file is refused unless `--force` is given. diff --git a/src/sdk/voice.ts b/src/sdk/voice.ts index 27ba69ef..499f6eb3 100644 --- a/src/sdk/voice.ts +++ b/src/sdk/voice.ts @@ -8,6 +8,7 @@ import { PolymorfaValidationError, type ApiResponse, type QueryValue, + type RequestOptions, type ResponseMetadata, } from '@polymorfa/sdk' @@ -171,7 +172,10 @@ export interface VoiceClient { synthesizeAudio( input: SynthesizeVoiceAudioInput, ): Promise> - retrieveAudio(assetId: string): Promise> + retrieveAudio( + assetId: string, + options?: RequestOptions, + ): Promise> updateAudio( assetId: string, input: UpdateVoiceAudioInput, @@ -227,9 +231,11 @@ export function createVoiceClient( options: { readonly body?: unknown readonly query?: Readonly> + readonly requestOptions?: RequestOptions } = {}, ): Promise> { const response = await raw.request>({ + ...options.requestOptions, method, path, ...(options.body === undefined ? {} : {body: options.body}), @@ -335,8 +341,8 @@ export function createVoiceClient( data('POST', `${assetPath(assetId)}/complete`), synthesizeAudio: (input) => data('POST', `${AUDIO_PATH}/tts`, {body: input}), - retrieveAudio: (assetId) => - data('GET', assetPath(assetId)), + retrieveAudio: (assetId, requestOptions = {}) => + data('GET', assetPath(assetId), {requestOptions}), updateAudio: (assetId, input) => data('PATCH', assetPath(assetId), {body: input}), deleteAudio: (assetId) => diff --git a/src/services/voice.ts b/src/services/voice.ts index af61cfab..ba353085 100644 --- a/src/services/voice.ts +++ b/src/services/voice.ts @@ -327,21 +327,37 @@ export async function waitForAudio( const sleep = options.sleep ?? defaultSleep const deadline = Date.now() + options.timeoutSeconds * 1000 let current = asset + const timedOut = () => + new PolymorfaTimeoutError( + `Audio ${current.data.id} was still ${current.data.status} after ${options.timeoutSeconds}s. Check it later with polymorfa voice audio view ${current.data.id}.`, + {code: 'request_timeout'}, + ) // Like the SDK's waitUntilReady: read first, then sleep between reads. while (current.data.status !== 'ready' && current.data.status !== 'failed') { - const response = await client.retrieveAudio(current.data.id) + const remaining = deadline - Date.now() + if (remaining <= 0) throw timedOut() + // Bound the entire poll, including response-body reads and retries. + const expiry = new AbortController() + const timer = setTimeout(() => expiry.abort(), remaining) + let response + try { + response = await client.retrieveAudio(current.data.id, { + signal: expiry.signal, + }) + } catch (error) { + if (expiry.signal.aborted) throw timedOut() + throw error + } finally { + clearTimeout(timer) + } + if (Date.now() >= deadline) throw timedOut() current = result(response.data, response.metadata) if (current.data.status === 'ready' || current.data.status === 'failed') { break } - const remaining = deadline - Date.now() - if (remaining <= 0) { - throw new PolymorfaTimeoutError( - `Audio ${current.data.id} was still ${current.data.status} after ${options.timeoutSeconds}s. Check it later with polymorfa voice audio view ${current.data.id}.`, - {code: 'request_timeout'}, - ) - } - await sleep(Math.min(intervalMs, remaining)) + const left = deadline - Date.now() + if (left <= 0) throw timedOut() + await sleep(Math.min(intervalMs, left)) } if (current.data.status === 'failed') { throw new CliConditionError( diff --git a/test/commands/voice-wait-timeout.test.ts b/test/commands/voice-wait-timeout.test.ts new file mode 100644 index 00000000..032d06d5 --- /dev/null +++ b/test/commands/voice-wait-timeout.test.ts @@ -0,0 +1,129 @@ +import {spawn} from 'node:child_process' +import {once} from 'node:events' +import {rm} from 'node:fs/promises' +import {createServer} from 'node:http' +import {fileURLToPath} from 'node:url' +import {expect, test} from 'vitest' + +import {temporaryDirectory} from '../support/fs.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const ASSET_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' +const PROJECT = '018f0000-0000-7000-8000-000000000002' + +test.each(['headers', 'body'] as const)( + 'exits at --wait-timeout and closes a poll stalled on its %s', + async (stall) => { + let polls = 0 + let pollStarted = 0 + let pollClosed = false + const server = createServer((request, reply) => { + request.resume() + if ( + request.method === 'POST' && + request.url === '/platform/voice/audio/tts' + ) { + reply.writeHead(201, {'content-type': 'application/json'}) + reply.end( + JSON.stringify({ + data: {id: ASSET_ID, status: 'transcoding'}, + success: true, + }), + ) + return + } + if ( + request.method === 'GET' && + request.url === `/platform/voice/audio/${ASSET_ID}` + ) { + polls += 1 + pollStarted = Date.now() + reply.once('close', () => { + pollClosed = true + }) + if (stall === 'body') { + reply.writeHead(200, {'content-type': 'application/json'}) + reply.write('{"success":true,"data":') + } + return + } + reply.writeHead(404).end() + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') + throw new Error('Server did not bind') + const directory = await temporaryDirectory() + const child = spawn( + process.execPath, + [ + '--import', + 'tsx', + fileURLToPath(new URL('../../bin/dev.js', import.meta.url)), + 'voice', + 'audio', + 'tts', + '--name', + 'Greeting', + '--text', + 'Hello', + '--provider', + 'openai', + '--voice', + 'alloy', + '--project', + PROJECT, + '--wait', + '--wait-timeout', + '1', + '--timeout', + '30000', + '--json', + ], + { + env: { + ...process.env, + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: `http://127.0.0.1:${address.port}`, + POLYMORFA_CLIENT_TOKEN: '', + POLYMORFA_PROJECT_TOKEN: '', + POLYMORFA_CONFIG_DIR: directory, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + let stdout = '' + let stderr = '' + child.stdout.on('data', (chunk: Buffer) => { + stdout += String(chunk) + }) + child.stderr.on('data', (chunk: Buffer) => { + stderr += String(chunk) + }) + let timer: NodeJS.Timeout | undefined + const closed = once(child, 'close') + try { + const outcome = await Promise.race([ + closed, + new Promise<'watchdog'>((resolve) => { + timer = setTimeout(() => resolve('watchdog'), 8_000) + }), + ]) + expect(polls).toBe(1) + expect(outcome, stderr).toEqual([5, null]) + expect(Date.now() - pollStarted).toBeLessThan(2_500) + expect(pollClosed).toBe(true) + expect(stdout + stderr).toContain('request_timeout') + expect(stdout + stderr).not.toContain(API_KEY) + } finally { + clearTimeout(timer) + child.kill() + await closed + server.closeAllConnections() + await new Promise((resolve) => server.close(() => resolve())) + await rm(directory, {force: true, recursive: true}) + } + }, + 15_000, +) diff --git a/test/services/voice.test.ts b/test/services/voice.test.ts index 724c1d74..66059cfa 100644 --- a/test/services/voice.test.ts +++ b/test/services/voice.test.ts @@ -103,7 +103,29 @@ describe('waitForAudio', () => { timeoutSeconds: 5, }), ).rejects.toMatchObject({code: 'request_timeout'}) - expect(retrieveAudio).toHaveBeenCalledTimes(4) + // No fourth request starts after sleeping up to the deadline. + expect(retrieveAudio).toHaveBeenCalledTimes(3) + } finally { + vi.restoreAllMocks() + } + }) + + test('does not return a ready result that arrives after the wait deadline', async () => { + let now = 0 + vi.spyOn(Date, 'now').mockImplementation(() => now) + const client = { + retrieveAudio: () => { + now = 1_001 + return Promise.resolve({ + data: {...asset, status: 'ready'}, + metadata: {attempts: 1, headers: {}, status: 200}, + }) + }, + } as unknown as VoiceClient + try { + await expect( + waitForAudio(client, submitted, {timeoutSeconds: 1}), + ).rejects.toMatchObject({code: 'request_timeout'}) } finally { vi.restoreAllMocks() } From 30a126ae5f95ab8b5aebb512609e69b44c533c47 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 12:53:16 +0200 Subject: [PATCH 06/11] Use typed SDK voice resources in CLI commands --- README.md | 2 + docs/command-research/voice.md | 41 ++-- src/sdk/voice.ts | 432 +++++++++++---------------------- src/services/voice.ts | 27 +-- test/commands/voice.test.ts | 31 +++ test/services/voice.test.ts | 91 +------ 6 files changed, 202 insertions(+), 422 deletions(-) diff --git a/README.md b/README.md index 4a78f26e..799bd59e 100644 --- a/README.md +++ b/README.md @@ -741,6 +741,8 @@ polymorfa voice audio delete 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes - `tts` reads the text from `--text` or `--text-file` (`-` for stdin): 1 to 5000 characters, or 4096 for `openai`. `--voice` is an OpenAI voice name or an ElevenLabs voice ID. `--model` defaults to the provider's default model. + Unsupported OpenAI voices and provider-specific models fail with exit code 2 + before any request is sent. Without `--credential`, the render uses Polymorfa's managed key for the provider. - `upload --wait` and `tts --wait` wait until the asset is `ready` (exit 0) or diff --git a/docs/command-research/voice.md b/docs/command-research/voice.md index 6c8145d7..99d3270b 100644 --- a/docs/command-research/voice.md +++ b/docs/command-research/voice.md @@ -6,7 +6,7 @@ - **API contract:** polymorfa/polymorfa draft PR #227, branch `t3code/voice-audio-library`, revision `voice-audio-v1` - **SDK reference:** `client.voice` in polymorfa/sdks draft PR #279 at - `f0f3c0719ce44226aa6af896c33a69c71ef934eb` + `8c61d642da51e14290ae916cd342f71cc0bf94e6` - **SDK pin:** `@polymorfa/sdk@0.1.0-dev.20260922102711`, inherited from CLI `dev` (see [SDK dependency](#sdk-dependency)) @@ -20,7 +20,7 @@ tree, key input rule, and distinct exit codes for `gate_limit_reached` and `voice_not_enabled`. - `packages/typescript/src/platform/voice.ts` and the upload URL transport in - polymorfa/sdks `f0f3c07`. + polymorfa/sdks `8c61d64`. ## Command tree @@ -48,7 +48,7 @@ Voice Automation phases and are not part of this revision. ## Operation mapping -| Command | Operation | SDK `client.voice` (f0f3c07) | +| Command | Operation | SDK `client.voice` (8c61d64) | | ------------------------- | ---------------------------------------------------------- | ------------------------------ | | `voice audio upload` | `POST /platform/voice/audio`, upload URL, `.../complete` | `audio.upload` | | `voice audio tts` | `POST /platform/voice/audio/tts` | `audio.synthesize` | @@ -57,7 +57,7 @@ Voice Automation phases and are not part of this revision. | `voice audio update` | `PATCH /platform/voice/audio/{id}` | `audio.update` | | `voice audio delete` | `DELETE /platform/voice/audio/{id}` | `audio.delete` | | `voice audio preview` | `GET /platform/voice/audio/{id}/preview` | `audio.previewUrl` | -| `--wait` | `GET /platform/voice/audio/{id}` until `ready` or `failed` | `audio.waitUntilReady` | +| `--wait` | `GET /platform/voice/audio/{id}` until `ready` or `failed` | `audio.retrieve` | | `voice credential add` | `POST /platform/voice/provider-credentials` | `providerCredentials.create` | | `voice credential list` | `GET /platform/voice/provider-credentials` | `providerCredentials.list` | | `voice credential view` | `GET /platform/voice/provider-credentials/{id}` | `providerCredentials.retrieve` | @@ -66,22 +66,16 @@ Voice Automation phases and are not part of this revision. ## SDK dependency -The pinned SDK has no `client.voice`. Since polymorfa/sdks `4f54b26` the SDK -lives in the `packages/typescript` workspace of a private workspace root, so a -`github:polymorfa/sdks#` pin installs the root, not `@polymorfa/sdk`, and -cannot take `f0f3c07`. CLI `dev` now pins an npm development prerelease, -`0.1.0-dev.20260922102711`, which still has no `client.voice`. API PR #227 and -SDK PR #279 must merge before this CLI PR can pin the exact resulting SDK -prerelease and replace the raw adapter with the typed resource. - -Until a release with `client.voice` can be pinned, `src/sdk/voice.ts` sends the -documented requests through the organization client's raw transport, the same approach as the open -call retention change. The SDK still resolves credentials, retries and -maps errors. The adapter mirrors the SDK's upload rules: the upload and preview -URLs must be HTTPS (HTTP only on loopback) without embedded credentials, the -request carries only the returned headers and never `Authorization`, -redirects are refused, and error messages never include the URL. Replace the -adapter with `client.voice` when the SDK pin moves. +The committed npm pin, `0.1.0-dev.20260922102711`, predates `client.voice`. +This branch uses the typed resource from SDK PR #279 and was verified with a +temporary local package. API PR #227 and SDK PR #279 must merge before this CLI +PR can pin the exact published SDK prerelease. No local package path belongs in +the committed manifest or lockfile. + +`src/sdk/voice.ts` calls the typed audio and provider-credential resources. +The SDK handles upload URL transfer without the API credential. The SDK does +not expose a preview download method, so the CLI downloads the signed preview +URL itself with no credential, no redirects, a bounded response, and a timeout. ## Credential rules @@ -105,10 +99,9 @@ adapter with `client.voice` when the SDK pin moves. - **Local refusals.** Upload type (from the extension, case-insensitive), empty files, files over 16,777,216 bytes, names over 100 characters, retention outside 1 to 3650, TTS text length (5000, or 4096 for `openai`), - ElevenLabs voice IDs (`^[A-Za-z0-9]{1,64}$`), non-UUID IDs and oversized keys - (over 512 characters) fail with exit code 2 before any request. OpenAI voice - and model names are left to the API so new values work without a CLI - release. + ElevenLabs voice IDs (`^[A-Za-z0-9]{1,64}$`), provider-specific model and + OpenAI voice names outside the SDK's accepted sets, non-UUID IDs and oversized + keys (over 512 characters) fail with exit code 2 before any request. - **Content types.** `.mp3` sends `audio/mpeg`, `.wav` `audio/wav`, `.ogg` and `.opus` `audio/ogg`, `.m4a` `audio/x-m4a`, `.mp4` `audio/mp4`. - **Waiting.** `--wait` reads the asset first and then every 2 seconds, like the diff --git a/src/sdk/voice.ts b/src/sdk/voice.ts index 499f6eb3..44da02de 100644 --- a/src/sdk/voice.ts +++ b/src/sdk/voice.ts @@ -1,5 +1,12 @@ import { + ELEVENLABS_TTS_MODELS, + OPENAI_TTS_MODELS, + OPENAI_TTS_VOICES, + VOICE_AUDIO_MAX_UPLOAD_BYTES, + VOICE_AUDIO_STATUSES, + VOICE_PROVIDERS, PolymorfaAuthorizationError, + PolymorfaConfigurationError, PolymorfaConnectionError, PolymorfaError, PolymorfaNotFoundError, @@ -7,109 +14,28 @@ import { PolymorfaTimeoutError, PolymorfaValidationError, type ApiResponse, - type QueryValue, + type KnownVoiceAudioUploadContentType, type RequestOptions, - type ResponseMetadata, + type UpdateVoiceAudioInput, + type VoiceAudioAsset, + type VoiceAudioPreview, + type VoiceProviderCredential, + type VoiceResourceDeleted, } from '@polymorfa/sdk' import type {ResolvedEnvironment} from '../context/environment.js' import {createPlatformClient} from './client.js' -// Voice Automation (beta) audio library and provider credentials, contract -// revision voice-audio-v1. The pinned SDK predates `client.voice`, and git -// pins can no longer install newer SDK commits, so this adapter sends the -// documented requests through the organization client's raw transport. The -// SDK keeps credential, retry and error mapping. The types and paths mirror -// `client.voice` in polymorfa/sdks f0f3c0719ce44226aa6af896c33a69c71ef934eb; -// replace this module with that resource once a release that includes it is -// pinned. - -export const VOICE_AUDIO_STATUSES = [ - 'pending_upload', - 'uploaded', - 'transcoding', - 'ready', - 'failed', -] as const -export type VoiceAudioStatus = (typeof VOICE_AUDIO_STATUSES)[number] - -export const VOICE_PROVIDERS = ['elevenlabs', 'openai'] as const -export type VoiceProvider = (typeof VOICE_PROVIDERS)[number] - -/** Largest upload the API accepts: 16 MiB. */ -export const VOICE_AUDIO_MAX_UPLOAD_BYTES = 16_777_216 - -export interface VoiceAudioTts { - readonly provider: string - readonly voiceId: string - readonly model: string - readonly text: string - readonly characters: number - readonly keySource: string - readonly credentialId: string | null -} - -export interface VoiceAudioAsset { - readonly id: string - readonly projectId: string - readonly name: string - readonly source: string - readonly status: string - readonly failureReason: string | null - readonly originalFormat: string | null - readonly originalContentType: string | null - readonly sizeBytes: number | null - readonly durationMs: number | null - readonly contentSha256: string | null - readonly tts: VoiceAudioTts | null - readonly retentionDays: number | null - readonly expiresAt: string | null - readonly inUseCount: number - readonly revision: number - readonly createdAt: string - readonly updatedAt: string - readonly readyAt: string | null -} - -/** Where to send the file. The URL is valid for 5 minutes; treat it as a secret. */ -export interface VoiceAudioUpload { - readonly url: string - readonly method: string - readonly headers: Readonly> - readonly maxBytes: number - readonly expiresAt: string -} - -export interface VoiceAudioUploadCreated { - readonly asset: VoiceAudioAsset - readonly upload: VoiceAudioUpload +export {VOICE_AUDIO_MAX_UPLOAD_BYTES, VOICE_AUDIO_STATUSES, VOICE_PROVIDERS} +export type { + VoiceAudioAsset, + VoiceAudioPreview, + VoiceProviderCredential, + VoiceResourceDeleted, } -/** A 5-minute link to Opus-in-Ogg audio (16 kHz mono). */ -export interface VoiceAudioPreview { - readonly url: string - readonly contentType: string - readonly expiresAt: string -} - -export interface VoiceProviderCredential { - readonly id: string - readonly projectId: string | null - readonly provider: string - readonly label: string - readonly keyFingerprint: string - readonly status: string - readonly verifiedAt: string | null - readonly lastError: string | null - readonly revision: number - readonly createdAt: string - readonly updatedAt: string -} - -export interface VoiceResourceDeleted { - readonly id: string - readonly deleted: true -} +const DEFAULT_BASE_URL = 'https://api.polymorfa.com' +const DEFAULT_TRANSFER_TIMEOUT_MS = 120_000 export interface VoiceAudioPage { readonly items: readonly VoiceAudioAsset[] @@ -117,61 +43,30 @@ export interface VoiceAudioPage { readonly hasMore: boolean } -export interface ListVoiceAudioParams { - readonly projectId: string - readonly status?: string - readonly cursor?: string - readonly limit?: number -} - -export interface CreateVoiceAudioUploadInput { - readonly projectId: string - readonly name: string - readonly contentType: string - readonly sizeBytes: number - readonly retentionDays?: number -} - -export interface SynthesizeVoiceAudioInput { - readonly projectId: string - readonly name: string - readonly text: string - readonly provider: string - readonly voiceId: string - readonly model?: string - readonly credentialId?: string - readonly retentionDays?: number -} - -export interface UpdateVoiceAudioInput { - readonly expectedRevision?: number - readonly name?: string - readonly retentionDays?: number | null -} - -export interface CreateVoiceProviderCredentialInput { - readonly provider: string - readonly label: string - /** Write-only. Never returned, logged or echoed. */ - readonly apiKey: string - /** A project, or omitted for a credential every project of the team can use. */ - readonly projectId?: string -} - export interface VoiceClient { - listAudio(params: ListVoiceAudioParams): Promise> - createAudioUpload( - input: CreateVoiceAudioUploadInput, - ): Promise> - /** Sends bytes to an upload URL with only its headers and no credential. */ - sendToUploadUrl( - upload: VoiceAudioUpload, - body: Uint8Array, - ): Promise - completeAudio(assetId: string): Promise> - synthesizeAudio( - input: SynthesizeVoiceAudioInput, - ): Promise> + listAudio(params: { + projectId: string + status?: string + cursor?: string + limit?: number + }): Promise> + uploadAudio(input: { + projectId: string + name: string + bytes: Uint8Array + contentType: KnownVoiceAudioUploadContentType + retentionDays?: number + }): Promise> + synthesizeAudio(input: { + projectId: string + name: string + text: string + provider: string + voiceId: string + model?: string + credentialId?: string + retentionDays?: number + }): Promise> retrieveAudio( assetId: string, options?: RequestOptions, @@ -182,7 +77,6 @@ export interface VoiceClient { ): Promise> deleteAudio(assetId: string): Promise> previewAudio(assetId: string): Promise> - /** Downloads a preview URL without a credential. */ downloadPreview( preview: VoiceAudioPreview, maxBytes: number, @@ -190,9 +84,12 @@ export interface VoiceClient { listCredentials( projectId?: string, ): Promise> - createCredential( - input: CreateVoiceProviderCredentialInput, - ): Promise> + createCredential(input: { + provider: string + label: string + apiKey: string + projectId?: string + }): Promise> retrieveCredential( credentialId: string, ): Promise> @@ -204,51 +101,22 @@ export interface VoiceClient { ): Promise> } -const AUDIO_PATH = '/platform/voice/audio' -const CREDENTIALS_PATH = '/platform/voice/provider-credentials' -const DEFAULT_BASE_URL = 'https://api.polymorfa.com' -const DEFAULT_TRANSFER_TIMEOUT_MS = 120_000 - -interface DataEnvelope { - readonly data: T - readonly page?: { - readonly nextCursor?: string | null - readonly hasMore?: boolean - } +function memberOf( + values: T, + value: string, +): value is T[number] { + return (values as readonly string[]).includes(value) } +/** The Platform API calls use the typed SDK. Only signed preview downloads use fetch. */ export function createVoiceClient( environment: ResolvedEnvironment, fetchImplementation: typeof fetch = fetch, ): VoiceClient { - const raw = createPlatformClient(environment).raw + const voice = createPlatformClient(environment).voice const baseUrl = environment.baseUrl ?? DEFAULT_BASE_URL const transferTimeoutMs = environment.timeoutMs ?? DEFAULT_TRANSFER_TIMEOUT_MS - async function data( - method: 'GET' | 'POST' | 'PATCH' | 'DELETE', - path: string, - options: { - readonly body?: unknown - readonly query?: Readonly> - readonly requestOptions?: RequestOptions - } = {}, - ): Promise> { - const response = await raw.request>({ - ...options.requestOptions, - method, - path, - ...(options.body === undefined ? {} : {body: options.body}), - ...(options.query === undefined ? {} : {query: options.query}), - }) - return {data: envelopeData(response.data), metadata: response.metadata} - } - - /** - * Sends one credential-free request and consumes its response inside the - * same timed section, so a server that sends headers and then stalls on the - * body still hits the transfer timeout. - */ async function transfer( url: URL, init: RequestInit, @@ -272,7 +140,6 @@ export function createVoiceClient( ) } if (error instanceof PolymorfaError) throw error - // Never include the URL: it is a short-lived capability. throw new PolymorfaConnectionError( `The ${label} could not reach its URL.`, {cause: error, code: 'connection_error'}, @@ -284,71 +151,75 @@ export function createVoiceClient( return { async listAudio(params) { - const response = await raw.request>({ - method: 'GET', - path: AUDIO_PATH, - query: { - projectId: params.projectId, - ...(params.status === undefined ? {} : {status: params.status}), - ...(params.cursor === undefined ? {} : {cursor: params.cursor}), - ...(params.limit === undefined ? {} : {limit: params.limit}), - }, - }) - const items = envelopeData(response.data) - if (!Array.isArray(items)) throw malformed() - const page = response.data.page + const {projectId, status, ...rest} = params + if (status !== undefined && !memberOf(VOICE_AUDIO_STATUSES, status)) { + throw new PolymorfaConfigurationError( + 'Unsupported voice audio status.', + 'status', + ) + } + const filters = {...rest, ...(status === undefined ? {} : {status})} + const page = await voice.audio.list(projectId, filters) return { data: { - hasMore: page?.hasMore === true, - items, - nextCursor: - typeof page?.nextCursor === 'string' ? page.nextCursor : null, + items: page.items, + hasMore: page.hasMore, + nextCursor: page.nextCursor ?? null, }, - metadata: response.metadata, + metadata: page.response.metadata, } }, - createAudioUpload: (input) => - data('POST', AUDIO_PATH, {body: input}), - async sendToUploadUrl(upload, body) { - const url = capabilityUrl(baseUrl, upload.url, 'upload') - const headers = new Headers() - for (const [name, value] of Object.entries(upload.headers)) { - if (name.toLowerCase() === 'authorization') continue - headers.set(name, value) + uploadAudio: ({projectId, name, bytes, contentType, retentionDays}) => + voice.audio.upload(projectId, { + name, + body: bytes, + contentType, + ...(retentionDays === undefined ? {} : {retentionDays}), + }), + synthesizeAudio: ({projectId, provider, voiceId, model, ...fields}) => { + if (provider === 'elevenlabs') { + if (model !== undefined && !memberOf(ELEVENLABS_TTS_MODELS, model)) { + throw new PolymorfaConfigurationError( + 'Unsupported ElevenLabs model.', + 'model', + ) + } + return voice.audio.synthesize(projectId, { + ...fields, + provider, + voiceId, + ...(model === undefined ? {} : {model}), + }) } - return transfer( - url, - { - body: body as unknown as BodyInit, - headers, - method: upload.method === 'PUT' ? 'PUT' : 'POST', - }, - 'upload', - async (response, signal) => { - if (!response.ok) { - throw transferError( - response, - await safeBody(response, signal), - 'upload', - ) - } - await discardBody(response) - return transferMetadata(response) - }, + if (provider === 'openai') { + if (!memberOf(OPENAI_TTS_VOICES, voiceId)) { + throw new PolymorfaConfigurationError( + 'Unsupported OpenAI voice.', + 'voice', + ) + } + if (model !== undefined && !memberOf(OPENAI_TTS_MODELS, model)) { + throw new PolymorfaConfigurationError( + 'Unsupported OpenAI model.', + 'model', + ) + } + return voice.audio.synthesize(projectId, { + ...fields, + provider, + voiceId, + ...(model === undefined ? {} : {model}), + }) + } + throw new PolymorfaConfigurationError( + 'Unsupported voice provider.', + 'provider', ) }, - completeAudio: (assetId) => - data('POST', `${assetPath(assetId)}/complete`), - synthesizeAudio: (input) => - data('POST', `${AUDIO_PATH}/tts`, {body: input}), - retrieveAudio: (assetId, requestOptions = {}) => - data('GET', assetPath(assetId), {requestOptions}), - updateAudio: (assetId, input) => - data('PATCH', assetPath(assetId), {body: input}), - deleteAudio: (assetId) => - data('DELETE', assetPath(assetId)), - previewAudio: (assetId) => - data('GET', `${assetPath(assetId)}/preview`), + retrieveAudio: (assetId, options) => voice.audio.retrieve(assetId, options), + updateAudio: (assetId, input) => voice.audio.update(assetId, input), + deleteAudio: (assetId) => voice.audio.delete(assetId), + previewAudio: (assetId) => voice.audio.previewUrl(assetId), async downloadPreview(preview, maxBytes) { const url = capabilityUrl(baseUrl, preview.url, 'preview') return transfer( @@ -368,8 +239,6 @@ export function createVoiceClient( await discardBody(response) throw tooLargePreview(maxBytes) } - // Content-Length can be missing, understated or describe a - // compressed body, so the limit is enforced on the bytes read. const bytes = await readBody(response, signal, maxBytes) if (bytes === undefined) throw tooLargePreview(maxBytes) return bytes @@ -377,49 +246,30 @@ export function createVoiceClient( ) }, listCredentials: (projectId) => - data('GET', CREDENTIALS_PATH, { - query: projectId === undefined ? {} : {projectId}, - }), - createCredential: (input) => - data('POST', CREDENTIALS_PATH, {body: input}), + voice.providerCredentials.list( + projectId === undefined ? {} : {projectId}, + ), + createCredential: (input) => { + if (!memberOf(VOICE_PROVIDERS, input.provider)) { + throw new PolymorfaConfigurationError( + 'Unsupported voice provider.', + 'provider', + ) + } + return voice.providerCredentials.create({ + ...input, + provider: input.provider, + }) + }, retrieveCredential: (credentialId) => - data('GET', credentialPath(credentialId)), + voice.providerCredentials.retrieve(credentialId), verifyCredential: (credentialId) => - data( - 'POST', - `${credentialPath(credentialId)}/verify`, - ), + voice.providerCredentials.verify(credentialId), deleteCredential: (credentialId) => - data('DELETE', credentialPath(credentialId)), + voice.providerCredentials.delete(credentialId), } } -function assetPath(assetId: string): string { - return `${AUDIO_PATH}/${encodeURIComponent(assetId)}` -} - -function credentialPath(credentialId: string): string { - return `${CREDENTIALS_PATH}/${encodeURIComponent(credentialId)}` -} - -function envelopeData(envelope: DataEnvelope): T { - if ( - typeof envelope !== 'object' || - envelope === null || - !('data' in envelope) - ) { - throw malformed() - } - return envelope.data -} - -function malformed(): PolymorfaServerError { - return new PolymorfaServerError( - 'The API returned a voice response without data.', - {code: 'invalid_response'}, - ) -} - /** * Resolve an upload or preview URL. It must use HTTPS (or HTTP on loopback) * and carry no credentials, matching the SDK's upload rules. @@ -451,16 +301,6 @@ function capabilityUrl(baseUrl: string, value: string, label: string): URL { return url } -function transferMetadata(response: Response): ResponseMetadata { - const requestId = response.headers.get('x-request-id') ?? undefined - return { - attempts: 1, - headers: {}, - status: response.status, - ...(requestId === undefined ? {} : {requestId}), - } -} - /** Error bodies are only inspected for an error code. */ const MAX_ERROR_BODY_BYTES = 65_536 diff --git a/src/services/voice.ts b/src/services/voice.ts index ba353085..a836f768 100644 --- a/src/services/voice.ts +++ b/src/services/voice.ts @@ -6,6 +6,7 @@ import { PolymorfaConfigurationError, PolymorfaConflictError, PolymorfaTimeoutError, + type KnownVoiceAudioUploadContentType, type ResponseMetadata, } from '@polymorfa/sdk' @@ -38,7 +39,9 @@ export const BETA_NOTICE = 'Voice Automation is a beta (calls.voice-automation) for enrolled teams only; other teams get voice_not_enabled (exit code 6).' /** File extensions the CLI uploads, and the content type it sends for each. */ -export const UPLOAD_CONTENT_TYPES: Readonly> = { +export const UPLOAD_CONTENT_TYPES: Readonly< + Record +> = { '.m4a': 'audio/x-m4a', '.mp3': 'audio/mpeg', '.mp4': 'audio/mp4', @@ -110,7 +113,9 @@ export function parseRetentionDays( return days } -export function uploadContentType(path: string): string { +export function uploadContentType( + path: string, +): KnownVoiceAudioUploadContentType { const extension = extname(path).toLowerCase() const contentType = UPLOAD_CONTENT_TYPES[extension] if (contentType === undefined) { @@ -125,7 +130,7 @@ export function uploadContentType(path: string): string { /** Read an upload after checking its type and size, before any request. */ export async function readUploadFile(path: string): Promise<{ readonly bytes: Uint8Array - readonly contentType: string + readonly contentType: KnownVoiceAudioUploadContentType }> { const contentType = uploadContentType(path) const absolute = resolve(path) @@ -286,24 +291,14 @@ export async function uploadAudio( client: VoiceClient, input: { readonly bytes: Uint8Array - readonly contentType: string + readonly contentType: KnownVoiceAudioUploadContentType readonly name: string readonly projectId: string readonly retentionDays?: number }, ): Promise> { - const created = await client.createAudioUpload({ - contentType: input.contentType, - name: input.name, - projectId: input.projectId, - sizeBytes: input.bytes.byteLength, - ...(input.retentionDays === undefined - ? {} - : {retentionDays: input.retentionDays}), - }) - await client.sendToUploadUrl(created.data.upload, input.bytes) - const completed = await client.completeAudio(created.data.asset.id) - return result(completed.data, completed.metadata) + const uploaded = await client.uploadAudio(input) + return result(uploaded.data, uploaded.metadata) } export async function synthesizeAudio( diff --git a/test/commands/voice.test.ts b/test/commands/voice.test.ts index 3d4d8f39..d543c240 100644 --- a/test/commands/voice.test.ts +++ b/test/commands/voice.test.ts @@ -352,6 +352,37 @@ describe('voice audio commands', () => { expect(server.requests).toHaveLength(1) }) + test('rejects provider-specific voices and models before sending TTS requests', async () => { + server = await startHttpSequenceServer([ok(asset)]) + const base = `voice audio tts --name Greeting --text Hello --project ${PROJECT}` + const badVoice = await apiKey(() => + runCommand(`${base} --provider openai --voice unsupported --json`), + ) + const badOpenAiModel = await apiKey(() => + runCommand(`${base} --provider openai --voice alloy --model unsupported`), + ) + const badElevenModel = await apiKey(() => + runCommand( + `${base} --provider elevenlabs --voice voice123 --model unsupported --json`, + ), + ) + + expect(exitCode(badVoice.error)).toBe(2) + expect(JSON.parse(badVoice.stderr)).toMatchObject({ + error: {category: 'configuration', message: 'Unsupported OpenAI voice.'}, + }) + expect(exitCode(badOpenAiModel.error)).toBe(2) + expect(badOpenAiModel.stderr).toContain('Unsupported OpenAI model.') + expect(exitCode(badElevenModel.error)).toBe(2) + expect(JSON.parse(badElevenModel.stderr)).toMatchObject({ + error: { + category: 'configuration', + message: 'Unsupported ElevenLabs model.', + }, + }) + expect(server.requests).toHaveLength(0) + }) + test('lists with filters and shows the next cursor', async () => { server = await startHttpSequenceServer([ { diff --git a/test/services/voice.test.ts b/test/services/voice.test.ts index 66059cfa..100b8af9 100644 --- a/test/services/voice.test.ts +++ b/test/services/voice.test.ts @@ -132,86 +132,13 @@ describe('waitForAudio', () => { }) }) -describe('createVoiceClient', () => { - const environment = { - baseUrl: 'https://api.example.test', - credential: { - type: 'apiKey' as const, - value: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - }, - surface: 'platform' as const, - } - const upload = { - expiresAt: '2026-09-19T10:05:00.000Z', - headers: {Authorization: 'Bearer leak', 'Content-Type': 'audio/wav'}, - maxBytes: 16_777_216, - method: 'POST', - url: 'https://uploads.example.test/u/token', - } - - test('sends only the upload headers, never a credential', async () => { - const fetch = vi.fn<(url: URL, init: RequestInit) => Promise>( - () => Promise.resolve(new Response('{"storageId":"s"}', {status: 201})), - ) - const client = createVoiceClient( - environment, - fetch as typeof globalThis.fetch, - ) - - await client.sendToUploadUrl(upload, new Uint8Array([1, 2])) - - const [url, init] = fetch.mock.calls[0] ?? [] - expect(String(url)).toBe('https://uploads.example.test/u/token') - const headers = new Headers(init?.headers) - expect(headers.get('authorization')).toBeNull() - expect(headers.get('content-type')).toBe('audio/wav') - expect(init?.redirect).toBe('error') - }) - - test('refuses plain HTTP upload URLs and maps 413 and 415 without the URL', async () => { - const fetch = vi.fn(() => Promise.resolve(new Response('', {status: 413}))) - const client = createVoiceClient(environment, fetch) - - await expect( - client.sendToUploadUrl( - {...upload, url: 'http://uploads.example.test/u/token'}, - new Uint8Array([1]), - ), - ).rejects.toMatchObject({code: 'invalid_upload_url'}) - expect(fetch).not.toHaveBeenCalled() - - const tooLarge = client.sendToUploadUrl(upload, new Uint8Array([1])) - await expect(tooLarge).rejects.toMatchObject({ - code: 'too_large', - status: 413, - }) - await expect(tooLarge).rejects.not.toThrow(/token/) - - fetch.mockImplementationOnce(() => - Promise.resolve(new Response('', {status: 415})), - ) - await expect( - client.sendToUploadUrl(upload, new Uint8Array([1])), - ).rejects.toThrow('content type does not match') - }) -}) - describe('voice transfers', () => { const ASSET_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' const preview = { - contentType: 'audio/ogg', + contentType: 'audio/ogg' as const, expiresAt: '2026-09-19T10:05:00.000Z', url: 'https://previews.example.test/p/token', } - const upload = { - expiresAt: '2026-09-19T10:05:00.000Z', - headers: {'Content-Type': 'audio/wav'}, - maxBytes: 16_777_216, - method: 'PUT', - url: 'https://uploads.example.test/u/token', - } - function environment(timeoutMs?: number) { return { baseUrl: 'https://api.example.test', @@ -278,24 +205,15 @@ describe('voice transfers', () => { test('times out an error response whose body stalls after the headers', async () => { const preview500 = stalledBody() - const upload500 = stalledBody() const previewClient = createVoiceClient( environment(50), fetchReturning(() => new Response(preview500.body, {status: 500})), ) - const uploadClient = createVoiceClient( - environment(50), - fetchReturning(() => new Response(upload500.body, {status: 403})), - ) await expect( previewClient.downloadPreview(preview, 1024), ).rejects.toMatchObject({code: 'request_timeout'}) - await expect( - uploadClient.sendToUploadUrl(upload, new Uint8Array([1])), - ).rejects.toMatchObject({code: 'request_timeout'}) expect(preview500.cancel).toHaveBeenCalled() - expect(upload500.cancel).toHaveBeenCalled() }) test('still reports the status and body code when the error body arrives', async () => { @@ -306,9 +224,10 @@ describe('voice transfers', () => { ), ) - await expect( - client.sendToUploadUrl(upload, new Uint8Array([1])), - ).rejects.toMatchObject({code: 'link_expired', status: 403}) + await expect(client.downloadPreview(preview, 1_000)).rejects.toMatchObject({ + code: 'link_expired', + status: 403, + }) }) test('stops reading a preview without Content-Length once it passes the limit', async () => { From 0319c079a775451c7d0b7ad83ddcb5280bf9d3f1 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 16:04:20 +0200 Subject: [PATCH 07/11] docs(cli): separate Voice and usage guidance after dev merge --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index eaef33b8..41d6940f 100644 --- a/README.md +++ b/README.md @@ -954,6 +954,7 @@ Other errors keep the standard exit codes: 2 for invalid input, 3 for authentication and authorization, 4 for conflicts, missing resources and other payment errors, and 5 for network, rate-limit, server and timeout failures. With `--json`, stderr carries the error code in `error.code`. + ### Call usage and limits When Calls access is enabled for a team, Polymorfa measures the calls its numbers host. Usage is measured, not charged. From bae3f9d0c13a6538972634d124a41d3649416e07 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 16:23:29 +0200 Subject: [PATCH 08/11] fix(voice): redact signed preview link from transfer errors --- src/sdk/voice.ts | 8 +++++--- test/services/voice.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/sdk/voice.ts b/src/sdk/voice.ts index 44da02de..9a3a87d2 100644 --- a/src/sdk/voice.ts +++ b/src/sdk/voice.ts @@ -136,13 +136,13 @@ export function createVoiceClient( if (controller.signal.aborted) { throw new PolymorfaTimeoutError( `The ${label} did not finish within ${transferTimeoutMs}ms.`, - {cause: error, code: 'request_timeout'}, + {code: 'request_timeout'}, ) } if (error instanceof PolymorfaError) throw error throw new PolymorfaConnectionError( `The ${label} could not reach its URL.`, - {cause: error, code: 'connection_error'}, + {code: 'connection_error'}, ) } finally { clearTimeout(timer) @@ -382,12 +382,14 @@ function transferError( body: unknown, label: string, ): PolymorfaError { - const bodyCode = + const candidateCode = typeof body === 'object' && body !== null && typeof (body as {error?: {code?: unknown}}).error?.code === 'string' ? (body as {error: {code: string}}).error.code : undefined + // The external storage response is untrusted and may echo the signed URL. + const bodyCode = candidateCode === 'link_expired' ? candidateCode : undefined const status = response.status const options = { status, diff --git a/test/services/voice.test.ts b/test/services/voice.test.ts index 100b8af9..8ed7a1e1 100644 --- a/test/services/voice.test.ts +++ b/test/services/voice.test.ts @@ -1,6 +1,7 @@ import {readdir, rm} from 'node:fs/promises' import {join} from 'node:path' import {PassThrough, Readable} from 'node:stream' +import {inspect} from 'node:util' import {describe, expect, test, vi} from 'vitest' import {createVoiceClient, type VoiceClient} from '../../src/sdk/voice.js' @@ -230,6 +231,34 @@ describe('voice transfers', () => { }) }) + test('does not retain a signed preview URL in network errors or storage response codes', async () => { + const secret = preview.url + const networkClient = createVoiceClient( + environment(50), + vi.fn(() => + Promise.reject(new Error(`GET ${secret} failed`)), + ) as typeof fetch, + ) + const networkError = await networkClient + .downloadPreview(preview, 1_000) + .catch((error: unknown) => error) + expect(networkError).toMatchObject({code: 'connection_error'}) + expect(inspect(networkError, {depth: 10})).not.toContain(secret) + + const storageClient = createVoiceClient( + environment(50), + fetchReturning( + () => + new Response(JSON.stringify({error: {code: secret}}), {status: 403}), + ), + ) + const storageError = await storageClient + .downloadPreview(preview, 1_000) + .catch((error: unknown) => error) + expect(storageError).toMatchObject({status: 403}) + expect(inspect(storageError, {depth: 10})).not.toContain(secret) + }) + test('stops reading a preview without Content-Length once it passes the limit', async () => { const maxBytes = 10_000 const chunkBytes = 1_000 From 3754cd49ee86885ab541a1bbad13eb60e1d369ce Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 16:37:54 +0200 Subject: [PATCH 09/11] docs(voice): explain signed preview error redaction --- README.md | 3 ++- docs/command-research/voice.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 41d6940f..b5b8f052 100644 --- a/README.md +++ b/README.md @@ -912,7 +912,8 @@ polymorfa voice audio delete 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes 3650). `update --retention-days none` keeps it until you delete it. - `preview` prints a link to the transcoded audio (Opus in Ogg). The link works for 5 minutes; treat it as a secret. `--output` downloads it instead and - refuses an existing file unless `--force` is given. + refuses an existing file unless `--force` is given. Storage refusals retain + the HTTP status but omit the signed link and storage response text. - `delete` asks for confirmation, or takes `--yes`, and exits with code 4 while a campaign or flow uses the asset. diff --git a/docs/command-research/voice.md b/docs/command-research/voice.md index 99d3270b..8c4b6f45 100644 --- a/docs/command-research/voice.md +++ b/docs/command-research/voice.md @@ -113,7 +113,8 @@ URL itself with no credential, no redirects, a bounded response, and a timeout. The upload request itself is not retried, matching the SDK. - **Preview.** Without `--output` the command prints the 5-minute URL. With it, the file is downloaded without a credential, limited to 32 MiB, and an - existing file is refused unless `--force` is given. + existing file is refused unless `--force` is given. Error messages and nested + causes must not retain the signed URL or storage response text. - **Confirmation.** Both deletes use the shared confirmation prompt, or `--yes` in noninteractive use. - **No idempotency flags.** The contract defines no idempotency behavior for From 1750c11c59cc25eaf247fe3bc474cd97aabb6f38 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 16:49:55 +0200 Subject: [PATCH 10/11] chore(voice): pin published SDK with upload redaction --- package-lock.json | 9 +++++---- package.json | 2 +- test/services/voice.test.ts | 4 +--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 43bccdd0..898db896 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923132258", + "@polymorfa/sdk": "0.1.0-dev.20260923144429", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,9 +997,10 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20260923132258", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923132258.tgz", - "integrity": "sha512-ol2nSNl0tfRJQOAk1kywPwMu3+7KJxO1pVVlZj5S6Akj2+mnyKzjSgSU1EVHIObDLY9LLgg/kkTSeVbBmf0FGg==", + "version": "0.1.0-dev.20260923144429", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923144429.tgz", + "integrity": "sha512-U/JQY4f5hE9LZFPQaOHSH8bSXXTNKFmd5ZtU6mpS7HmMcQH89eJlFfquTI3bgLtHJiyt4j/jt1wp8ULVv6IY7Q==", + "license": "MIT", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index a6965320..c4cb310e 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923132258", + "@polymorfa/sdk": "0.1.0-dev.20260923144429", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, diff --git a/test/services/voice.test.ts b/test/services/voice.test.ts index 8ed7a1e1..c4d8744a 100644 --- a/test/services/voice.test.ts +++ b/test/services/voice.test.ts @@ -235,9 +235,7 @@ describe('voice transfers', () => { const secret = preview.url const networkClient = createVoiceClient( environment(50), - vi.fn(() => - Promise.reject(new Error(`GET ${secret} failed`)), - ) as typeof fetch, + vi.fn(() => Promise.reject(new Error(`GET ${secret} failed`))), ) const networkError = await networkClient .downloadPreview(preview, 1_000) From 3ff9e042ea0fa5fce3a4757d940649597c8f1804 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 16:59:55 +0200 Subject: [PATCH 11/11] fix(voice): omit signed preview link from download JSON --- README.md | 5 +++-- docs/command-research/voice.md | 5 +++-- src/services/voice.ts | 21 ++++++++++++++++----- test/commands/voice.test.ts | 27 +++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b5b8f052..8161a789 100644 --- a/README.md +++ b/README.md @@ -912,8 +912,9 @@ polymorfa voice audio delete 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 --yes 3650). `update --retention-days none` keeps it until you delete it. - `preview` prints a link to the transcoded audio (Opus in Ogg). The link works for 5 minutes; treat it as a secret. `--output` downloads it instead and - refuses an existing file unless `--force` is given. Storage refusals retain - the HTTP status but omit the signed link and storage response text. + refuses an existing file unless `--force` is given. With `--output`, JSON + results omit the signed link. Storage refusals retain the HTTP status but + omit the signed link and storage response text. - `delete` asks for confirmation, or takes `--yes`, and exits with code 4 while a campaign or flow uses the asset. diff --git a/docs/command-research/voice.md b/docs/command-research/voice.md index 8c4b6f45..1cb61ad9 100644 --- a/docs/command-research/voice.md +++ b/docs/command-research/voice.md @@ -113,8 +113,9 @@ URL itself with no credential, no redirects, a bounded response, and a timeout. The upload request itself is not retried, matching the SDK. - **Preview.** Without `--output` the command prints the 5-minute URL. With it, the file is downloaded without a credential, limited to 32 MiB, and an - existing file is refused unless `--force` is given. Error messages and nested - causes must not retain the signed URL or storage response text. + existing file is refused unless `--force` is given. JSON download results, + error messages, and nested causes must not retain the signed URL or storage + response text. - **Confirmation.** Both deletes use the shared confirmation prompt, or `--yes` in noninteractive use. - **No idempotency flags.** The contract defines no idempotency behavior for diff --git a/src/services/voice.ts b/src/services/voice.ts index a836f768..fa424680 100644 --- a/src/services/voice.ts +++ b/src/services/voice.ts @@ -414,10 +414,15 @@ export async function deleteAudio( return result(response.data, response.metadata) } -export interface PreviewResult { - readonly preview: VoiceAudioPreview - readonly download?: {readonly bytes: number; readonly path: string} -} +export type PreviewResult = + | { + readonly preview: VoiceAudioPreview + readonly download?: undefined + } + | { + readonly preview: Pick + readonly download: {readonly bytes: number; readonly path: string} + } export async function previewAudio( client: VoiceClient, @@ -445,7 +450,13 @@ export async function previewAudio( throw error } return result( - {download: {bytes: bytes.byteLength, path: output}, preview: response.data}, + { + download: {bytes: bytes.byteLength, path: output}, + preview: { + contentType: response.data.contentType, + expiresAt: response.data.expiresAt, + }, + }, response.metadata, ) } diff --git a/test/commands/voice.test.ts b/test/commands/voice.test.ts index d543c240..7f3109da 100644 --- a/test/commands/voice.test.ts +++ b/test/commands/voice.test.ts @@ -550,6 +550,33 @@ describe('voice audio commands', () => { ]) }) + test('omits the signed link from JSON after downloading a preview', async () => { + const signedUrl = '/previews/signed-capability' + server = await startHttpSequenceServer([ + ok({ + contentType: 'audio/ogg', + expiresAt: '2026-09-19T10:05:00.000Z', + url: signedUrl, + }), + {rawBody: Buffer.from('OggS-audio')}, + ]) + directory = await mkdtemp(join(tmpdir(), 'polymorfa-voice-')) + const output = join(directory, 'greeting.ogg') + + const downloaded = await apiKey(() => + runCommand(`voice audio preview ${ASSET_ID} --output ${output} --json`), + ) + + expect(downloaded.error, downloaded.stderr).toBeUndefined() + expect(JSON.parse(downloaded.stdout)).toMatchObject({ + data: {download: {bytes: 10, path: output}}, + }) + expect(downloaded.stdout).not.toContain(signedUrl) + expect(await readFile(output, 'utf8')).toBe('OggS-audio') + expect(server.requests[1]?.url).toBe(signedUrl) + expect(server.requests[1]?.headers.authorization).toBeUndefined() + }) + async function apiKey(run: () => Promise): Promise { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment(