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'
|
export const runtime = 'nodejs'
|
||||||
|
|
||||||
import { NextResponse } from 'next/server'
|
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'
|
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 {
|
function getVoiceId(agent: string | undefined): string | null {
|
||||||
if (agent === 'lusya') return process.env.LUSYA_TTS_VOICE || null
|
if (agent === 'lusya') return process.env.LUSYA_TTS_VOICE || null
|
||||||
return process.env.COSMO_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) {
|
export async function POST(req: Request) {
|
||||||
const apiKey = process.env.ELEVENLABS_API_KEY
|
const apiKey = process.env.ELEVENLABS_API_KEY
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -35,16 +94,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const model = process.env.ELEVENLABS_MODEL || DEFAULT_MODEL
|
const model = process.env.ELEVENLABS_MODEL || DEFAULT_MODEL
|
||||||
|
|
||||||
const upstream = await fetch(
|
const payload = JSON.stringify({
|
||||||
`${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({
|
|
||||||
text,
|
text,
|
||||||
model_id: model,
|
model_id: model,
|
||||||
voice_settings: {
|
voice_settings: {
|
||||||
@@ -53,19 +103,21 @@ export async function POST(req: Request) {
|
|||||||
style: 0.25,
|
style: 0.25,
|
||||||
use_speaker_boost: true,
|
use_speaker_boost: true,
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!upstream.ok || !upstream.body) {
|
const result = await requestElevenLabs(voiceId, payload, apiKey)
|
||||||
const errText = await upstream.text().catch(() => '')
|
|
||||||
|
if (result.status !== 200 || !result.body) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: `elevenlabs_${upstream.status}`, detail: errText.slice(0, 300) },
|
{ error: `elevenlabs_${result.status || 'fetch_failed'}`, detail: result.error || '' },
|
||||||
{ status: 502 }
|
{ 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: {
|
headers: {
|
||||||
'Content-Type': 'audio/mpeg',
|
'Content-Type': 'audio/mpeg',
|
||||||
'Cache-Control': 'no-store',
|
'Cache-Control': 'no-store',
|
||||||
|
|||||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^11.1.7",
|
"framer-motion": "^11.1.7",
|
||||||
"googleapis": "^171.4.0",
|
"googleapis": "^171.4.0",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
"lucide-react": "^0.376.0",
|
"lucide-react": "^0.376.0",
|
||||||
"next": "14.2.3",
|
"next": "14.2.3",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^11.1.7",
|
"framer-motion": "^11.1.7",
|
||||||
"googleapis": "^171.4.0",
|
"googleapis": "^171.4.0",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
"lucide-react": "^0.376.0",
|
"lucide-react": "^0.376.0",
|
||||||
"next": "14.2.3",
|
"next": "14.2.3",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
|
|||||||
Reference in New Issue
Block a user