feat(voice/tts): route ElevenLabs through HTTP proxy for non-RU egress
All checks were successful
Deploy / deploy (push) Successful in 4m3s
All checks were successful
Deploy / deploy (push) Successful in 4m3s
ElevenLabs Cloudflare returns 302 to a region-restricted help page when requested from a Russian IP. Tablet host (.60) is in RU, so the Stage 2 call was failing with 502 upstream. Fix: use https-proxy-agent when ELEVENLABS_PROXY (or generic HTTPS_PROXY / HTTP_PROXY) env var is set. Tinyproxy on .103 (non-RU egress host) acts as the tunnel. - package.json: add https-proxy-agent ^7.0.6 - app/api/voice/tts: switch from global fetch to node:https with explicit Agent (either direct or HttpsProxyAgent). Still streams MP3 back via Readable.toWeb so Next.js Response pipes it to the browser as audio arrives. Operational: set ELEVENLABS_PROXY=http://192.168.31.103:8888 in tablet.env after bringing tinyproxy up on .103.
This commit is contained in:
@@ -2,15 +2,74 @@ export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import * as https from 'node:https'
|
||||
import { Readable } from 'node:stream'
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent'
|
||||
|
||||
const ELEVENLABS_BASE = 'https://api.elevenlabs.io/v1'
|
||||
const ELEVENLABS_HOST = 'api.elevenlabs.io'
|
||||
const DEFAULT_MODEL = 'eleven_flash_v2_5'
|
||||
|
||||
// ElevenLabs blocks Russian IPs at Cloudflare level; route via non-RU proxy
|
||||
// (set ELEVENLABS_PROXY=http://192.168.31.103:8888 in tablet.env).
|
||||
// Falls back to HTTPS_PROXY / HTTP_PROXY if set.
|
||||
function getProxyAgent(): HttpsProxyAgent<string> | null {
|
||||
const proxy =
|
||||
process.env.ELEVENLABS_PROXY ||
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.HTTP_PROXY ||
|
||||
''
|
||||
return proxy ? new HttpsProxyAgent(proxy) : null
|
||||
}
|
||||
|
||||
function getVoiceId(agent: string | undefined): string | null {
|
||||
if (agent === 'lusya') return process.env.LUSYA_TTS_VOICE || null
|
||||
return process.env.COSMO_TTS_VOICE || null
|
||||
}
|
||||
|
||||
interface UpstreamResult {
|
||||
status: number
|
||||
body: Readable | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
function requestElevenLabs(voiceId: string, payload: string, apiKey: string): Promise<UpstreamResult> {
|
||||
return new Promise((resolve) => {
|
||||
const options: https.RequestOptions = {
|
||||
host: ELEVENLABS_HOST,
|
||||
path: `/v1/text-to-speech/${encodeURIComponent(voiceId)}/stream?output_format=mp3_44100_64`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xi-api-key': apiKey,
|
||||
Accept: 'audio/mpeg',
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload).toString(),
|
||||
},
|
||||
timeout: 30000,
|
||||
}
|
||||
const proxyAgent = getProxyAgent()
|
||||
if (proxyAgent) options.agent = proxyAgent
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
if ((res.statusCode || 0) !== 200) {
|
||||
let errText = ''
|
||||
res.setEncoding('utf8')
|
||||
res.on('data', (chunk: string) => { errText += chunk })
|
||||
res.on('end', () =>
|
||||
resolve({ status: res.statusCode || 0, body: null, error: errText.slice(0, 500) })
|
||||
)
|
||||
return
|
||||
}
|
||||
resolve({ status: 200, body: res })
|
||||
})
|
||||
|
||||
req.on('timeout', () => req.destroy(new Error('elevenlabs_timeout')))
|
||||
req.on('error', (e) => resolve({ status: 0, body: null, error: e.message }))
|
||||
|
||||
req.write(payload)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const apiKey = process.env.ELEVENLABS_API_KEY
|
||||
if (!apiKey) {
|
||||
@@ -35,16 +94,7 @@ export async function POST(req: Request) {
|
||||
|
||||
const model = process.env.ELEVENLABS_MODEL || DEFAULT_MODEL
|
||||
|
||||
const upstream = await fetch(
|
||||
`${ELEVENLABS_BASE}/text-to-speech/${encodeURIComponent(voiceId)}/stream?output_format=mp3_44100_64`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xi-api-key': apiKey,
|
||||
Accept: 'audio/mpeg',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
const payload = JSON.stringify({
|
||||
text,
|
||||
model_id: model,
|
||||
voice_settings: {
|
||||
@@ -53,19 +103,21 @@ export async function POST(req: Request) {
|
||||
style: 0.25,
|
||||
use_speaker_boost: true,
|
||||
},
|
||||
}),
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
if (!upstream.ok || !upstream.body) {
|
||||
const errText = await upstream.text().catch(() => '')
|
||||
const result = await requestElevenLabs(voiceId, payload, apiKey)
|
||||
|
||||
if (result.status !== 200 || !result.body) {
|
||||
return NextResponse.json(
|
||||
{ error: `elevenlabs_${upstream.status}`, detail: errText.slice(0, 300) },
|
||||
{ error: `elevenlabs_${result.status || 'fetch_failed'}`, detail: result.error || '' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(upstream.body, {
|
||||
// Node Readable -> Web ReadableStream for Next.js Response
|
||||
const webStream = Readable.toWeb(result.body) as unknown as ReadableStream<Uint8Array>
|
||||
|
||||
return new Response(webStream, {
|
||||
headers: {
|
||||
'Content-Type': 'audio/mpeg',
|
||||
'Cache-Control': 'no-store',
|
||||
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.1.7",
|
||||
"googleapis": "^171.4.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"lucide-react": "^0.376.0",
|
||||
"next": "14.2.3",
|
||||
"react": "^18",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.1.7",
|
||||
"googleapis": "^171.4.0",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"lucide-react": "^0.376.0",
|
||||
"next": "14.2.3",
|
||||
"react": "^18",
|
||||
|
||||
Reference in New Issue
Block a user