From d583490bade8c10ced5b6591cb5f1365b3809acb Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 26 Oct 2026 17:52:46 -0700 Subject: [PATCH] feat(litellm): add native Beijing Bailian ASR and personal vocabulary --- servers/bwh-hk-cmi/litellm/.gitignore | 1 + .../litellm/bailian_transcription.py | 119 ++++++++++++++++++ servers/bwh-hk-cmi/litellm/compose.yaml | 3 + servers/bwh-hk-cmi/litellm/provider_routes.py | 19 ++- servers/bwh-hk-cmi/litellm/render_config.py | 4 +- servers/bwh-hk-cmi/litellm/test_config.py | 8 ++ .../bwh-hk-cmi/litellm/vertex_dictation.py | 2 + servers/bwh-hk-cmi/litellm/百炼北京ASR.md | 24 ++++ servers/tools-pve/litellm/.env.example | 2 + .../litellm/bailian_transcription.py | 119 ++++++++++++++++++ servers/tools-pve/litellm/compose.yaml | 3 + .../litellm/personal_transcription.py | 2 + servers/tools-pve/litellm/render_config.py | 19 ++- servers/tools-pve/litellm/test_config.py | 12 ++ servers/tools-pve/litellm/百炼北京ASR.md | 24 ++++ 15 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 servers/bwh-hk-cmi/litellm/.gitignore create mode 100644 servers/bwh-hk-cmi/litellm/bailian_transcription.py create mode 100644 servers/bwh-hk-cmi/litellm/百炼北京ASR.md create mode 100644 servers/tools-pve/litellm/bailian_transcription.py create mode 100644 servers/tools-pve/litellm/百炼北京ASR.md diff --git a/servers/bwh-hk-cmi/litellm/.gitignore b/servers/bwh-hk-cmi/litellm/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/servers/bwh-hk-cmi/litellm/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/servers/bwh-hk-cmi/litellm/bailian_transcription.py b/servers/bwh-hk-cmi/litellm/bailian_transcription.py new file mode 100644 index 0000000..cda1339 --- /dev/null +++ b/servers/bwh-hk-cmi/litellm/bailian_transcription.py @@ -0,0 +1,119 @@ +"""Bridge OpenAI file uploads to Beijing Bailian's native ASR API.""" +import base64 +import io +import json +from pathlib import Path +from urllib.parse import urlsplit +import wave + +from litellm.llms.base_llm.audio_transcription.transformation import AudioTranscriptionRequestData +from litellm.llms.openai.transcriptions.whisper_transformation import OpenAIWhisperAudioTranscriptionConfig +from litellm.litellm_core_utils.audio_utils.utils import process_audio_file +from litellm.types.utils import TranscriptionResponse, TranscriptionUsageDurationObject + +MODEL = 'qwen-audio-3.1-asr-flash' +FORMATS = {'audio/wav':'wav','audio/x-wav':'wav','audio/wave':'wav', + 'audio/mpeg':'mp3','audio/mp3':'mp3','audio/flac':'flac','audio/x-flac':'flac', + 'audio/mp4':'m4a','audio/m4a':'m4a','audio/x-m4a':'m4a', + 'audio/ogg':'ogg','audio/webm':'webm','video/webm':'webm','audio/aac':'aac'} +VOCABULARY_PATHS = frozenset({'/etc/litellm/personal-vocabulary.json', + '/credentials/personal-vocabulary.json'}) + + +def vocabulary(path): + if path not in VOCABULARY_PATHS: + raise ValueError('Unexpected personal vocabulary path') + words = json.loads(Path(path).read_text()) + if not isinstance(words,list) or not 1 <= len(words) <= 100: + raise ValueError('Personal vocabulary must contain 1-100 terms') + if any(not isinstance(w,str) or not w.strip() or len(w)>100 for w in words): + raise ValueError('Invalid personal vocabulary term') + return {w.strip():3 for w in words} + + +class BailianTranscriptionConfig(OpenAIWhisperAudioTranscriptionConfig): + @property + def has_native_transcription_endpoint(self): + return True + + def get_supported_openai_params(self,model): + return ['language','response_format','prompt'] + + def map_openai_params(self,non_default_params,optional_params,model,drop_params): + result=super().map_openai_params(non_default_params,optional_params,model,drop_params) + if result.get('response_format') not in (None,'json','text'): + raise ValueError('Bailian dictation supports json/text without timestamps') + return result + + def validate_environment(self,headers,model,messages,optional_params,litellm_params, + api_key=None,api_base=None): + if not api_key:raise ValueError('Beijing Bailian API key required') + return {**headers,'Authorization':'Bearer '+api_key,'Content-Type':'application/json', + 'X-DashScope-SSE':'disable'} + + def get_complete_url(self,api_base,api_key,model,optional_params,litellm_params,stream=None): + parsed=urlsplit(api_base or '') + if (model != MODEL or parsed.scheme != 'https' or + not (parsed.hostname or '').endswith('.cn-beijing.maas.aliyuncs.com') or + parsed.path.rstrip('/') != '/api/v1' or parsed.port not in (None,443) or + parsed.username or parsed.password or parsed.query or parsed.fragment): + raise ValueError('Bailian ASR restricted to Beijing workspace native endpoints') + if stream:raise ValueError('Streaming not enabled on this dictation route') + return api_base.rstrip('/')+'/services/aigc/multimodal-generation/generation' + + def transform_audio_transcription_request(self,model,audio_file,optional_params,litellm_params): + if model != MODEL or optional_params.get('timestamp_granularities'): + raise ValueError('Unsupported Bailian dictation model or timestamps') + audio=process_audio_file(audio_file) + mime=audio.content_type.split(';',1)[0].lower() + if mime not in FORMATS:raise ValueError('Unsupported Bailian audio MIME type') + if len(audio.file_content)>10_000_000:raise ValueError('Bailian inline audio exceeds 10 MB') + parameters={'format':FORMATS[mime]} + if parameters['format']=='wav': + with wave.open(io.BytesIO(audio.file_content),'rb') as wav: + parameters['sample_rate']=str(wav.getframerate()) + language=optional_params.get('language') + if language:parameters['language_hints']=[language] + # LiteLLM classifies DashScope as OpenAI-compatible and nests custom kwargs. + extra=optional_params.get('extra_body') or {} + path=optional_params.get('bailian_vocabulary_path') or extra.get('bailian_vocabulary_path') + if path:parameters['vocabulary']=vocabulary(path) + prompt=optional_params.get('prompt') + messages=[] + if prompt: + if not isinstance(prompt,str) or len(prompt)>400: + raise ValueError('Bailian context prompt must be at most 400 characters') + messages.append({'role':'user','content':[{'type':'input_text','text':prompt}]}) + # Always preserve the original bytes; only WAV metadata supplies sample rate. + uri_mime='audio/mp3' if parameters['format']=='mp3' else mime + data_uri='data:'+uri_mime+';base64,'+base64.b64encode(audio.file_content).decode() + if len(data_uri)>10_000_000: + raise ValueError('Bailian inline audio exceeds 10 MB after Base64 encoding') + messages.append({'role':'user','content':[{'type':'input_audio','input_audio':{ + 'data':data_uri}}]}) + return AudioTranscriptionRequestData(data={'model':MODEL,'input':{'messages':messages}, + 'parameters':parameters}) + + def transform_audio_transcription_response(self,raw_response): + body=raw_response.json() + text=body.get('output',{}).get('text',body.get('text')) + if not isinstance(text,str):raise ValueError('Bailian returned no transcript') + result=TranscriptionResponse(text=text) + duration=(body.get('usage') or {}).get('duration') + if duration is not None: + result.usage=TranscriptionUsageDurationObject(type='duration',seconds=duration) + result['duration']=duration + return result + + +def install(): + import litellm + from litellm.utils import ProviderConfigManager + original=ProviderConfigManager.get_provider_audio_transcription_config + if getattr(original,'_bailian_asr_adapter',False):return + def select(model,provider): + if provider==litellm.LlmProviders.DASHSCOPE and model==MODEL: + return BailianTranscriptionConfig() + return original(model,provider) + select._bailian_asr_adapter=True + ProviderConfigManager.get_provider_audio_transcription_config=staticmethod(select) diff --git a/servers/bwh-hk-cmi/litellm/compose.yaml b/servers/bwh-hk-cmi/litellm/compose.yaml index be96d54..ccbe5d1 100644 --- a/servers/bwh-hk-cmi/litellm/compose.yaml +++ b/servers/bwh-hk-cmi/litellm/compose.yaml @@ -35,12 +35,15 @@ services: ZENMUX_SUBSCRIPTION_API_KEY: ${ZENMUX_SUBSCRIPTION_API_KEY:-} ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + BAILIAN_CN_API_KEY: ${BAILIAN_CN_API_KEY:-} + BAILIAN_CN_API_BASE: ${BAILIAN_CN_API_BASE:-https://ws-v68kvjnmhkiejaxo.cn-beijing.maas.aliyuncs.com/api/v1} volumes: - ./models.yaml:/etc/litellm/models.yaml:ro - ./provider_routes.py:/etc/litellm/provider_routes.py:ro - ./render_config.py:/etc/litellm/render_config.py:ro - ./vertex_dictation.py:/etc/litellm/vertex_dictation.py:ro - ./zenmux_transcription.py:/etc/litellm/zenmux_transcription.py:ro + - ./bailian_transcription.py:/etc/litellm/bailian_transcription.py:ro - google-adc:/credentials:ro entrypoint: ["/bin/sh", "-c"] command: diff --git a/servers/bwh-hk-cmi/litellm/provider_routes.py b/servers/bwh-hk-cmi/litellm/provider_routes.py index b7d9fb7..8777d1f 100644 --- a/servers/bwh-hk-cmi/litellm/provider_routes.py +++ b/servers/bwh-hk-cmi/litellm/provider_routes.py @@ -75,6 +75,22 @@ def namespace_config(config): return config +def add_bailian_routes(config, env, vocabulary_path): + if env.get('BAILIAN_CN_API_KEY'): + base = env.get('BAILIAN_CN_API_BASE') + if not base: + raise ValueError('BAILIAN_CN_API_BASE required for Beijing Bailian') + for personal in [False, True]: + params = {'model': 'dashscope/qwen-audio-3.1-asr-flash', + 'api_base': base, 'api_key': 'os.environ/BAILIAN_CN_API_KEY', + 'max_retries': 0} + if personal: + params['bailian_vocabulary_path'] = vocabulary_path + config['model_list'].append({'model_name': 'bailian-cn/qwen-audio-3.1-asr-flash' + + ('-personal' if personal else ''), 'litellm_params': params}) + return config + + def build_config(catalog, env, exists, namespaced=True): routes = [] fallbacks = [] @@ -146,7 +162,8 @@ def build_config(catalog, env, exists, namespaced=True): # Only explicitly approved open-model routes have cross-provider fallbacks. 'router_settings': {'num_retries': 1, 'timeout': 180, 'fallbacks': fallbacks}, } - return namespace_config(config) if namespaced else config + return add_bailian_routes(namespace_config(config), env, + '/etc/litellm/personal-vocabulary.json') if namespaced else config if __name__ == '__main__': diff --git a/servers/bwh-hk-cmi/litellm/render_config.py b/servers/bwh-hk-cmi/litellm/render_config.py index e5ea423..cb48a20 100644 --- a/servers/bwh-hk-cmi/litellm/render_config.py +++ b/servers/bwh-hk-cmi/litellm/render_config.py @@ -4,7 +4,7 @@ from pathlib import Path import yaml -from provider_routes import build_config, namespace_config +from provider_routes import build_config, namespace_config, add_bailian_routes def build_hk_config(catalog, env): @@ -47,7 +47,7 @@ def build_hk_config(catalog, env): 'model': 'vertex_ai/' + model, 'vertex_project': 'os.environ/VERTEXAI_PROJECT', 'vertex_location': 'os.environ/VERTEXAI_LOCATION'}}) - return namespace_config(config) + return add_bailian_routes(namespace_config(config), env, '/credentials/personal-vocabulary.json') if __name__ == '__main__': diff --git a/servers/bwh-hk-cmi/litellm/test_config.py b/servers/bwh-hk-cmi/litellm/test_config.py index f959c77..c9a7fdf 100644 --- a/servers/bwh-hk-cmi/litellm/test_config.py +++ b/servers/bwh-hk-cmi/litellm/test_config.py @@ -7,6 +7,14 @@ from render_config import build_hk_config class RoutingTests(unittest.TestCase): + def test_bailian_personal_uses_hk_private_vocabulary(self): + catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text()) + routes = build_hk_config(catalog, {'BAILIAN_CN_API_KEY':'synthetic', + 'BAILIAN_CN_API_BASE':'https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1'})['model_list'] + self.assertEqual(len(routes),2) + self.assertEqual(routes[1]['litellm_params']['bailian_vocabulary_path'], + '/credentials/personal-vocabulary.json') + def test_all_providers(self): catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text()) env = {key: 'synthetic' for key in ['ZENMUX_SUBSCRIPTION_API_KEY', diff --git a/servers/bwh-hk-cmi/litellm/vertex_dictation.py b/servers/bwh-hk-cmi/litellm/vertex_dictation.py index 51eee13..0acf551 100644 --- a/servers/bwh-hk-cmi/litellm/vertex_dictation.py +++ b/servers/bwh-hk-cmi/litellm/vertex_dictation.py @@ -46,7 +46,9 @@ if __name__ == '__main__': import sys from litellm import run_server from zenmux_transcription import install as install_zenmux_asr + from bailian_transcription import install as install_bailian_asr install_zenmux_asr() + install_bailian_asr() install() sys.argv[0] = 'litellm' sys.exit(run_server()) diff --git a/servers/bwh-hk-cmi/litellm/百炼北京ASR.md b/servers/bwh-hk-cmi/litellm/百炼北京ASR.md new file mode 100644 index 0000000..d537ac5 --- /dev/null +++ b/servers/bwh-hk-cmi/litellm/百炼北京ASR.md @@ -0,0 +1,24 @@ +# 百炼北京原厂 ASR + +2026-10-27 上线,仅北京,不配置新加坡或跨地域回退。客户端沿用本机 LiteLLM 的 /v1/audio/transcriptions、现有客户端 key、multipart 文件上传,推荐 response_format=json。 + +| 模型名 | 行为 | +| --- | --- | +| bailian-cn/qwen-audio-3.1-asr-flash | 原厂识别,无词表注入 | +| bailian-cn/qwen-audio-3.1-asr-flash-personal | 相同模型,原生 vocabulary 注入既有 20 个人名/公司名,每词权重 3 | + +上游为业务空间专属域名 `https://ws-v68kvjnmhkiejaxo.cn-beijing.maas.aliyuncs.com/api/v1`,使用原生 `/services/aigc/multimodal-generation/generation`,不是 compatible-mode。已用 .145 现有 BAILIAN_API_KEY 实测该空间可用;部署后凭据权威是各 Arcane Project 的 BAILIAN_CN_API_KEY,原始 key 不进入 Git。 + +当前 LiteLLM 1.104.2 没有 DashScope 原生转写配置,启动适配器仅接管 dashscope provider 的 qwen-audio-3.1-asr-flash。普通 Gemini、Vertex、ZenMux 和 OpenAI 不变。不增加服务、端口或镜像,不重新部署会议管线。 + +文件原字节转为 Base64 Data URI。WAV 从文件头读取采样率,非 WAV 不伪造采样率;上游可选 prompt 作为 input_text 上下文,不覆盖、不做二次 LLM 改写,最多 400 字。生产个人词表从当前节点已有私有文件读取,不把词表放到 Git。DashScope 自定义参数在 SDK 中进入 extra_body,适配器需取出私有词表路径,再生成 parameters.vocabulary;私有路径不能泄漏上游。 + +不启用 streaming、时间戳或说话人分离,不承诺自动去口语化;原厂默认识别行为不额外覆盖。上传 Base64 Data URI 保守限制 10 MB,超限明确拒绝,不自动把文件上传公网。json 为已验证的推荐响应格式。 + +配置验证:先运行每目录 test_config.py,再在固定镜像中执行 scripts/test-bailian-asr-wire.py,检查同步/异步真实 SDK 的 HTTP 请求体、热词、原字节保持及其他 provider 不受影响。 + +原厂 A/B:2026-07-21 电话前 90 秒,无热词两处“洛奇”,指定临时热词“洛启、普华”(权重 3)后两处“洛启”,耗时分别 3.432/3.649 秒。该结果只证明该片段短词表有改善,不代表已有 20 词表一定同等改善或整体 CER/WER。 + +上线后必须验证:两节点普通/个人路由公开音频、现有客户端 key 权限、词表开关和 native vocabulary 请求体。新模型不因部署便自动获得 virtual key 权限;仅为用户批准的应用增加这两条路线,不扩大到全部模型。 + +官方文档:https://help.aliyun.com/zh/model-studio/fun-asr-flash-recorded-speech-recognition-http-api diff --git a/servers/tools-pve/litellm/.env.example b/servers/tools-pve/litellm/.env.example index 481f44a..5a6a154 100644 --- a/servers/tools-pve/litellm/.env.example +++ b/servers/tools-pve/litellm/.env.example @@ -6,6 +6,8 @@ LITELLM_SALT_KEY= ZENMUX_SUBSCRIPTION_API_KEY= ZENMUX_PAYG_API_KEY= OPENROUTER_API_KEY= +BAILIAN_CN_API_KEY= +BAILIAN_CN_API_BASE=https://ws-v68kvjnmhkiejaxo.cn-beijing.maas.aliyuncs.com/api/v1 GOOGLE_AI_STUDIO_API_KEY= VERTEXAI_ENABLED=false VERTEXAI_PROJECT= diff --git a/servers/tools-pve/litellm/bailian_transcription.py b/servers/tools-pve/litellm/bailian_transcription.py new file mode 100644 index 0000000..cda1339 --- /dev/null +++ b/servers/tools-pve/litellm/bailian_transcription.py @@ -0,0 +1,119 @@ +"""Bridge OpenAI file uploads to Beijing Bailian's native ASR API.""" +import base64 +import io +import json +from pathlib import Path +from urllib.parse import urlsplit +import wave + +from litellm.llms.base_llm.audio_transcription.transformation import AudioTranscriptionRequestData +from litellm.llms.openai.transcriptions.whisper_transformation import OpenAIWhisperAudioTranscriptionConfig +from litellm.litellm_core_utils.audio_utils.utils import process_audio_file +from litellm.types.utils import TranscriptionResponse, TranscriptionUsageDurationObject + +MODEL = 'qwen-audio-3.1-asr-flash' +FORMATS = {'audio/wav':'wav','audio/x-wav':'wav','audio/wave':'wav', + 'audio/mpeg':'mp3','audio/mp3':'mp3','audio/flac':'flac','audio/x-flac':'flac', + 'audio/mp4':'m4a','audio/m4a':'m4a','audio/x-m4a':'m4a', + 'audio/ogg':'ogg','audio/webm':'webm','video/webm':'webm','audio/aac':'aac'} +VOCABULARY_PATHS = frozenset({'/etc/litellm/personal-vocabulary.json', + '/credentials/personal-vocabulary.json'}) + + +def vocabulary(path): + if path not in VOCABULARY_PATHS: + raise ValueError('Unexpected personal vocabulary path') + words = json.loads(Path(path).read_text()) + if not isinstance(words,list) or not 1 <= len(words) <= 100: + raise ValueError('Personal vocabulary must contain 1-100 terms') + if any(not isinstance(w,str) or not w.strip() or len(w)>100 for w in words): + raise ValueError('Invalid personal vocabulary term') + return {w.strip():3 for w in words} + + +class BailianTranscriptionConfig(OpenAIWhisperAudioTranscriptionConfig): + @property + def has_native_transcription_endpoint(self): + return True + + def get_supported_openai_params(self,model): + return ['language','response_format','prompt'] + + def map_openai_params(self,non_default_params,optional_params,model,drop_params): + result=super().map_openai_params(non_default_params,optional_params,model,drop_params) + if result.get('response_format') not in (None,'json','text'): + raise ValueError('Bailian dictation supports json/text without timestamps') + return result + + def validate_environment(self,headers,model,messages,optional_params,litellm_params, + api_key=None,api_base=None): + if not api_key:raise ValueError('Beijing Bailian API key required') + return {**headers,'Authorization':'Bearer '+api_key,'Content-Type':'application/json', + 'X-DashScope-SSE':'disable'} + + def get_complete_url(self,api_base,api_key,model,optional_params,litellm_params,stream=None): + parsed=urlsplit(api_base or '') + if (model != MODEL or parsed.scheme != 'https' or + not (parsed.hostname or '').endswith('.cn-beijing.maas.aliyuncs.com') or + parsed.path.rstrip('/') != '/api/v1' or parsed.port not in (None,443) or + parsed.username or parsed.password or parsed.query or parsed.fragment): + raise ValueError('Bailian ASR restricted to Beijing workspace native endpoints') + if stream:raise ValueError('Streaming not enabled on this dictation route') + return api_base.rstrip('/')+'/services/aigc/multimodal-generation/generation' + + def transform_audio_transcription_request(self,model,audio_file,optional_params,litellm_params): + if model != MODEL or optional_params.get('timestamp_granularities'): + raise ValueError('Unsupported Bailian dictation model or timestamps') + audio=process_audio_file(audio_file) + mime=audio.content_type.split(';',1)[0].lower() + if mime not in FORMATS:raise ValueError('Unsupported Bailian audio MIME type') + if len(audio.file_content)>10_000_000:raise ValueError('Bailian inline audio exceeds 10 MB') + parameters={'format':FORMATS[mime]} + if parameters['format']=='wav': + with wave.open(io.BytesIO(audio.file_content),'rb') as wav: + parameters['sample_rate']=str(wav.getframerate()) + language=optional_params.get('language') + if language:parameters['language_hints']=[language] + # LiteLLM classifies DashScope as OpenAI-compatible and nests custom kwargs. + extra=optional_params.get('extra_body') or {} + path=optional_params.get('bailian_vocabulary_path') or extra.get('bailian_vocabulary_path') + if path:parameters['vocabulary']=vocabulary(path) + prompt=optional_params.get('prompt') + messages=[] + if prompt: + if not isinstance(prompt,str) or len(prompt)>400: + raise ValueError('Bailian context prompt must be at most 400 characters') + messages.append({'role':'user','content':[{'type':'input_text','text':prompt}]}) + # Always preserve the original bytes; only WAV metadata supplies sample rate. + uri_mime='audio/mp3' if parameters['format']=='mp3' else mime + data_uri='data:'+uri_mime+';base64,'+base64.b64encode(audio.file_content).decode() + if len(data_uri)>10_000_000: + raise ValueError('Bailian inline audio exceeds 10 MB after Base64 encoding') + messages.append({'role':'user','content':[{'type':'input_audio','input_audio':{ + 'data':data_uri}}]}) + return AudioTranscriptionRequestData(data={'model':MODEL,'input':{'messages':messages}, + 'parameters':parameters}) + + def transform_audio_transcription_response(self,raw_response): + body=raw_response.json() + text=body.get('output',{}).get('text',body.get('text')) + if not isinstance(text,str):raise ValueError('Bailian returned no transcript') + result=TranscriptionResponse(text=text) + duration=(body.get('usage') or {}).get('duration') + if duration is not None: + result.usage=TranscriptionUsageDurationObject(type='duration',seconds=duration) + result['duration']=duration + return result + + +def install(): + import litellm + from litellm.utils import ProviderConfigManager + original=ProviderConfigManager.get_provider_audio_transcription_config + if getattr(original,'_bailian_asr_adapter',False):return + def select(model,provider): + if provider==litellm.LlmProviders.DASHSCOPE and model==MODEL: + return BailianTranscriptionConfig() + return original(model,provider) + select._bailian_asr_adapter=True + ProviderConfigManager.get_provider_audio_transcription_config=staticmethod(select) diff --git a/servers/tools-pve/litellm/compose.yaml b/servers/tools-pve/litellm/compose.yaml index becb5d6..1f142cc 100644 --- a/servers/tools-pve/litellm/compose.yaml +++ b/servers/tools-pve/litellm/compose.yaml @@ -37,6 +37,8 @@ services: ZENMUX_SUBSCRIPTION_API_KEY: ${ZENMUX_SUBSCRIPTION_API_KEY:-} ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + BAILIAN_CN_API_KEY: ${BAILIAN_CN_API_KEY:-} + BAILIAN_CN_API_BASE: ${BAILIAN_CN_API_BASE:-https://ws-v68kvjnmhkiejaxo.cn-beijing.maas.aliyuncs.com/api/v1} GOOGLE_AI_STUDIO_API_KEY: ${GOOGLE_AI_STUDIO_API_KEY:-} VERTEXAI_ENABLED: ${VERTEXAI_ENABLED:-false} VERTEXAI_PROJECT: ${VERTEXAI_PROJECT:-} @@ -49,6 +51,7 @@ services: - ./models.yaml:/etc/litellm/models.yaml:ro - ./render_config.py:/etc/litellm/render_config.py:ro - ./personal_transcription.py:/etc/litellm/personal_transcription.py:ro + - ./bailian_transcription.py:/etc/litellm/bailian_transcription.py:ro - /opt/litellm/personal-vocabulary.json:/etc/litellm/personal-vocabulary.json:ro - oauth:/tokens entrypoint: [/bin/sh, -c] diff --git a/servers/tools-pve/litellm/personal_transcription.py b/servers/tools-pve/litellm/personal_transcription.py index b7ee016..9fe1030 100644 --- a/servers/tools-pve/litellm/personal_transcription.py +++ b/servers/tools-pve/litellm/personal_transcription.py @@ -45,6 +45,8 @@ def install(): if __name__ == '__main__': import sys from litellm import run_server + from bailian_transcription import install as install_bailian_asr + install_bailian_asr() install() sys.argv[0] = 'litellm' sys.exit(run_server()) diff --git a/servers/tools-pve/litellm/render_config.py b/servers/tools-pve/litellm/render_config.py index b7d9fb7..8777d1f 100644 --- a/servers/tools-pve/litellm/render_config.py +++ b/servers/tools-pve/litellm/render_config.py @@ -75,6 +75,22 @@ def namespace_config(config): return config +def add_bailian_routes(config, env, vocabulary_path): + if env.get('BAILIAN_CN_API_KEY'): + base = env.get('BAILIAN_CN_API_BASE') + if not base: + raise ValueError('BAILIAN_CN_API_BASE required for Beijing Bailian') + for personal in [False, True]: + params = {'model': 'dashscope/qwen-audio-3.1-asr-flash', + 'api_base': base, 'api_key': 'os.environ/BAILIAN_CN_API_KEY', + 'max_retries': 0} + if personal: + params['bailian_vocabulary_path'] = vocabulary_path + config['model_list'].append({'model_name': 'bailian-cn/qwen-audio-3.1-asr-flash' + + ('-personal' if personal else ''), 'litellm_params': params}) + return config + + def build_config(catalog, env, exists, namespaced=True): routes = [] fallbacks = [] @@ -146,7 +162,8 @@ def build_config(catalog, env, exists, namespaced=True): # Only explicitly approved open-model routes have cross-provider fallbacks. 'router_settings': {'num_retries': 1, 'timeout': 180, 'fallbacks': fallbacks}, } - return namespace_config(config) if namespaced else config + return add_bailian_routes(namespace_config(config), env, + '/etc/litellm/personal-vocabulary.json') if namespaced else config if __name__ == '__main__': diff --git a/servers/tools-pve/litellm/test_config.py b/servers/tools-pve/litellm/test_config.py index 1a62c04..e2b6679 100644 --- a/servers/tools-pve/litellm/test_config.py +++ b/servers/tools-pve/litellm/test_config.py @@ -18,6 +18,18 @@ class ConfigTests(unittest.TestCase): def test_missing_credentials_exclude_routes(self): self.assertEqual(build_config(self.catalog, {}, lambda _: False)['model_list'], []) + def test_bailian_routes_are_native_and_opt_in(self): + env = {'BAILIAN_CN_API_KEY': 'synthetic', 'BAILIAN_CN_API_BASE': + 'https://ws-test.cn-beijing.maas.aliyuncs.com/api/v1'} + routes = build_config(self.catalog, env, lambda _: False)['model_list'] + self.assertEqual(len(routes), 2) + self.assertEqual(routes[0]['model_name'], 'bailian-cn/qwen-audio-3.1-asr-flash') + self.assertNotIn('bailian_vocabulary_path', routes[0]['litellm_params']) + self.assertEqual(routes[1]['litellm_params']['bailian_vocabulary_path'], + '/etc/litellm/personal-vocabulary.json') + self.assertEqual(routes[0]['litellm_params']['model'], 'dashscope/qwen-audio-3.1-asr-flash') + self.assertNotIn('synthetic', str(routes)) + def test_namespaces_preserve_every_legacy_route(self): catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text()) env = {key: 'synthetic' for key in ['ZENMUX_SUBSCRIPTION_API_KEY', diff --git a/servers/tools-pve/litellm/百炼北京ASR.md b/servers/tools-pve/litellm/百炼北京ASR.md new file mode 100644 index 0000000..d537ac5 --- /dev/null +++ b/servers/tools-pve/litellm/百炼北京ASR.md @@ -0,0 +1,24 @@ +# 百炼北京原厂 ASR + +2026-10-27 上线,仅北京,不配置新加坡或跨地域回退。客户端沿用本机 LiteLLM 的 /v1/audio/transcriptions、现有客户端 key、multipart 文件上传,推荐 response_format=json。 + +| 模型名 | 行为 | +| --- | --- | +| bailian-cn/qwen-audio-3.1-asr-flash | 原厂识别,无词表注入 | +| bailian-cn/qwen-audio-3.1-asr-flash-personal | 相同模型,原生 vocabulary 注入既有 20 个人名/公司名,每词权重 3 | + +上游为业务空间专属域名 `https://ws-v68kvjnmhkiejaxo.cn-beijing.maas.aliyuncs.com/api/v1`,使用原生 `/services/aigc/multimodal-generation/generation`,不是 compatible-mode。已用 .145 现有 BAILIAN_API_KEY 实测该空间可用;部署后凭据权威是各 Arcane Project 的 BAILIAN_CN_API_KEY,原始 key 不进入 Git。 + +当前 LiteLLM 1.104.2 没有 DashScope 原生转写配置,启动适配器仅接管 dashscope provider 的 qwen-audio-3.1-asr-flash。普通 Gemini、Vertex、ZenMux 和 OpenAI 不变。不增加服务、端口或镜像,不重新部署会议管线。 + +文件原字节转为 Base64 Data URI。WAV 从文件头读取采样率,非 WAV 不伪造采样率;上游可选 prompt 作为 input_text 上下文,不覆盖、不做二次 LLM 改写,最多 400 字。生产个人词表从当前节点已有私有文件读取,不把词表放到 Git。DashScope 自定义参数在 SDK 中进入 extra_body,适配器需取出私有词表路径,再生成 parameters.vocabulary;私有路径不能泄漏上游。 + +不启用 streaming、时间戳或说话人分离,不承诺自动去口语化;原厂默认识别行为不额外覆盖。上传 Base64 Data URI 保守限制 10 MB,超限明确拒绝,不自动把文件上传公网。json 为已验证的推荐响应格式。 + +配置验证:先运行每目录 test_config.py,再在固定镜像中执行 scripts/test-bailian-asr-wire.py,检查同步/异步真实 SDK 的 HTTP 请求体、热词、原字节保持及其他 provider 不受影响。 + +原厂 A/B:2026-07-21 电话前 90 秒,无热词两处“洛奇”,指定临时热词“洛启、普华”(权重 3)后两处“洛启”,耗时分别 3.432/3.649 秒。该结果只证明该片段短词表有改善,不代表已有 20 词表一定同等改善或整体 CER/WER。 + +上线后必须验证:两节点普通/个人路由公开音频、现有客户端 key 权限、词表开关和 native vocabulary 请求体。新模型不因部署便自动获得 virtual key 权限;仅为用户批准的应用增加这两条路线,不扩大到全部模型。 + +官方文档:https://help.aliyun.com/zh/model-studio/fun-asr-flash-recorded-speech-recognition-http-api