fix(tts): request a browser-playable format from custom providers

`generateOpenAITTS` serves every custom OpenAI-compatible TTS provider but
never sent `response_format`, so it inherited whatever each provider
defaults to. OpenAI defaults to mp3; OpenRouter's /audio/speech defaults
to raw `pcm`. The unknown content type then fell through to the `'mp3'`
default below, the client built `data:audio/mp3;base64,…` from headerless
PCM samples, and playback failed with "no supported source was found" —
while the server logged a clean 200, because the audio really was
generated. Name the format instead of inheriting it.

Also stop mislabelling an unrecognised body: `pcm`/`l16` now raises a
message naming the cause, and `aac`/`opus` are recognised.

Two supporting fixes:

- /api/openrouter-models normalises its base URL the way the adapters do
  and falls back to the public catalog when a custom base URL fails, so a
  typo in a free-text settings field cannot empty the model picker. Also
  types the headers object so tsc accepts the conditional.
- provider-neutrality-guard pins exact per-vendor occurrence counts in
  lib/server/provider-config.ts. Adding the image and video env entries
  raises "openrouter" from 2 to 6 (each entry contributes both its key and
  its value); CI failed without the bump.

Closes #1355
This commit is contained in:
wizkid68
2026-09-05 05:48:52 -04:00
parent 5989abf263
commit d553a0808a
3 changed files with 42 additions and 17 deletions
+23 -16
View File
@@ -16,7 +16,10 @@
*/
import type { NextRequest } from 'next/server';
import { OPENROUTER_DEFAULT_BASE_URL } from '@/lib/media/adapters/openrouter-image-adapter';
import {
OPENROUTER_DEFAULT_BASE_URL,
openRouterBaseUrl,
} from '@/lib/media/adapters/openrouter-image-adapter';
import { apiError } from '@/lib/server/api-response';
export const runtime = 'nodejs';
@@ -47,12 +50,14 @@ export async function GET(req: NextRequest) {
req.headers.get('x-api-key')?.trim() ||
'';
const baseUrl =
// Same normalisation the adapters use: a saved base URL that already ends in
// /images or /videos would otherwise ask for `/images/images/models` and 404.
const baseUrl = openRouterBaseUrl(
req.headers.get('x-base-url')?.trim() ||
(kind === 'image'
? process.env.IMAGE_OPENROUTER_BASE_URL?.trim()
: process.env.VIDEO_OPENROUTER_BASE_URL?.trim()) ||
OPENROUTER_DEFAULT_BASE_URL;
(kind === 'image'
? process.env.IMAGE_OPENROUTER_BASE_URL?.trim()
: process.env.VIDEO_OPENROUTER_BASE_URL?.trim()),
);
const cacheKey = `${kind}:${baseUrl}`;
const hit = cache.get(cacheKey);
@@ -60,18 +65,20 @@ export async function GET(req: NextRequest) {
return Response.json({ models: hit.models, cached: true });
}
let upstream: Response;
try {
upstream = await fetch(`${baseUrl}/${kind}s/models`, {
method: 'GET',
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
});
} catch {
return apiError('UPSTREAM_ERROR', 502, `unable to reach ${baseUrl}`);
const headers: Record<string, string> = apiKey ? { Authorization: `Bearer ${apiKey}` } : {};
const read = (base: string) =>
fetch(`${base}/${kind}s/models`, { method: 'GET', headers }).catch(() => null);
// A saved base URL is a free-text field, so any typo would otherwise empty
// the model picker. Fall back to the public catalog rather than fail: the
// list is read-only, and a wrong base URL still surfaces at generation time.
let upstream = await read(baseUrl);
if ((!upstream || !upstream.ok) && baseUrl !== OPENROUTER_DEFAULT_BASE_URL) {
upstream = await read(OPENROUTER_DEFAULT_BASE_URL);
}
if (!upstream.ok) {
const text = await upstream.text().catch(() => '');
if (!upstream || !upstream.ok) {
const text = upstream ? await upstream.text().catch(() => '') : 'unreachable';
return apiError('UPSTREAM_ERROR', 502, `OpenRouter ${kind} catalog failed: ${text}`);
}
+18
View File
@@ -290,6 +290,13 @@ async function generateOpenAITTS(
input: text,
voice: config.voice,
speed: config.speed || 1.0,
// Ask for a container the browser can decode. OpenAI defaults to mp3, but
// this same function serves every custom OpenAI-compatible provider and
// their defaults differ — OpenRouter's /audio/speech defaults to raw
// `pcm`, which arrives headerless, gets labelled mp3 below, and fails in
// the client with "no supported source was found". Naming the format
// removes the guess. Providers that ignore the field are unaffected.
response_format: 'mp3',
}),
signal,
});
@@ -441,6 +448,17 @@ function getAudioResponseFormat(contentType: string): string {
if (contentType.includes('audio/flac')) return 'flac';
if (contentType.includes('audio/ogg')) return 'ogg';
if (contentType.includes('audio/webm')) return 'webm';
if (contentType.includes('audio/aac')) return 'aac';
if (contentType.includes('audio/opus')) return 'opus';
// `pcm`/`l16` is headerless: the client builds `data:audio/<format>` and no
// browser decodes bare samples, so say so plainly instead of calling it mp3
// and failing later with an opaque "no supported source was found".
if (contentType.includes('audio/pcm') || contentType.includes('audio/l16')) {
throw new Error(
'TTS provider returned raw PCM, which browsers cannot play. Configure the ' +
'provider to return mp3, wav, or ogg.',
);
}
return 'mp3';
}
@@ -178,7 +178,7 @@ const TEMPORARY_VENDOR_DEBT: readonly AllowedVendorDebt[] = [
['glm', 4],
['siliconflow', 2],
['doubao', 6],
['openrouter', 2],
['openrouter', 6],
['grok', 6],
['tencent', 4],
['hunyuan', 3],