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_SUBSCRIPTION_API_KEY: ${ZENMUX_SUBSCRIPTION_API_KEY:-}
|
||||||
ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-}
|
ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-}
|
||||||
OPENROUTER_API_KEY: ${OPENROUTER_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:
|
volumes:
|
||||||
- ./models.yaml:/etc/litellm/models.yaml:ro
|
- ./models.yaml:/etc/litellm/models.yaml:ro
|
||||||
- ./provider_routes.py:/etc/litellm/provider_routes.py:ro
|
- ./provider_routes.py:/etc/litellm/provider_routes.py:ro
|
||||||
- ./render_config.py:/etc/litellm/render_config.py:ro
|
- ./render_config.py:/etc/litellm/render_config.py:ro
|
||||||
- ./vertex_dictation.py:/etc/litellm/vertex_dictation.py:ro
|
- ./vertex_dictation.py:/etc/litellm/vertex_dictation.py:ro
|
||||||
- ./zenmux_transcription.py:/etc/litellm/zenmux_transcription.py:ro
|
- ./zenmux_transcription.py:/etc/litellm/zenmux_transcription.py:ro
|
||||||
|
- ./bailian_transcription.py:/etc/litellm/bailian_transcription.py:ro
|
||||||
- google-adc:/credentials:ro
|
- google-adc:/credentials:ro
|
||||||
entrypoint: ["/bin/sh", "-c"]
|
entrypoint: ["/bin/sh", "-c"]
|
||||||
command:
|
command:
|
||||||
|
|||||||
@@ -75,6 +75,22 @@ def namespace_config(config):
|
|||||||
return 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):
|
def build_config(catalog, env, exists, namespaced=True):
|
||||||
routes = []
|
routes = []
|
||||||
fallbacks = []
|
fallbacks = []
|
||||||
@@ -146,7 +162,8 @@ def build_config(catalog, env, exists, namespaced=True):
|
|||||||
# Only explicitly approved open-model routes have cross-provider fallbacks.
|
# Only explicitly approved open-model routes have cross-provider fallbacks.
|
||||||
'router_settings': {'num_retries': 1, 'timeout': 180, 'fallbacks': 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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import yaml
|
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):
|
def build_hk_config(catalog, env):
|
||||||
@@ -47,7 +47,7 @@ def build_hk_config(catalog, env):
|
|||||||
'model': 'vertex_ai/' + model,
|
'model': 'vertex_ai/' + model,
|
||||||
'vertex_project': 'os.environ/VERTEXAI_PROJECT',
|
'vertex_project': 'os.environ/VERTEXAI_PROJECT',
|
||||||
'vertex_location': 'os.environ/VERTEXAI_LOCATION'}})
|
'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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ from render_config import build_hk_config
|
|||||||
|
|
||||||
|
|
||||||
class RoutingTests(unittest.TestCase):
|
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):
|
def test_all_providers(self):
|
||||||
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
|
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
|
||||||
env = {key: 'synthetic' for key in ['ZENMUX_SUBSCRIPTION_API_KEY',
|
env = {key: 'synthetic' for key in ['ZENMUX_SUBSCRIPTION_API_KEY',
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ if __name__ == '__main__':
|
|||||||
import sys
|
import sys
|
||||||
from litellm import run_server
|
from litellm import run_server
|
||||||
from zenmux_transcription import install as install_zenmux_asr
|
from zenmux_transcription import install as install_zenmux_asr
|
||||||
|
from bailian_transcription import install as install_bailian_asr
|
||||||
install_zenmux_asr()
|
install_zenmux_asr()
|
||||||
|
install_bailian_asr()
|
||||||
install()
|
install()
|
||||||
sys.argv[0] = 'litellm'
|
sys.argv[0] = 'litellm'
|
||||||
sys.exit(run_server())
|
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_SUBSCRIPTION_API_KEY=
|
||||||
ZENMUX_PAYG_API_KEY=
|
ZENMUX_PAYG_API_KEY=
|
||||||
OPENROUTER_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=
|
GOOGLE_AI_STUDIO_API_KEY=
|
||||||
VERTEXAI_ENABLED=false
|
VERTEXAI_ENABLED=false
|
||||||
VERTEXAI_PROJECT=
|
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_SUBSCRIPTION_API_KEY: ${ZENMUX_SUBSCRIPTION_API_KEY:-}
|
||||||
ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-}
|
ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-}
|
||||||
OPENROUTER_API_KEY: ${OPENROUTER_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:-}
|
GOOGLE_AI_STUDIO_API_KEY: ${GOOGLE_AI_STUDIO_API_KEY:-}
|
||||||
VERTEXAI_ENABLED: ${VERTEXAI_ENABLED:-false}
|
VERTEXAI_ENABLED: ${VERTEXAI_ENABLED:-false}
|
||||||
VERTEXAI_PROJECT: ${VERTEXAI_PROJECT:-}
|
VERTEXAI_PROJECT: ${VERTEXAI_PROJECT:-}
|
||||||
@@ -49,6 +51,7 @@ services:
|
|||||||
- ./models.yaml:/etc/litellm/models.yaml:ro
|
- ./models.yaml:/etc/litellm/models.yaml:ro
|
||||||
- ./render_config.py:/etc/litellm/render_config.py:ro
|
- ./render_config.py:/etc/litellm/render_config.py:ro
|
||||||
- ./personal_transcription.py:/etc/litellm/personal_transcription.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
|
- /opt/litellm/personal-vocabulary.json:/etc/litellm/personal-vocabulary.json:ro
|
||||||
- oauth:/tokens
|
- oauth:/tokens
|
||||||
entrypoint: [/bin/sh, -c]
|
entrypoint: [/bin/sh, -c]
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ def install():
|
|||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
import sys
|
import sys
|
||||||
from litellm import run_server
|
from litellm import run_server
|
||||||
|
from bailian_transcription import install as install_bailian_asr
|
||||||
|
install_bailian_asr()
|
||||||
install()
|
install()
|
||||||
sys.argv[0] = 'litellm'
|
sys.argv[0] = 'litellm'
|
||||||
sys.exit(run_server())
|
sys.exit(run_server())
|
||||||
|
|||||||
@@ -75,6 +75,22 @@ def namespace_config(config):
|
|||||||
return 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):
|
def build_config(catalog, env, exists, namespaced=True):
|
||||||
routes = []
|
routes = []
|
||||||
fallbacks = []
|
fallbacks = []
|
||||||
@@ -146,7 +162,8 @@ def build_config(catalog, env, exists, namespaced=True):
|
|||||||
# Only explicitly approved open-model routes have cross-provider fallbacks.
|
# Only explicitly approved open-model routes have cross-provider fallbacks.
|
||||||
'router_settings': {'num_retries': 1, 'timeout': 180, 'fallbacks': 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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ class ConfigTests(unittest.TestCase):
|
|||||||
def test_missing_credentials_exclude_routes(self):
|
def test_missing_credentials_exclude_routes(self):
|
||||||
self.assertEqual(build_config(self.catalog, {}, lambda _: False)['model_list'], [])
|
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):
|
def test_namespaces_preserve_every_legacy_route(self):
|
||||||
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
|
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
|
||||||
env = {key: 'synthetic' for key in ['ZENMUX_SUBSCRIPTION_API_KEY',
|
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