Deploy public LiteLLM on US DC1 with preserved HK routes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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']))
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user