164 lines
5.7 KiB
JavaScript
164 lines
5.7 KiB
JavaScript
/**
|
||
* 提供商定义 —— 所有差异收敛在这里的开关上。
|
||
*
|
||
* 下面的取值来自实测(备份项目 AGENTS.md 2026-09-16 矩阵),不是文档推测。
|
||
* 新增提供商时必须补全每个开关,漏一个就等于关掉了对应的护栏或裁剪:
|
||
* verboseJson / timestamps / language / temperature → 传给接口的字段裁剪
|
||
* maxBytes / maxSeconds → 提交前护栏
|
||
* rejectFormats → 需要先转码的容器
|
||
*/
|
||
|
||
/** 源文件扩展名 → 实际容器族。转码/切块后只会是 wav 或 mp3。 */
|
||
export const FORMAT_FAMILY = {
|
||
wav: 'wav', mp3: 'mp3', m4a: 'mp4', mp4: 'mp4', aac: 'mp4',
|
||
aiff: 'aiff', aif: 'aiff', caf: 'caf', wma: 'wma',
|
||
ogg: 'ogg', oga: 'ogg', opus: 'ogg', flac: 'flac',
|
||
webm: 'webm', amr: 'amr', '3gp': 'mp4', m4b: 'mp4',
|
||
};
|
||
|
||
export const PROVIDERS = {
|
||
siliconflow: {
|
||
name: 'siliconflow',
|
||
label: '硅基流动',
|
||
endpoint: 'https://api.siliconflow.cn/v1/audio/transcriptions',
|
||
models: ['FunAudioLLM/SenseVoiceSmall'],
|
||
defaultModel: 'FunAudioLLM/SenseVoiceSmall',
|
||
keyUrl: 'https://cloud.siliconflow.cn/account/ak',
|
||
note: '仅接受 file+model,只返回纯文本,无时间戳',
|
||
hint: '硅基流动只接受 file + model 两个字段(多传 language / response_format 会 400),'
|
||
+ '响应只有 {"text": ...},没有时间戳。上限 50MB / 1 小时,超限自动切块。',
|
||
|
||
// ---- 能力开关:false 的字段绝不发出去 ----
|
||
caps: {
|
||
verboseJson: false,
|
||
timestamps: false,
|
||
language: false,
|
||
temperature: false,
|
||
},
|
||
|
||
// ---- 护栏 ----
|
||
maxBytes: 48 * 1024 * 1024, // 官方 50MB,留余量
|
||
/**
|
||
* 官方文档写的是 1 小时 / 50MB,但实测不是这样:
|
||
* 37 分钟 / 36MB 的音频处理约 225 秒后,上游返回**空响应体的 500**
|
||
* (两次实测 224s / 226s,耗时高度稳定 → 是确定性的资源上限,不是随机过载)。
|
||
*
|
||
* 所以这里设成 20 分钟:留足余量,避免用户上传什么都被上游在 225 秒处掐断。
|
||
* 超出的部分由 ffmpeg 切块后逐块提交,对用户透明。
|
||
*/
|
||
maxSeconds: 1200,
|
||
|
||
// ---- 实测在硅基流动稳定 400 的容器,必须先转码成 wav ----
|
||
rejectFormats: ['mp4', 'aiff', 'caf', 'wma', 'amr'],
|
||
|
||
buildForm(fd, chunk, model) {
|
||
// 顺序固定 file → model
|
||
fd.append('file', chunk.blob, chunk.name);
|
||
fd.append('model', model);
|
||
},
|
||
},
|
||
|
||
openrouter: {
|
||
name: 'openrouter',
|
||
label: 'OpenRouter',
|
||
endpoint: 'https://openrouter.ai/api/v1/audio/transcriptions',
|
||
models: [
|
||
'openai/whisper-large-v3-turbo',
|
||
'openai/whisper-large-v3',
|
||
'openai/whisper-1',
|
||
],
|
||
defaultModel: 'openai/whisper-large-v3-turbo',
|
||
keyUrl: 'https://openrouter.ai/settings/keys',
|
||
note: 'multipart 硬上限 25MB,上游 60s 超时;base64 JSON 实测 403 不可用',
|
||
hint: 'OpenRouter 走原生 multipart 转写。base64 JSON 路径实测 403(连 31KB 小文件也是),'
|
||
+ '所以只能用 multipart:25MB 硬上限 + 上游 60s 超时。超限自动切块。',
|
||
|
||
caps: {
|
||
verboseJson: true,
|
||
timestamps: true,
|
||
language: true,
|
||
temperature: true,
|
||
},
|
||
|
||
maxBytes: 25 * 1024 * 1024,
|
||
// 上游 60s 超时才是真实瓶颈:33 分钟实测 18.6s,再长就撞超时
|
||
maxSeconds: 1800,
|
||
|
||
rejectFormats: [],
|
||
|
||
/** turbo 系列才请求 verbose_json,非 turbo 给了会 400 */
|
||
formOpts(model) {
|
||
return { verbose: /turbo/i.test(model) };
|
||
},
|
||
|
||
buildForm(fd, chunk, model, opts) {
|
||
fd.append('file', chunk.blob, chunk.name);
|
||
fd.append('model', model);
|
||
if (opts && opts.verbose) {
|
||
fd.append('response_format', 'verbose_json');
|
||
// 只要 segment:逐字时间戳响应体太大,合并时用不到
|
||
fd.append('timestamp_granularities[]', 'segment');
|
||
}
|
||
},
|
||
},
|
||
};
|
||
|
||
export const PROVIDER_NAMES = Object.keys(PROVIDERS);
|
||
export const DEFAULT_PROVIDER = 'siliconflow';
|
||
|
||
/**
|
||
* 允许部署时用环境变量覆盖端点(自测会把它指到本地 mock 上游)。
|
||
* 只覆盖端点,能力开关与护栏不受影响。
|
||
*/
|
||
const BASE_URL_ENV = {
|
||
openrouter: 'OPENROUTER_BASE_URL',
|
||
siliconflow: 'SILICONFLOW_BASE_URL',
|
||
};
|
||
|
||
function applyOverrides() {
|
||
for (const [name, envKey] of Object.entries(BASE_URL_ENV)) {
|
||
const base = process.env[envKey];
|
||
if (!base) continue;
|
||
const provider = PROVIDERS[name];
|
||
provider.endpoint = `${String(base).replace(/\/+$/, '')}/audio/transcriptions`;
|
||
}
|
||
}
|
||
applyOverrides();
|
||
|
||
export function getProvider(name) {
|
||
const p = PROVIDERS[name];
|
||
if (!p) {
|
||
const err = new Error(`未知提供商 ${name},可选: ${PROVIDER_NAMES.join(', ')}`);
|
||
err.status = 400;
|
||
throw err;
|
||
}
|
||
return p;
|
||
}
|
||
|
||
/**
|
||
* 创建表单数据。按能力开关裁剪字段——不支持的字段一个都不发。
|
||
* @returns {{ form: FormData, opts: object }}
|
||
*/
|
||
export function buildRequest(providerName, chunk, model, language) {
|
||
const p = getProvider(providerName);
|
||
const fd = new FormData();
|
||
const opts = p.formOpts ? p.formOpts(model) : {};
|
||
|
||
p.buildForm(fd, chunk, model, opts);
|
||
|
||
// 这些字段硅基流动一个都不接受,必须按开关发
|
||
if (p.caps.language && language && language !== 'auto') {
|
||
fd.append('language', language);
|
||
}
|
||
if (p.caps.temperature) {
|
||
fd.append('temperature', '0');
|
||
}
|
||
return { form: fd, opts };
|
||
}
|
||
|
||
/** 是否需要先转码再上传(容器不被该提供商接受) */
|
||
export function needsTranscode(providerName, formatFamily) {
|
||
const p = getProvider(providerName);
|
||
return p.rejectFormats.includes(formatFamily);
|
||
}
|