Files

164 lines
5.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 提供商定义 —— 所有差异收敛在这里的开关上。
*
* 下面的取值来自实测(备份项目 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);
}