Deploy public LiteLLM on US DC1 with preserved HK routes

This commit is contained in:
Codex
2026-10-28 19:12:03 -07:00
parent 81c475464e
commit df391cd41f
12 changed files with 741 additions and 0 deletions
@@ -20,3 +20,10 @@ dc1.opfo.xyz {
</html>
HTML
}
api.opfo.xyz {
encode zstd gzip
reverse_proxy litellm-api:1049 {
flush_interval -1
}
}
@@ -28,6 +28,9 @@ services:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
networks:
- default
- edge-api
logging:
driver: json-file
options:
@@ -39,6 +42,9 @@ volumes:
caddy-config:
networks:
edge-api:
external: true
name: dc1-api
proxy:
external: true
name: dc1-proxy
+3
View File
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.env
+26
View File
@@ -0,0 +1,26 @@
# DC1 公网模型代理
2026-10-29从HK迁移,保留`https://api.opfo.xyz/v1`、原客户端认证、全部旧别名、
Vertex ADC与SMART个人听写、四款ZenMux ASR、百炼及开源模型主备。
无数据库,不迁移`.49`的OAuth或数据库,不从其他Agent复制refresh token。
代码与非敏感配置在本目录,真实环境变量在DC1 Arcane `litellm-managed`私有.env。
ADC和个人词库从原HK项目环境快照初始化到私有named volume,只读提供给应用;
Base64不是加密,不能提交env或凭据卷。旧HK保留作回滚,不自动删除任何数据。
单worker,应用CPU上限1核、内存896MiB;不在VPS构建镜像。固定原LiteLLM镜像,
本轮不升级镜像或SDK。Docker网络`dc1-api`由此项目创建,现有frontdoor Caddy加入它,
HAProxy的既有SNI分流与代理端口不变,公网不直接发布LiteLLM或管理端口。
新增`openai/...`为官方API独立路由,只有明确批准的OPENAI_API_KEY出现才启用;
不把ZenMux GPT转写别名偷换为官方API。这里没有订阅OAuth,因此不伪造Codex备用链。
`.49`的订阅到API回退是另一个独立实例。本次不自动启用AI Studio。
迁移顺序:源元数据/私有环境备份;DC1新Git Sync/Project;部署后以原key验证;
再更新frontdoor并确认HTTPS签发条件;最后只切api.opfo.xyz的CNAME,保留旧值回滚。
TLS证书由DC1 Caddy自动签发,正式DNS切换后须检查证书链和SSE/音频请求,失败回滚。
其他域名、HK网站、Gitea、代理协议、防火墙和Tailscale不改。
```bash
python3 -m unittest discover -s . -p test_config.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)
+71
View File
@@ -0,0 +1,71 @@
services:
adc-init:
image: alpine:3.24@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
restart: "no"
environment:
GOOGLE_ADC_B64: ${GOOGLE_ADC_B64}
PERSONAL_VOCABULARY_B64: ${PERSONAL_VOCABULARY_B64:?Personal vocabulary is required}
volumes:
- google-adc:/credentials
command:
- /bin/sh
- -c
- |
set -eu
umask 077
printf '%s' "$${GOOGLE_ADC_B64}" | base64 -d > /credentials/application_default_credentials.json
printf '%s' "$${PERSONAL_VOCABULARY_B64}" | base64 -d > /credentials/personal-vocabulary.json
litellm:
image: ghcr.io/berriai/litellm:main-stable@sha256:3d88bd757134a00e8c00237b85a021ab1f6d54abb9c1df750f411dbe7256a500
restart: unless-stopped
cpus: "1.0"
mem_limit: 896m
depends_on:
adc-init:
condition: service_completed_successfully
networks:
edge-api:
aliases:
- litellm-api
environment:
GOOGLE_APPLICATION_CREDENTIALS: /credentials/application_default_credentials.json
VERTEXAI_PROJECT: ${VERTEXAI_PROJECT}
VERTEXAI_LOCATION: ${VERTEXAI_LOCATION:-global}
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
VERTEXAI_ENABLED: "true"
ZENMUX_SUBSCRIPTION_API_KEY: ${ZENMUX_SUBSCRIPTION_API_KEY:-}
ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_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:
- python /etc/litellm/render_config.py && exec python /etc/litellm/vertex_dictation.py --config /tmp/litellm.yaml --host 0.0.0.0 --port ${LITELLM_PORT:-1049} --num_workers 1
logging:
driver: json-file
options:
max-size: 1m
max-file: "2"
healthcheck:
test: [CMD, python, -c, 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:1049/health/liveliness", timeout=5)']
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
volumes:
google-adc:
networks:
edge-api:
name: dc1-api
+54
View File
@@ -0,0 +1,54 @@
# 除 Google 自带 latest 别名外,本项目映射不会自行追踪上游发布。
zenmux:
claude-sonnet-latest: anthropic/claude-sonnet-5.5
claude-opus-latest: anthropic/claude-opus-5.5
claude-haiku-latest: anthropic/claude-haiku-5.5
openrouter:
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
deepseek-pro-latest: deepseek/deepseek-v4-pro
glm-latest: z-ai/glm-5.3-prime
glm-flash-latest: z-ai/glm-5.3-flash
kimi-latest: moonshotai/kimi-k3
mimo-flash-latest: xiaomi/mimo-v2.6-flash
# These existing public aliases are subscription-first, not load-balanced.
zenmux_open_models:
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
deepseek-pro-latest: deepseek/deepseek-v4-pro
glm-latest: z-ai/glm-5.3-prime
glm-flash-latest: z-ai/glm-5.3-flash
kimi-latest: moonshotai/kimi-k3
mimo-flash-latest: xiaomi/mimo-v2.6-flash
zenmux_chat:
gemini-flash-zenmux: google/gemini-3.8-flash
google_ai_studio:
gemini-flash-latest: gemini-flash-latest
gemini-flash-lite-latest: gemini-flash-lite-latest
gemini-pro-latest: gemini-pro-latest
gemini-embedding-2-studio: gemini-embedding-2
google_vertex:
gemini-flash-vertex: gemini-3.8-flash
gemini-flash-lite-vertex: gemini-3.5-flash-lite
gemini-pro-vertex: gemini-3.1-pro-preview
google-rerank-default: semantic-ranker-default@latest
google-rerank-fast: semantic-ranker-fast@latest
# 已有 Hindsight 部署用过的候选模型;本代理仍需独立授权与请求验收。
# renderer 在实际 token 出现前排除这些路线。
chatgpt:
gpt-luna-latest: gpt-6-luna
gpt-sol-latest: gpt-6.1-sol
gpt-work-sol: gpt-6-sol
gpt-review-5.5: gpt-5.5
gpt-astra-latest: gpt-6-astra
xai:
grok-latest: grok-4.7
openai:
gpt-terra-latest: gpt-6.1-terra
gpt-transcribe: gpt-transcribe
gpt-4o-transcribe: gpt-4o-transcribe
gpt-4o-mini-transcribe: gpt-4o-mini-transcribe
gpt-4o-transcribe-diarize: gpt-4o-transcribe-diarize
text-embedding-3-small: text-embedding-3-small
text-embedding-3-large: text-embedding-3-large
gpt-image-2.5-flare: gpt-image-2.5-flare
gpt-image-2.5-sunburst: gpt-image-2.5-sunburst
gpt-4o-mini-tts: gpt-4o-mini-tts
@@ -0,0 +1,175 @@
"""Render only credential-ready routes; credentials remain environment references."""
import os
import json
from copy import deepcopy
from pathlib import Path
import yaml
def oauth_ready(path):
try:
record = json.loads(path.read_text())
return isinstance(record, dict) and bool(record.get('access_token'))
except (OSError, ValueError):
return False
def namespace_config(config):
"""Add explicit provider names without removing existing client aliases."""
routes = config['model_list']
existing = {r['model_name']: r for r in routes}
fallbacks = config['router_settings']['fallbacks']
automatic = {name for group in fallbacks for name in group}
renamed = {}
def clone(route, alias):
if alias in existing:
if existing[alias]['litellm_params'] != route['litellm_params']:
raise ValueError('Conflicting namespace alias: ' + alias)
return
new = deepcopy(route)
new['model_name'] = alias
existing[alias] = new
routes.append(new)
for route in list(routes):
alias = route['model_name']
model = route['litellm_params']['model']
if alias in {'gemini-3.6-flash', 'gemini-3.5-flash-lite',
'google/gemini-embedding-2', 'google/gemini-3.8-flash'}:
continue
if alias.endswith('-openrouter'):
new = 'openrouter/' + alias.removesuffix('-openrouter')
elif alias in automatic:
new = 'auto/' + alias
clone(route, 'zenmux/' + alias)
elif model.startswith('anthropic/'):
new = ('zenmux-payg/' + alias.removesuffix('-payg')
if alias.endswith('-payg') else 'zenmux/' + alias)
elif model.startswith('openai/'):
name = model.removeprefix('openai/').split('/', 1)[-1]
new = 'zenmux/' + (name if alias.startswith('asr-') else
alias.replace('-zenmux', '-latest'))
elif model.startswith('openrouter/'):
new = 'openrouter/' + alias
elif model.startswith('gemini/'):
new = 'aistudio/' + alias.removesuffix('-studio')
elif model.startswith('vertex_ai/'):
name = {'google-rerank-default': 'rerank-default-latest',
'google-rerank-fast': 'rerank-fast-latest'}.get(alias,
alias.replace('-vertex', '-latest'))
new = 'vertex/' + name
elif model.startswith('chatgpt/'):
name = model.split('/', 1)[1] if alias in {'gpt-work-sol', 'gpt-review-5.5'} else alias
new = 'codex/' + name
elif model.startswith('xai/'):
new = 'xai/' + alias
else:
raise ValueError('Unrecognized namespace provider: ' + alias)
clone(route, new)
renamed[alias] = new
for group in list(fallbacks):
for source, targets in group.items():
fallbacks.append({renamed[source]: [renamed[target] for target in targets]})
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 = []
def add(alias, model, key=None, **params):
if key:
params['api_key'] = 'os.environ/' + key
routes.append({'model_name': alias, 'litellm_params': {'model': model, **params}})
for key, suffix in [('ZENMUX_SUBSCRIPTION_API_KEY', ''), ('ZENMUX_PAYG_API_KEY', '-payg')]:
if env.get(key):
for alias, model in catalog['zenmux'].items():
# Strip only the LiteLLM provider prefix, retaining ZenMux's vendor prefix.
add(alias + suffix, 'anthropic/' + model, key,
api_base='https://zenmux.ai/api/anthropic')
if env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
for alias, model in catalog.get('zenmux_chat', {}).items():
add(alias, 'openai/' + model, 'ZENMUX_SUBSCRIPTION_API_KEY',
api_base='https://zenmux.ai/api/v1')
if env.get('OPENROUTER_API_KEY'):
for alias, model in catalog['openrouter'].items():
primary = catalog.get('zenmux_open_models', {}).get(alias)
if primary and env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
if primary != model:
raise ValueError('Cross-provider model mismatch: ' + alias)
add(alias, 'openai/' + primary, 'ZENMUX_SUBSCRIPTION_API_KEY',
api_base='https://zenmux.ai/api/v1', max_retries=0)
routes[-1]['model_info'] = {'cooldown_time': 60,
'allowed_fails_policy': {'RateLimitErrorAllowedFails': 0,
'AuthenticationErrorAllowedFails': 0,
'InternalServerErrorAllowedFails': 0,
'ServiceUnavailableErrorAllowedFails': 0,
'TimeoutErrorAllowedFails': 0}}
add(alias + '-openrouter', 'openrouter/' + model, 'OPENROUTER_API_KEY')
fallbacks.append({alias: [alias + '-openrouter']})
else:
add(alias, 'openrouter/' + model, 'OPENROUTER_API_KEY')
elif env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
for alias, model in catalog.get('zenmux_open_models', {}).items():
add(alias, 'openai/' + model, 'ZENMUX_SUBSCRIPTION_API_KEY',
api_base='https://zenmux.ai/api/v1', max_retries=0)
if env.get('GOOGLE_AI_STUDIO_API_KEY'):
add('gemini-transcribe-personal', 'gemini/gemini-3.5-transcribe',
'GOOGLE_AI_STUDIO_API_KEY', personal_vocabulary=True)
for alias, model in catalog['google_ai_studio'].items():
add(alias, 'gemini/' + model, 'GOOGLE_AI_STUDIO_API_KEY')
if env.get('VERTEXAI_ENABLED', '').lower() == 'true' and env.get('VERTEXAI_PROJECT'):
for alias, model in catalog.get('google_vertex', {}).items():
add(alias, 'vertex_ai/' + model,
vertex_project='os.environ/VERTEXAI_PROJECT',
vertex_location='os.environ/VERTEXAI_LOCATION')
for provider, token_var, default_dir in [
('chatgpt', 'CHATGPT_TOKEN_DIR', '/tokens/chatgpt'),
('xai', 'XAI_OAUTH_TOKEN_DIR', '/tokens/xai'),
]:
if exists(Path(env.get(token_var, default_dir)) / 'auth.json'):
for alias, model in catalog[provider].items():
extra = {'use_xai_oauth': True} if provider == 'xai' else {}
add(alias, provider + '/' + model, **extra)
config = {
'model_list': routes,
'general_settings': {
'master_key': 'os.environ/LITELLM_MASTER_KEY',
'database_url': 'os.environ/DATABASE_URL',
'store_model_in_db': False,
'disable_env_credential_login': True,
},
'litellm_settings': {'drop_params': False, 'set_verbose': False},
# Only explicitly approved open-model routes have cross-provider fallbacks.
'router_settings': {'num_retries': 1, 'timeout': 180, 'fallbacks': fallbacks},
}
return add_bailian_routes(namespace_config(config), env,
'/etc/litellm/personal-vocabulary.json') if namespaced else config
if __name__ == '__main__':
catalog = yaml.safe_load(Path('/etc/litellm/models.yaml').read_text())
config = build_config(catalog, os.environ, oauth_ready)
output = Path('/tmp/litellm.yaml')
output.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False))
output.chmod(0o600)
print('Configured routes:', ', '.join(route['model_name'] for route in config['model_list']))
@@ -0,0 +1,68 @@
"""HK-compatible public routes on DC1, with separately named official OpenAI API."""
import os
from pathlib import Path
import yaml
from provider_routes import build_config, namespace_config, add_bailian_routes
def build_hk_config(catalog, env):
config = build_config(catalog, env, lambda path: False, namespaced=False)
config['general_settings'] = {'master_key': 'os.environ/LITELLM_MASTER_KEY'}
routes = config['model_list']
routes[:] = [route for route in routes if route['model_name'] != 'gemini-transcribe-personal']
if env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
asr_models = {
'asr-gpt': 'openai/gpt-transcribe',
'asr-qwen': 'qwen/qwen-audio-3.1-asr-flash',
'asr-doubao': 'bytedance/doubao-seed-asr-2.0',
'asr-mimo': 'xiaomi/mimo-v2.5-asr',
}
for alias, model in asr_models.items():
routes.append({'model_name': alias, 'litellm_params': {
'model': 'openai/' + model,
'api_key': 'os.environ/ZENMUX_SUBSCRIPTION_API_KEY',
'api_base': 'https://zenmux.ai/api/v1',
'max_retries': 0}})
if env.get('GOOGLE_AI_STUDIO_API_KEY'):
routes.append({'model_name': 'gemini-transcribe', 'litellm_params': {
'model': 'gemini/gemini-3.5-transcribe',
'api_key': 'os.environ/GOOGLE_AI_STUDIO_API_KEY'}})
if env.get('VERTEXAI_ENABLED', '').lower() == 'true' and env.get('VERTEXAI_PROJECT'):
routes.append({'model_name': 'gemini-transcribe-personal', 'litellm_params': {
'model': 'vertex_ai/gemini-3.5-transcribe',
'vertex_project': 'os.environ/VERTEXAI_PROJECT',
'vertex_location': 'global',
'personal_dictation': True}})
# Keep old client aliases; the old 3.6 alias now follows the verified Flash map.
legacy = {
'gemini-3.6-flash': catalog['google_vertex']['gemini-flash-vertex'],
'gemini-3.5-flash-lite': catalog['google_vertex']['gemini-flash-lite-vertex'],
'google/gemini-embedding-2': 'gemini-embedding-2',
'google/gemini-3.8-flash': catalog['google_vertex']['gemini-flash-vertex'],
}
for alias, model in legacy.items():
routes.append({'model_name': alias, 'litellm_params': {
'model': 'vertex_ai/' + model,
'vertex_project': 'os.environ/VERTEXAI_PROJECT',
'vertex_location': 'os.environ/VERTEXAI_LOCATION'}})
config = add_bailian_routes(namespace_config(config), env, '/credentials/personal-vocabulary.json')
if env.get('OPENAI_API_KEY'):
models = dict(catalog.get('openai', {}))
for alias, model in catalog.get('chatgpt', {}).items():
models[alias if alias.endswith('-latest') else model] = model
for alias, model in models.items():
config['model_list'].append({'model_name': 'openai/' + alias, 'litellm_params': {
'model': 'openai/' + model, 'api_base': 'https://api.openai.com/v1',
'api_key': 'os.environ/OPENAI_API_KEY', 'max_retries': 0}})
return config
if __name__ == '__main__':
catalog = yaml.safe_load(Path('/etc/litellm/models.yaml').read_text())
config = build_hk_config(catalog, os.environ)
output = Path('/tmp/litellm.yaml')
output.write_text(yaml.safe_dump(config, allow_unicode=True, sort_keys=False))
output.chmod(0o600)
print('Configured routes:', ', '.join(route['model_name'] for route in config['model_list']))
+67
View File
@@ -0,0 +1,67 @@
import unittest
from pathlib import Path
import yaml
from render_config import build_hk_config
class RoutingTests(unittest.TestCase):
def test_official_api_names_do_not_replace_hk_routes(self):
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
env = {'ZENMUX_SUBSCRIPTION_API_KEY': 'synthetic', 'OPENAI_API_KEY': 'synthetic'}
config = build_hk_config(catalog, env)
routes = {r['model_name']: r['litellm_params'] for r in config['model_list']}
self.assertEqual(routes['openai/gpt-transcribe']['api_base'], 'https://api.openai.com/v1')
self.assertEqual(routes['zenmux/gpt-transcribe']['api_base'], 'https://zenmux.ai/api/v1')
self.assertEqual(routes['openai/gpt-sol-latest']['model'], 'openai/gpt-6.1-sol')
self.assertNotIn('codex/gpt-sol-latest', routes)
self.assertNotIn('synthetic', yaml.safe_dump(config))
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',
'ZENMUX_PAYG_API_KEY', 'OPENROUTER_API_KEY', 'GOOGLE_AI_STUDIO_API_KEY',
'VERTEXAI_PROJECT']}
env['VERTEXAI_ENABLED'] = 'true'
result = build_hk_config(catalog, env)
routes = {r['model_name']: r['litellm_params'] for r in result['model_list']}
self.assertEqual(len(routes), len(result['model_list']))
self.assertEqual(routes['gemini-transcribe-personal']['model'], 'vertex_ai/gemini-3.5-transcribe')
self.assertIs(routes['gemini-transcribe-personal']['personal_dictation'], True)
self.assertEqual(routes['gemini-transcribe-personal']['vertex_location'], 'global')
self.assertNotIn('grok-latest', routes)
self.assertNotIn('gpt-sol-latest', routes)
self.assertEqual(routes['asr-gpt']['model'], 'openai/openai/gpt-transcribe')
self.assertEqual(routes['asr-qwen']['model'], 'openai/qwen/qwen-audio-3.1-asr-flash')
self.assertEqual(routes['asr-doubao']['model'], 'openai/bytedance/doubao-seed-asr-2.0')
self.assertEqual(routes['asr-mimo']['model'], 'openai/xiaomi/mimo-v2.5-asr')
for alias in ['asr-gpt', 'asr-qwen', 'asr-doubao', 'asr-mimo']:
self.assertNotIn('personal_dictation', routes[alias])
self.assertEqual(routes[alias]['api_key'], 'os.environ/ZENMUX_SUBSCRIPTION_API_KEY')
self.assertEqual(routes['gemini-transcribe']['model'], 'gemini/gemini-3.5-transcribe')
self.assertEqual(routes['gemini-3.6-flash']['model'], 'vertex_ai/gemini-3.8-flash')
self.assertEqual(len(result['router_settings']['fallbacks']), 12)
self.assertEqual(routes['vertex/gemini-transcribe-personal'], routes['gemini-transcribe-personal'])
self.assertNotIn('aistudio/gemini-transcribe-personal', routes)
self.assertEqual(routes['zenmux/gpt-transcribe'], routes['asr-gpt'])
self.assertEqual(result['general_settings'], {'master_key': 'os.environ/LITELLM_MASTER_KEY'})
self.assertNotIn('synthetic', yaml.safe_dump(result))
def test_missing_credentials(self):
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
result = build_hk_config(catalog, {})
self.assertEqual(result['model_list'], [])
self.assertEqual(result['router_settings']['fallbacks'], [])
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,54 @@
"""Scoped Vertex dictation settings; ordinary transcription calls stay unchanged."""
import json
from pathlib import Path
VOCABULARY = Path('/credentials/personal-vocabulary.json')
def vocabulary(path=VOCABULARY):
words = json.loads(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(word, str) or not word.strip() or len(word) > 100 for word in words):
raise ValueError('Invalid personal vocabulary term')
return list(dict.fromkeys(word.strip() for word in words))
def install():
from litellm.llms.vertex_ai.audio_transcription.gemini_transcribe_transformation import (
VertexGeminiAudioTranscriptionConfig,
)
original = VertexGeminiAudioTranscriptionConfig.transform_audio_transcription_request
if getattr(original, '_personal_dictation_patch', False):
return
def transform(self, model, audio_file, optional_params, litellm_params):
if optional_params.get('personal_dictation') is not True:
return original(self, model, audio_file, optional_params, litellm_params)
if model.removeprefix('vertex_ai/') != 'gemini-3.5-transcribe':
raise ValueError('Personal dictation is restricted to Vertex Gemini 3.5 Transcribe')
if optional_params.get('timestamp_granularities') or optional_params.get('response_format') not in (None, 'json', 'text'):
raise ValueError('Personal dictation supports json/text without timestamps')
clean = {key: val for key, val in optional_params.items() if key != 'personal_dictation'}
result = original(self, model, audio_file, clean, litellm_params)
config = result.data.setdefault('generationConfig', {}).setdefault('audioTranscriptionConfig', {})
if set(config) - {'mode', 'languageCodes'} or config.get('mode') not in (None, 'VERBATIM'):
raise ValueError('Unexpected Vertex transcription defaults; review adapter compatibility')
config['mode'] = 'SMART'
config['customVocabulary'] = vocabulary()
return result
transform._personal_dictation_patch = True
VertexGeminiAudioTranscriptionConfig.transform_audio_transcription_request = transform
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,91 @@
"""Adapt four allowlisted ZenMux JSON ASR models to LiteLLM's audio endpoint."""
import base64
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
MODELS = frozenset({'openai/gpt-transcribe', 'qwen/qwen-audio-3.1-asr-flash',
'bytedance/doubao-seed-asr-2.0', 'xiaomi/mimo-v2.5-asr'})
API_BASE = 'https://zenmux.ai/api/v1'
class ZenMuxTranscriptionConfig(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):
params = super().map_openai_params(non_default_params, optional_params, model, drop_params)
if params.get('prompt'):
raise ValueError('ZenMux comparison routes do not accept prompts; leave prompt empty')
if params.get('response_format') not in (None, 'json', 'text'):
raise ValueError('ZenMux comparison routes support json/text only')
return params
def validate_environment(self, headers, model, messages, optional_params,
litellm_params, api_key=None, api_base=None):
if not api_key:
raise ValueError('ZenMux subscription API key is required')
return {**headers, 'Authorization': 'Bearer ' + api_key, 'Content-Type': 'application/json'}
def get_complete_url(self, api_base, api_key, model, optional_params, litellm_params, stream=None):
if (api_base or '').rstrip('/') != API_BASE or model not in MODELS:
raise ValueError('ZenMux ASR adapter is restricted to the configured endpoint and model allowlist')
if stream:
raise ValueError('Streaming is not enabled for comparison dictation routes')
return API_BASE + '/audio/transcriptions'
def transform_audio_transcription_request(self, model, audio_file, optional_params, litellm_params):
if model not in MODELS:
raise ValueError('Unknown ZenMux ASR model')
if optional_params.get('timestamp_granularities') or optional_params.get('prompt'):
raise ValueError('Timestamps and prompt injection are disabled on comparison routes')
audio = process_audio_file(audio_file)
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/m4a': 'm4a', 'audio/mp4': 'm4a',
'audio/x-m4a': 'm4a', 'audio/ogg': 'ogg', 'audio/webm': 'webm',
'video/webm': 'webm', 'audio/aac': 'aac'}
mime = audio.content_type.split(';', 1)[0].lower()
if mime not in formats:
raise ValueError('Unsupported audio MIME type for ZenMux ASR')
body = {'model': model, 'input_audio': {
'data': base64.b64encode(audio.file_content).decode(), 'format': formats[mime]},
'stream': False}
language = optional_params.get('language')
if language:
body['language'] = language
return AudioTranscriptionRequestData(data=body)
def transform_audio_transcription_response(self, raw_response):
body = raw_response.json()
if not isinstance(body.get('text'), str):
raise ValueError('ZenMux ASR returned no transcript text')
response = TranscriptionResponse(text=body['text'])
usage = body.get('usage') or {}
if usage.get('seconds') is not None:
# ZenMux has no audio/text token breakdown; report duration without inventing one.
response.usage = TranscriptionUsageDurationObject(type='duration', seconds=usage['seconds'])
response['duration'] = usage['seconds']
return response
def install():
import litellm
from litellm.utils import ProviderConfigManager
original = ProviderConfigManager.get_provider_audio_transcription_config
if getattr(original, '_zenmux_asr_adapter', False):
return
def select(model, provider):
if provider == litellm.LlmProviders.OPENAI and model in MODELS:
return ZenMuxTranscriptionConfig()
return original(model, provider)
select._zenmux_asr_adapter = True
ProviderConfigManager.get_provider_audio_transcription_config = staticmethod(select)