Skip to content

Add voice cloning API endpoint with reference audio enhancement - #401

Open
gianpaj wants to merge 4 commits into
mainfrom
claude/voice-cloning-api-87ztG
Open

Add voice cloning API endpoint with reference audio enhancement#401
gianpaj wants to merge 4 commits into
mainfrom
claude/voice-cloning-api-87ztG

Conversation

@gianpaj

@gianpaj gianpaj commented May 30, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a new /api/v1/clone endpoint that enables users to clone voices from reference audio and synthesize new speech with the cloned voice. The implementation supports two cloning providers (Mistral Voxtral for supported locales, Replicate for others), includes optional reference audio enhancement via denoising, and integrates with the existing billing and credit system.

Changes

  • New API endpoint (POST /api/v1/clone): Accepts reference audio as either a public URL or base64-encoded data, synthesizes speech with the cloned voice, and returns the generated audio URL
  • Clone service module (lib/clone/clone-service.ts): Core voice cloning logic including provider resolution, audio validation/processing, Mistral Voxtral and Replicate integration, and reference audio enhancement billing
  • Reference audio enhancement (lib/clone/reference-audio-enhancement.ts): Optional denoising/enhancement of reference audio before cloning (best-effort, falls back gracefully)
  • Request/response schemas: Added VoiceCloneRequestSchema and VoiceCloneResponseSchema with proper validation and OpenAPI documentation
  • Comprehensive test coverage: Unit tests for clone service utilities and integration tests for the API endpoint covering happy paths, error cases, and billing scenarios
  • Documentation: Updated API docs with curl and TypeScript examples, added OpenAPI endpoint definition
  • Pricing integration: Extended pricing module to support Mistral provider and voice cloning billing

How to test

  1. API endpoint validation: Call POST /api/v1/clone with valid reference audio (URL or base64) and input text; verify 200 response with generated audio URL and correct credit deduction
  2. Provider routing: Test with locale: "en" (uses Mistral) and locale: "ja" (uses Replicate); verify correct provider is invoked
  3. Error handling: Test missing/invalid API key (401), rate limit exceeded (429), insufficient credits (402), unsupported locale (400), reference audio too short (400)
  4. Reference audio enhancement: Call with enhance_reference_audio: true; verify additional credits are deducted and enhancement metadata is logged
  5. Run test suite: pnpm test api-v1-clone.test.ts clone-service.test.ts to verify all unit and integration tests pass

Scope

  • Backend
  • Docs

Checklist

  • I self-reviewed this PR
  • I ran pnpm run fixall
  • I ran pnpm run type-check
  • I added or updated tests where needed
  • I updated docs/comments where needed

Notes for reviewers

Key implementation details:

  • The endpoint uses a 800-second timeout (Vercel Fluid Compute) to accommodate cloning operations
  • Reference audio enhancement is best-effort: if it fails, the endpoint falls back to the original audio without failing the request
  • Audio processing (conversion to WAV, trimming) is provider-specific and happens before cloning
  • Billing tracks both base cloning credits and optional enhancement credits separately in usage events
  • The implementation mirrors the internal /api/clone-voice route's audio normalization logic but operates on buffers instead of File objects to support both URL and base64 inputs

https://claude.ai/code/session_01JEyH3uDy8xL49NnUygrDqz

Add POST /api/v1/clone to the external, API-key-authenticated v1 API. It
accepts a JSON body with reference audio as either a public URL
(reference_audio_url) or base64 (reference_audio), reuses the existing
auth/rate-limit/billing/error infrastructure, and reuses the Mistral Voxtral
and Replicate Chatterbox cloning providers via a new shared clone-service
module (the internal /api/clone-voice route is left untouched).

- lib/clone/clone-service.ts: reusable, RouteError-free cloning helpers
  (provider/locale resolution, audio normalization, provider calls) that
  throw a typed CloneServiceError mapped to API errors by the route
- lib/api/schemas.ts: VoiceCloneRequest/Response schemas
- lib/api/pricing.ts: api_voice_cloning pricing for mistral/replicate
- lib/api/openapi.ts: register /api/v1/clone + schemas (v1.1.0)
- docs: api.mdx Voice Cloning section + generated endpoint page
- dashboard: voice cloning FeatureCard on the API Keys page
- i18n: cloneTitle/cloneDescription in all six locales

Tests: api-v1-clone (route), clone-service (helpers), pricing updates.
Copilot AI review requested due to automatic review settings May 30, 2026 11:40
@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@pullfrog

pullfrog Bot commented May 30, 2026

Copy link
Copy Markdown

Pullfrog stalled — likely cause: rate limited (429)

The agent stopped emitting events for 300s and was killed by the activity-timeout watchdog. No events were emitted before the failure.

Recent agent stderr
... (older lines truncated)
HTTPS\n  - Format options: \"markdown\" (default), \"text\", or \"html\"\n  - This tool is read-only and does not modify any files\n  - Results may be summarized if the content is very large\n","input_schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"url":{"type":"string","description":"The URL to fetch content from"},"format":{"anyOf":[{"type":"string","enum":["text","markdown","html"],"description":"The format to return the content in (text, markdown, or html). Defaults to markdown.","default":"markdown"},{"type":"null"}]},"timeout":{"type":"number","description":"Optional timeout in seconds (max 120)"}},"required":["url"]},"eager_input_streaming":true},{"name":"write","description":"Writes a file to the local filesystem.\n\nUsage:\n- This tool will overwrite the existing file if there is one at the provided path.\n- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first.\n- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.\n- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User.\n- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked.\n","input_schema":{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"content":{"type":"string","description":"The content to write to the file"},"filePath":{"type":"string","description":"The absolute path to the file to write (must be absolute, not relative)"}},"required":["content","filePath"]},"eager_input_streaming":true}],"tool_choice":{"type":"auto"},"stream":true},"statusCode":429,"responseHeaders":{"anthropic-organization-id":"453c67c4-fc07-4a9b-93d6-a92691b21fa7","cf-cache-status":"DYNAMIC","cf-ray":"a03d7ed05c1581c3-IAD","connection":"keep-alive","content-encoding":"gzip","content-security-policy":"default-src 'none'; frame-ancestors 'none'","content-type":"application/json","date":"Sat, 30 May 2026 11:45:55 GMT","request-id":"req_011CbYhQmnh43N22VNw8sKmz","server":"cloudflare","strict-transport-security":"max-age=31536000; includeSubDomains; preload","traceresponse":"00-ca4f47bd9c4feeea466e0878f884301b-fac81ef14335c5d1-01","transfer-encoding":"chunked","vary":"Accept-Encoding","x-robots-tag":"none","x-should-retry":"true","set-cookie":"_cfuvid=i2uuwy0UexpjbEhXfcYyB0yHO8ycXGezbY7A5eVLHfI-1780141555.2606323-1.0.1.1-tDE5EMW5YSfkiVKza7KpshF9objmFsht7XElPuu0INE; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"},"responseBody":"{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"Error\"},\"request_id\":\"req_011CbYhQmnh43N22VNw8sKmz\"}","isRetryable":true,"data":{"type":"error","error":{"type":"rate_limit_error","message":"Error"}}}} stream error
INFO  2026-05-30T11:45:55 +2ms service=bus type=session.status publishing

Pullfrog  | Rerun failed job ➔View workflow run | via Pullfrog𝕏

@vercel

vercel Bot commented May 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
sexyvoice Ready Ready Preview, Comment Jul 7, 2026 4:44pm
sexyvoice-docs Ready Ready Preview, Comment Jul 7, 2026 4:44pm

Request Review

@claude

claude Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @gianpaj's task in 4m 46s —— View job


Code Review

  • Read CLAUDE.md / AGENTS.md
  • Review API endpoint route (apps/web/app/api/v1/clone/route.ts)
  • Review clone service module (apps/web/lib/clone/clone-service.ts)
  • Review reference audio enhancement
  • Review schemas, pricing, and OpenAPI
  • Review tests
  • Post inline comments + summary

Overall

Solid implementation. The clone service module is well-organized, error mapping is comprehensive, billing is split correctly between base cloning and optional enhancement, and the test suite covers most happy/sad paths for the route. The enhancement-output URL allow-listing in reference-audio-enhancement.ts is a nice touch.

There's one critical security issue and a handful of correctness / robustness improvements worth addressing before this is exposed externally.


🔴 Critical

  1. SSRF in reference_audio_url fetchapps/web/app/api/v1/clone/route.ts:115. User-supplied URL is fetch()-ed with no scheme/host restriction and no timeout. Outbound URLs from fal.ai are allow-listed (good); the inbound URL must be too. An API-key holder can probe internal infrastructure (e.g. 169.254.169.254, localhost, RFC1918 ranges) via timing/status codes. See inline comment for mitigation list. Note: redirects must also be validated.

🟡 Should fix

  1. Success-path log is not awaitedroute.ts:519. Every other log() call is awaited; only the 200 path is fire-and-forget, which on Vercel can be cut off after the response flushes — meaning successful clone events drop silently from the log pipeline.
  2. predictionId for Mistral is a synthesized randomUUID()clone-service.ts:458. This breaks traceability to upstream Mistral logs. Use the SDK's request id if available, or at least prefix to make local origin obvious in DB rows.
  3. Permissive base64 decodingroute.ts:160. Buffer.from(garbage, 'base64') produces something for almost any input, so the length === 0 check rarely triggers; failures surface much later with a confusing error.
  4. Missing test: enhancement fallback pathapi-v1-clone.test.ts. The "best-effort" fallback (a key selling point in the PR description) isn't exercised; also no tests for reference_audio_too_long / reference_audio_too_large.

🟢 Minor / nits

  1. Redundant boolean in shouldNormalizeToWavclone-service.ts:261. The logic is correct but the variable name doesn't match the intent. Suggested rename in inline comment.
  2. crypto.subtle.digest vs node:crypto.createHashclone-service.ts:208. Tiny optimization + removes the WebCrypto detour; randomUUID is already imported from node:crypto.
  3. SUPPORTED_LOCALE_CODES.find(...)clone-service.ts:132. Mirror the Set pattern used for Voxtral locales.
  4. refine path on VoiceCloneRequestSchemaschemas.ts:194. Always points at reference_audio_url, which is misleading when the actual problem is the inverse.
  5. Race condition: balance check then deductroute.ts:365. Pre-existing pattern across the speech route too, but voice cloning is more expensive per request. Worth a follow-up to atomicize the credit deduction.

Notes / observations

  • AGENTS.md flags duplication as a code smell. The internal apps/web/app/api/clone-voice/route.ts still has its own constants for REFERENCE_AUDIO_ENHANCEMENT_* (lines 65-68 in that file). This PR's new clone-service.ts is the right home for them — consider a follow-up that switches the internal route to import from there to avoid drift.
  • maxDuration = 800 is high but justified for cloning + enhancement. Worth keeping an eye on cost.
  • Docs and OpenAPI are well-integrated; pricing table additions look correct against the per-1k character costs cited.
  • Per AGENTS.md task completion requirements, please confirm pnpm fixall, pnpm type-check, pnpm test, and pnpm check-translations all pass — translation keys were touched across all six locale files.

| Branch: claude/voice-cloning-api-87ztG

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new Voice Cloning API endpoint (POST /api/v1/clone) that allows users to clone voices from reference recordings (via URL or base64) and synthesize speech using Mistral or Replicate. It includes API documentation, OpenAPI schema updates, pricing configurations, and comprehensive unit tests. The review feedback highlights a critical billing vulnerability where expensive audio enhancement is performed before credit validation, a potential OOM risk when fetching unbounded reference audio streams, an unawaited logging promise in a serverless context, and an opportunity to optimize buffer hashing using standard node:crypto.

Comment thread apps/web/app/api/v1/clone/route.ts Outdated
Comment on lines +326 to +382
enhancementDurationSeconds = processed.duration;
enhancementCredits = calculateReferenceAudioEnhancementCredits(
enhancementDurationSeconds,
);
enhancementDollarAmount = getReferenceAudioEnhancementDollarCost(
enhancementDurationSeconds,
);

try {
const enhanced = await enhanceReferenceAudio({
abortSignal: request.signal,
buffer: processed.buffer,
filename: reference.filename,
mimeType: processed.mimeType,
});
cloneBuffer = enhanced.buffer;
cloneMimeType = enhanced.mimeType;
cloneAudioHash = await generateBufferHash(enhanced.buffer);
cloneDuration =
(await getAudioDuration(enhanced.buffer, enhanced.mimeType)) ??
cloneDuration;
referenceAudioEnhanced = true;
enhancementModelUsed = enhanced.modelUsed;
enhancementRequestId = enhanced.requestId;
creditsUsed = baseCloneCredits + enhancementCredits;
} catch (enhancementError) {
// Enhancement is best-effort: fall back to the original reference audio.
captureException(enhancementError, {
extra: { requestId, endpoint: ENDPOINT, locale },
});
enhancementCredits = 0;
enhancementDollarAmount = 0;
enhancementDurationSeconds = null;
creditsUsed = baseCloneCredits;
}
}

validateAudioDuration(cloneDuration, provider);

const currentCredits = await getCreditsAdmin(userId);
if (currentCredits < creditsUsed) {
await log({
status: 402,
errorCode: 'insufficient_credits',
userId,
apiKeyId: authResult.apiKeyId,
textLength: input.length,
});
return respond(
createApiError({
message: 'Insufficient credits',
type: 'permission_error',
code: 'insufficient_credits',
}),
{ status: 402 },
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

Critical Billing Vulnerability: Credit Check Performed After Expensive API Call

The endpoint performs the expensive enhanceReferenceAudio API call (which invokes an external provider and costs real money/credits) before verifying if the user has sufficient credits.

An attacker or user with 0 credits can repeatedly call this endpoint with enhance_reference_audio: true, forcing the server to perform the enhancement and incur costs before failing with a 402 Insufficient credits error.

Fix: Move the credit check to happen before the enhanceReferenceAudio call. We can estimate the total credits needed (base credits + enhancement credits) and validate the user's balance early.

      enhancementDurationSeconds = processed.duration;
      enhancementCredits = calculateReferenceAudioEnhancementCredits(
        enhancementDurationSeconds,
      );
      enhancementDollarAmount = getReferenceAudioEnhancementDollarCost(
        enhancementDurationSeconds,
      );
      creditsUsed = baseCloneCredits + enhancementCredits;
    }

    const currentCredits = await getCreditsAdmin(userId);
    if (currentCredits < creditsUsed) {
      await log({
        status: 402,
        errorCode: 'insufficient_credits',
        userId,
        apiKeyId: authResult.apiKeyId,
        textLength: input.length,
      });
      return respond(
        createApiError({
          message: 'Insufficient credits',
          type: 'permission_error',
          code: 'insufficient_credits',
        }),
        { status: 402 },
      );
    }

    if (enhancementEnabled) {
      try {
        const enhanced = await enhanceReferenceAudio({
          abortSignal: request.signal,
          buffer: processed.buffer,
          filename: reference.filename,
          mimeType: processed.mimeType,
        });
        cloneBuffer = enhanced.buffer;
        cloneMimeType = enhanced.mimeType;
        cloneAudioHash = await generateBufferHash(enhanced.buffer);
        cloneDuration =
          (await getAudioDuration(enhanced.buffer, enhanced.mimeType)) ??
          cloneDuration;
        referenceAudioEnhanced = true;
        enhancementModelUsed = enhanced.modelUsed;
        enhancementRequestId = enhanced.requestId;
      } catch (enhancementError) {
        // Enhancement is best-effort: fall back to the original reference audio.
        captureException(enhancementError, {
          extra: { requestId, endpoint: ENDPOINT, locale },
        });
        enhancementCredits = 0;
        enhancementDollarAmount = 0;
        enhancementDurationSeconds = null;
        creditsUsed = baseCloneCredits;
      }
    }

    validateAudioDuration(cloneDuration, provider);

Comment thread apps/web/app/api/v1/clone/route.ts Outdated
Comment on lines +124 to +144
const contentLength = response.headers.get('content-length');
if (
contentLength &&
Number.isFinite(Number(contentLength)) &&
Number(contentLength) > REFERENCE_AUDIO_MAX_FETCH_BYTES
) {
throw new CloneServiceError(
'unsupported_audio_format',
'Reference audio exceeds the maximum allowed size.',
{ param: 'reference_audio_url' },
);
}

const arrayBuffer = await response.arrayBuffer();
if (arrayBuffer.byteLength > REFERENCE_AUDIO_MAX_FETCH_BYTES) {
throw new CloneServiceError(
'unsupported_audio_format',
'Reference audio exceeds the maximum allowed size.',
{ param: 'reference_audio_url' },
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Potential Out Of Memory (OOM) via Unbounded response.arrayBuffer()

Although there is a check on the content-length header, if the external server omits the content-length header or sends a spoofed/incorrect one, calling await response.arrayBuffer() will read the entire response body into memory.

If a user provides a URL pointing to an extremely large file (e.g., several gigabytes), this will consume all available memory and crash the serverless function with an Out Of Memory (OOM) error.

Fix: Enforce a hard limit on the fetched bytes by consuming the response body stream chunk-by-chunk and aborting the request if the total bytes read exceed REFERENCE_AUDIO_MAX_FETCH_BYTES.

    const contentLength = response.headers.get('content-length');
    if (
      contentLength &&
      Number.isFinite(Number(contentLength)) &&
      Number(contentLength) > REFERENCE_AUDIO_MAX_FETCH_BYTES
    ) {
      throw new CloneServiceError(
        'unsupported_audio_format',
        'Reference audio exceeds the maximum allowed size.',
        { param: 'reference_audio_url' },
      );
    }

    const reader = response.body?.getReader();
    if (!reader) {
      throw new CloneServiceError(
        'unsupported_audio_format',
        'Failed to read reference audio stream.',
        { param: 'reference_audio_url' },
      );
    }

    const chunks: Uint8Array[] = [];
    let totalBytes = 0;
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      totalBytes += value.length;
      if (totalBytes > REFERENCE_AUDIO_MAX_FETCH_BYTES) {
        reader.cancel();
        throw new CloneServiceError(
          'unsupported_audio_format',
          'Reference audio exceeds the maximum allowed size.',
          { param: 'reference_audio_url' },
        );
      }
      chunks.push(value);
    }
    const buffer = Buffer.concat(chunks);

Comment on lines +506 to +519
log({
status: 200,
userId,
apiKeyId: authResult.apiKeyId,
model: modelUsed,
textLength: input.length,
provider,
creditsUsed,
dollarAmount,
referenceAudioEnhanced,
userHasPaid,
}).catch((err) => {
console.error('[clone] success-path log failed:', err);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Unawaited Success Log Promise in Route Handler

On the success path, the log promise is called but not awaited. In serverless environments (like Vercel / AWS Lambda), the container can be suspended immediately after the response is returned. If the log promise is not resolved before suspension, the log write may be delayed, truncated, or completely lost.

Since all other log calls in this file are correctly awaited, this success log should also be awaited.

Suggested change
log({
status: 200,
userId,
apiKeyId: authResult.apiKeyId,
model: modelUsed,
textLength: input.length,
provider,
creditsUsed,
dollarAmount,
referenceAudioEnhanced,
userHasPaid,
}).catch((err) => {
console.error('[clone] success-path log failed:', err);
});
await log({
status: 200,
userId,
apiKeyId: authResult.apiKeyId,
model: modelUsed,
textLength: input.length,
provider,
creditsUsed,
dollarAmount,
referenceAudioEnhanced,
userHasPaid,
}).catch((err) => {
console.error('[clone] success-path log failed:', err);
});

Comment thread apps/web/lib/clone/clone-service.ts Outdated
Comment on lines +205 to +210
export async function generateBufferHash(buffer: Buffer): Promise<string> {
const data = new Uint8Array(buffer);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use Standard node:crypto for Buffer Hashing

The generateBufferHash function uses crypto.subtle.digest (Web Crypto API) which requires converting the buffer to a Uint8Array, returning a Promise, and manually mapping the resulting array to a hex string.

Since node:crypto is already imported in this file, we can use the standard, synchronous, and highly optimized createHash function. This simplifies the code, avoids Web Crypto overhead, and removes the need for manual hex formatting.

export async function generateBufferHash(buffer: Buffer): Promise<string> {
  const { createHash } = await import('node:crypto');
  return createHash('sha256').update(buffer).digest('hex');
}

Comment thread apps/web/app/api/v1/clone/route.ts Outdated
reference_audio_format?: string;
}): Promise<{ buffer: Buffer; mimeType: string; filename: string }> {
if (data.reference_audio_url) {
const response = await fetch(data.reference_audio_url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Security: SSRF in reference_audio_url fetch.

fetch(data.reference_audio_url) is called against a user-provided URL with no scheme/host restriction. The Zod schema only validates that the string is a syntactically valid URL — it does not block:

  • http://169.254.169.254/... (AWS metadata service)
  • http://localhost:6379/..., http://10.0.0.1/... (internal services)
  • file://, gopher://, ftp:// schemes
  • Other internal infrastructure

Because the response body is returned to the caller (via the eventually-generated audio URL we control) and any non-2xx is surfaced as a status code in the error, an attacker can use this to probe internal networks via timing and status codes, or read responses from internal HTTP services that may serve audio-like content.

Compare with apps/web/lib/clone/reference-audio-enhancement.ts:38-56 where the outbound fal.ai URL is restricted to fal-cdn.com / fal.media with https:-only — the inbound user URL needs equivalent treatment.

Suggested mitigations:

  • Enforce parsedUrl.protocol === 'https:' (or at minimum reject file:, data:, etc.)
  • Resolve the hostname and reject RFC1918 / loopback / link-local / IPv6 ULA addresses (or use an allow-list of trusted CDN hosts)
  • Add an AbortSignal.timeout(...) to bound the request duration (currently no timeout — a slow URL can block up to maxDuration = 800s)
  • Set redirect: 'manual' (or explicitly handle redirects) so SSRF protection isn't bypassed by a 302 to an internal host

Fix this →

/^data:[^;]+;base64,/,
'',
);
const buffer = Buffer.from(base64, 'base64');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Permissive base64 decoding.

Buffer.from(base64, 'base64') silently drops invalid characters and produces some buffer for almost any non-empty string, so the buffer.length === 0 check only catches the truly-empty case. A long string of garbage will pass this check and then fail much later (during parseBuffer, conversion, or at the provider) with an error that isn't as user-friendly.

Consider an explicit validation pass to fail-fast with a clear error:

if (!/^[A-Za-z0-9+/]+=*$/.test(base64) || base64.length % 4 !== 0) {
  throw new CloneServiceError('unsupported_audio_format', 'reference_audio is not valid base64', { param: 'reference_audio' });
}

Also note: the regex ^data:[^;]+;base64, only handles the ;base64, data URL form. If a caller passes data:audio/wav,... (base64-less data URL), it slips through. Probably worth either accepting both or rejecting non-base64 data URLs explicitly.

Comment thread apps/web/app/api/v1/clone/route.ts Outdated

validateAudioDuration(cloneDuration, provider);

const currentCredits = await getCreditsAdmin(userId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Time-of-check / time-of-use on credits.

Between getCreditsAdmin here and reduceCreditsAdmin on line 429, multiple concurrent requests from the same API key can all observe sufficient balance and then all decrement, allowing the account to go negative.

This is a pre-existing pattern in the speech route, so probably out of scope for this PR — but voice cloning is more expensive (especially with enhancement) so the financial impact per race is larger. Worth tracking as a follow-up:

  • Move the balance check into the same SQL statement as the reduce (e.g. UPDATE credits SET balance = balance - $amount WHERE user_id = $u AND balance >= $amount RETURNING balance), or
  • Use a Postgres advisory lock per userId for the duration of the credit operation.

Also, the rate limiter (per key) bounds blast radius — but does not eliminate the race.

userHasPaid,
}).catch((err) => {
console.error('[clone] success-path log failed:', err);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent logging: success path is not awaited.

Every other log({...}) call in this handler is awaited; only the 200 success path is fire-and-forget with a .catch. On Vercel, an unawaited promise that resolves after the response is sent may be cut off by the runtime before it flushes — meaning successful cloning events can silently drop from the log pipeline, while every error path logs reliably.

Either await it like the rest, or event.waitUntil(...) it via after() from next/server if you specifically want it off the response path.

Suggested change
});
await log({
status: 200,
userId,
apiKeyId: authResult.apiKeyId,
model: modelUsed,
textLength: input.length,
provider,
creditsUsed,
dollarAmount,
referenceAudioEnhanced,
userHasPaid,
});

throw new Error('Mistral Voxtral response did not return a valid WAV file');
}

return { buffer, modelUsed: model, requestId: randomUUID() };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

predictionId is a synthesized UUID, not the upstream request id.

randomUUID() is opaque and not traceable back to a Mistral request. The Mistral SDK exposes a request id (e.g. response.requestID or via response headers depending on SDK version) — capturing that would let support correlate the saved row in audio_files.prediction_id with logs on Mistral's side when guardrail/policy issues arise.

If the SDK doesn't expose one cleanly, at minimum prefix the UUID (e.g. local-${randomUUID()}) so it's obvious from the DB row that this is a locally-generated id, not an upstream one.


const canNormalizeToWav = isConversionSupported(normalizedMimeType, filename);
const shouldNormalizeToWav =
provider === 'mistral' || enhancementEnabled || canNormalizeToWav;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant boolean in shouldNormalizeToWav.

shouldNormalizeToWav = provider === 'mistral' || enhancementEnabled || canNormalizeToWav; — since canNormalizeToWav is true whenever conversion is supported, this is effectively "normalize whenever it's possible." The first two clauses only matter when canNormalizeToWav === false, in which case we enter the if (... && needsConversion) block and explicitly throw unsupported_audio_format.

That's the intent for Mistral and enhancement (force-fail unsupported formats), but means the variable name is a bit misleading: it's not "should normalize" — it's "should error when format can't normalize."

Consider renaming + clarifying with a comment:

// For Mistral and enhancement we *require* normalization — fail loudly if not possible.
// For Replicate, normalize when we can but pass through unknown formats untouched.
const requireWavNormalization = provider === 'mistral' || enhancementEnabled;
const shouldAttemptConversion = requireWavNormalization || canNormalizeToWav;

Functionally equivalent, just easier to reason about.

Comment thread apps/web/lib/clone/clone-service.ts Outdated
export async function generateBufferHash(buffer: Buffer): Promise<string> {
const data = new Uint8Array(buffer);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: crypto.subtle.digest works but node:crypto.createHash is faster.

You're already importing randomUUID from node:crypto at the top. For hashing a Buffer in Node, createHash('sha256').update(buffer).digest('hex') is more direct and avoids the WebCrypto detour (and the Uint8Array wrapping). Tiny micro-optimization, but it also makes the implementation environment-independent (doesn't depend on crypto.subtle being a global, which is true in Node 24 but isn't in older runtimes).

import { createHash, randomUUID } from 'node:crypto';

export function generateBufferHash(buffer: Buffer): string {
  return createHash('sha256').update(buffer).digest('hex');
}

(Note: this changes the return type from Promise<string> to string, so callers like cloneAudioHash = await generateBufferHash(...) need to drop the await. Or keep the async signature for callsite compatibility.)

}

export function validateLocale(locale: string): void {
const localeConfig = SUPPORTED_LOCALE_CODES.find((l) => l.code === locale);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O(n) .find on every request.

SUPPORTED_LOCALE_CODES.find(...) is called in both validateLocale and cloneVoiceWithReplicate. The list is small (~25 entries) so this is fine, but since you already have a Set pattern for Voxtral locales (VOXTRAL_SUPPORTED_LOCALE_CODES in constants.ts), consider mirroring it:

const SUPPORTED_LOCALE_CODE_SET = new Set(SUPPORTED_LOCALE_CODES.map((l) => l.code));

…and use .has() for membership checks. Keeps the array for iteration / the error message.

})
.refine(
(data) =>
Boolean(data.reference_audio_url) !== Boolean(data.reference_audio),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refinement error path only flags reference_audio_url.

When the user provides neither or both fields, the validation error is attached to reference_audio_url. That's a bit misleading when the actual problem might be that they provided reference_audio and reference_audio_url simultaneously, or neither.

Consider either reporting both paths, or omitting path so it surfaces as a top-level form error:

.refine(
  (data) => Boolean(data.reference_audio_url) !== Boolean(data.reference_audio),
  { message: 'Provide exactly one of "reference_audio_url" or "reference_audio"' },
)

Minor UX nit — the existing test (api-v1-clone.test.ts:110-130) already covers both branches but only asserts the top-level error code, so this change won't break it.

creditsUsed: 120, // 12s * 10 credits/sec
}),
);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test gap: no coverage for the enhancement fallback path.

The route advertises (and the PR description emphasizes) that enhancement is best-effort — if enhanceReferenceAudio throws, the request should still succeed using the original audio, with no enhancement credits charged. There's no test for that path. A useful addition:

it('falls back to original audio when enhancement fails', async () => {
  mockFalSubscribe.mockRejectedValueOnce(new Error('fal-ai/deepfilternet3 failed'));
  const response = await POST(cloneRequest({
    input: 'Hello world',
    locale: 'en',
    reference_audio: createWavBase64(),
    enhance_reference_audio: true,
  }));
  expect(response.status).toBe(200);
  const json = await response.json();
  expect(json.credits_used).toBeLessThan(120); // No enhancement credits
  // Only the base cloning usage event — no audio_processing event.
  expect(vi.mocked(insertUsageEvent)).toHaveBeenCalledTimes(1);
});

Also missing: test for reference_audio_too_long (duration > 60s with enhance_reference_audio: true) and reference_audio_too_large (buffer > 25 MB).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new external voice-cloning API endpoint (POST /api/v1/clone) with provider routing (Mistral Voxtral vs Replicate), optional reference-audio enhancement, and billing/pricing + OpenAPI/docs updates to expose the feature in the product and documentation.

Changes:

  • Implement POST /api/v1/clone route with validation, provider execution, storage upload, and usage-event + credit deduction logic.
  • Add core cloning utilities/service code (audio normalization, locale/provider resolution, enhancement billing helpers).
  • Extend external API pricing + OpenAPI schemas/docs and add unit/integration tests.

Reviewed changes

Copilot reviewed 18 out of 18 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
apps/web/app/api/v1/clone/route.ts New external voice cloning endpoint orchestration (auth, rate limit, validation, cloning, storage, billing).
apps/web/lib/clone/clone-service.ts Core cloning utilities/provider clients (Mistral/Replicate), audio normalization, and enhancement billing helpers.
apps/web/lib/api/schemas.ts Adds request/response schemas for /api/v1/clone.
apps/web/lib/api/openapi.ts Exposes /api/v1/clone in OpenAPI and bumps API version/description.
apps/web/lib/api/pricing.ts Adds pricing entries/provider typing for voice cloning (Mistral + Replicate).
apps/web/lib/api/logger.ts Extends log fields to include referenceAudioEnhanced.
apps/web/tests/api-v1-clone.test.ts Integration tests for /api/v1/clone behavior and billing scenarios.
apps/web/tests/clone-service.test.ts Unit tests for clone-service helpers/validation logic.
apps/web/tests/api-pricing.test.ts Updates pricing tests for new cloning pricing rules.
apps/web/app/[lang]/(dashboard)/dashboard/api-keys/api-keys.tsx Adds dashboard link/card for the new clone endpoint docs.
apps/web/messages/en.json UI copy update + formatting tweak.
apps/web/messages/da.json Adds localized “Clone” feature card strings.
apps/web/messages/de.json Adds localized “Clone” feature card strings.
apps/web/messages/es.json Adds localized “Clone” feature card strings.
apps/web/messages/fr.json Adds localized “Clone” feature card strings.
apps/web/messages/it.json Adds localized “Clone” feature card strings.
apps/docs/content/docs/api-reference/endpoints/api/v1/clone/post.mdx Generated endpoint doc page for /api/v1/clone.
apps/docs/content/docs/api-reference/api.mdx Adds human-authored docs/examples for voice cloning.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +160 to +170
const buffer = Buffer.from(base64, 'base64');
if (buffer.length === 0) {
throw new CloneServiceError(
'unsupported_audio_format',
'reference_audio is not valid base64-encoded audio.',
{ param: 'reference_audio' },
);
}
const mimeType =
data.reference_audio_format?.split(';')[0]?.trim() || 'audio/wav';
return { buffer, mimeType, filename: 'reference-audio' };
Comment thread apps/web/app/api/v1/clone/route.ts Outdated
Comment on lines +114 to +116
if (data.reference_audio_url) {
const response = await fetch(data.reference_audio_url);
if (!response.ok) {
Comment on lines +544 to +549
createApiError({
message: error.message,
type: mapping.type,
code: mapping.code,
param: mapping.param ?? null,
}),
Comment on lines +298 to +310
if (
processed.duration !== null &&
processed.duration > REFERENCE_AUDIO_ENHANCEMENT_MAX_DURATION
) {
return respond(
createApiError({
message: `Reference audio enhancement supports clips up to ${REFERENCE_AUDIO_ENHANCEMENT_MAX_DURATION} seconds`,
type: 'invalid_request_error',
code: 'reference_audio_too_long',
param: 'reference_audio',
}),
{ status: 400 },
);
Comment on lines +312 to +323
if (
processed.buffer.length > REFERENCE_AUDIO_ENHANCEMENT_MAX_INPUT_BYTES
) {
return respond(
createApiError({
message: 'Reference audio enhancement input exceeds size limit',
type: 'invalid_request_error',
code: 'reference_audio_too_large',
param: 'reference_audio',
}),
{ status: 400 },
);
Comment on lines +296 to +300
throw new CloneServiceError(
'audio_conversion_failed',
'Failed to convert audio format to WAV. Reference audio must be MP3, OGG, Opus, or WAV.',
{ mimeType: normalizedMimeType },
);

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 76f77b0107

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread apps/web/app/api/v1/clone/route.ts Outdated
reference_audio_format?: string;
}): Promise<{ buffer: Buffer; mimeType: string; filename: string }> {
if (data.reference_audio_url) {
const response = await fetch(data.reference_audio_url);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block private reference-audio URL fetches

When an API key holder supplies reference_audio_url, this server-side fetch will request any URL before audio validation, including localhost/link-local/private-network hosts such as http://169.254.169.254/... or internal services. Because the endpoint returns distinguishable status/timing/errors, this opens the new clone API to SSRF and internal endpoint scanning; restrict downloads to public https? targets, block private/link-local/localhost addresses and redirects to them, and add a bounded timeout.

Useful? React with 👍 / 👎.

claude added 2 commits May 31, 2026 08:32
The docs site generates endpoint reference pages from the live production
OpenAPI document (https://sexyvoice.ai/api/v1/openapi) at build time. Since
/api/v1/clone is not yet deployed to production, the hand-committed APIPage
for that operation could not be resolved and failed Next.js page-data
collection, breaking the sexyvoice-docs deployment.

The page will be regenerated automatically by 'generate-openapi-docs' once
the endpoint is live in production. The prose docs and examples in api.mdx
remain.

https://claude.ai/code/session_01JEyH3uDy8xL49NnUygrDqz
Address security and correctness feedback on the new voice cloning endpoint:

- Billing: verify credit balance BEFORE invoking the paid reference-audio
  enhancement, so a user with insufficient credits can no longer force the
  billable enhancement call that then 402s.
- SSRF: validate reference_audio_url (http/https only, no credentials, reject
  loopback/link-local/private/CGNAT/reserved IPs via DNS resolution),
  disallow redirects, and add a fetch timeout.
- OOM: stream the URL download and abort once a hard byte cap is exceeded,
  instead of buffering the whole body via arrayBuffer(); enforce the same
  size cap on the base64 path.
- Map CloneServiceError.details.param to the response so URL errors are
  attributed to reference_audio_url (not reference_audio).
- Log the enhancement too-long/too-large 400 rejections for parity with
  other error paths.
- Use a WebM-specific conversion error message (parity with the internal
  clone route) and node:crypto createHash for buffer hashing.

Adds tests for the SSRF rejection and the credits-before-enhancement guard.

https://claude.ai/code/session_01JEyH3uDy8xL49NnUygrDqz
@argos-ci

argos-ci Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
default (Inspect) ⚠️ Changes detected (Review) 1 changed Jul 7, 2026, 4:45 PM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants