feat(litellm): add native Beijing Bailian ASR and personal vocabulary
This commit is contained in:
@@ -0,0 +1 @@
|
||||
__pycache__/
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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__':
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user