feat(voice/tts): route ElevenLabs through HTTP proxy for non-RU egress
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:
Cosmo
2026-04-23 13:00:55 +00:00
parent a780fc7bd5
commit c29da75c19
3 changed files with 80 additions and 26 deletions

View File

@@ -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,37 +94,30 @@ 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({
text,
model_id: model,
voice_settings: {
stability: 0.45,
similarity_boost: 0.75,
style: 0.25,
use_speaker_boost: true,
},
}),
}
)
const payload = JSON.stringify({
text,
model_id: model,
voice_settings: {
stability: 0.45,
similarity_boost: 0.75,
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',