mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-03 09:48:24 +08:00
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:
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user