diff --git a/servers/bwh-us-dc1/frontdoor/caddy/Caddyfile b/servers/bwh-us-dc1/frontdoor/caddy/Caddyfile index bd73605..a34535e 100644 --- a/servers/bwh-us-dc1/frontdoor/caddy/Caddyfile +++ b/servers/bwh-us-dc1/frontdoor/caddy/Caddyfile @@ -20,3 +20,10 @@ dc1.opfo.xyz { HTML } + +api.opfo.xyz { + encode zstd gzip + reverse_proxy litellm-api:1049 { + flush_interval -1 + } +} diff --git a/servers/bwh-us-dc1/frontdoor/compose.yaml b/servers/bwh-us-dc1/frontdoor/compose.yaml index 49af3b4..577e6e3 100644 --- a/servers/bwh-us-dc1/frontdoor/compose.yaml +++ b/servers/bwh-us-dc1/frontdoor/compose.yaml @@ -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 diff --git a/servers/bwh-us-dc1/litellm/.gitignore b/servers/bwh-us-dc1/litellm/.gitignore new file mode 100644 index 0000000..b6cf5f0 --- /dev/null +++ b/servers/bwh-us-dc1/litellm/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.env diff --git a/servers/bwh-us-dc1/litellm/README.md b/servers/bwh-us-dc1/litellm/README.md new file mode 100644 index 0000000..c77ba73 --- /dev/null +++ b/servers/bwh-us-dc1/litellm/README.md @@ -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 +``` diff --git a/servers/bwh-us-dc1/litellm/bailian_transcription.py b/servers/bwh-us-dc1/litellm/bailian_transcription.py new file mode 100644 index 0000000..cda1339 --- /dev/null +++ b/servers/bwh-us-dc1/litellm/bailian_transcription.py @@ -0,0 +1,119 @@ +"""Bridge OpenAI file uploads to Beijing Bailian's native ASR API.""" +import base64 +import io +import json +from pathlib import Path +from urllib.parse import urlsplit +import wave + +from litellm.llms.base_llm.audio_transcription.transformation import AudioTranscriptionRequestData +from litellm.llms.openai.transcriptions.whisper_transformation import OpenAIWhisperAudioTranscriptionConfig +from litellm.litellm_core_utils.audio_utils.utils import process_audio_file +from litellm.types.utils import TranscriptionResponse, TranscriptionUsageDurationObject + +MODEL = 'qwen-audio-3.1-asr-flash' +FORMATS = {'audio/wav':'wav','audio/x-wav':'wav','audio/wave':'wav', + 'audio/mpeg':'mp3','audio/mp3':'mp3','audio/flac':'flac','audio/x-flac':'flac', + 'audio/mp4':'m4a','audio/m4a':'m4a','audio/x-m4a':'m4a', + 'audio/ogg':'ogg','audio/webm':'webm','video/webm':'webm','audio/aac':'aac'} +VOCABULARY_PATHS = frozenset({'/etc/litellm/personal-vocabulary.json', + '/credentials/personal-vocabulary.json'}) + + +def vocabulary(path): + if path not in VOCABULARY_PATHS: + raise ValueError('Unexpected personal vocabulary path') + words = json.loads(Path(path).read_text()) + if not isinstance(words,list) or not 1 <= len(words) <= 100: + raise ValueError('Personal vocabulary must contain 1-100 terms') + if any(not isinstance(w,str) or not w.strip() or len(w)>100 for w in words): + raise ValueError('Invalid personal vocabulary term') + return {w.strip():3 for w in words} + + +class BailianTranscriptionConfig(OpenAIWhisperAudioTranscriptionConfig): + @property + def has_native_transcription_endpoint(self): + return True + + def get_supported_openai_params(self,model): + return ['language','response_format','prompt'] + + def map_openai_params(self,non_default_params,optional_params,model,drop_params): + result=super().map_openai_params(non_default_params,optional_params,model,drop_params) + if result.get('response_format') not in (None,'json','text'): + raise ValueError('Bailian dictation supports json/text without timestamps') + return result + + def validate_environment(self,headers,model,messages,optional_params,litellm_params, + api_key=None,api_base=None): + if not api_key:raise ValueError('Beijing Bailian API key required') + return {**headers,'Authorization':'Bearer '+api_key,'Content-Type':'application/json', + 'X-DashScope-SSE':'disable'} + + def get_complete_url(self,api_base,api_key,model,optional_params,litellm_params,stream=None): + parsed=urlsplit(api_base or '') + if (model != MODEL or parsed.scheme != 'https' or + not (parsed.hostname or '').endswith('.cn-beijing.maas.aliyuncs.com') or + parsed.path.rstrip('/') != '/api/v1' or parsed.port not in (None,443) or + parsed.username or parsed.password or parsed.query or parsed.fragment): + raise ValueError('Bailian ASR restricted to Beijing workspace native endpoints') + if stream:raise ValueError('Streaming not enabled on this dictation route') + return api_base.rstrip('/')+'/services/aigc/multimodal-generation/generation' + + def transform_audio_transcription_request(self,model,audio_file,optional_params,litellm_params): + if model != MODEL or optional_params.get('timestamp_granularities'): + raise ValueError('Unsupported Bailian dictation model or timestamps') + audio=process_audio_file(audio_file) + mime=audio.content_type.split(';',1)[0].lower() + if mime not in FORMATS:raise ValueError('Unsupported Bailian audio MIME type') + if len(audio.file_content)>10_000_000:raise ValueError('Bailian inline audio exceeds 10 MB') + parameters={'format':FORMATS[mime]} + if parameters['format']=='wav': + with wave.open(io.BytesIO(audio.file_content),'rb') as wav: + parameters['sample_rate']=str(wav.getframerate()) + language=optional_params.get('language') + if language:parameters['language_hints']=[language] + # LiteLLM classifies DashScope as OpenAI-compatible and nests custom kwargs. + extra=optional_params.get('extra_body') or {} + path=optional_params.get('bailian_vocabulary_path') or extra.get('bailian_vocabulary_path') + if path:parameters['vocabulary']=vocabulary(path) + prompt=optional_params.get('prompt') + messages=[] + if prompt: + if not isinstance(prompt,str) or len(prompt)>400: + raise ValueError('Bailian context prompt must be at most 400 characters') + messages.append({'role':'user','content':[{'type':'input_text','text':prompt}]}) + # Always preserve the original bytes; only WAV metadata supplies sample rate. + uri_mime='audio/mp3' if parameters['format']=='mp3' else mime + data_uri='data:'+uri_mime+';base64,'+base64.b64encode(audio.file_content).decode() + if len(data_uri)>10_000_000: + raise ValueError('Bailian inline audio exceeds 10 MB after Base64 encoding') + messages.append({'role':'user','content':[{'type':'input_audio','input_audio':{ + 'data':data_uri}}]}) + return AudioTranscriptionRequestData(data={'model':MODEL,'input':{'messages':messages}, + 'parameters':parameters}) + + def transform_audio_transcription_response(self,raw_response): + body=raw_response.json() + text=body.get('output',{}).get('text',body.get('text')) + if not isinstance(text,str):raise ValueError('Bailian returned no transcript') + result=TranscriptionResponse(text=text) + duration=(body.get('usage') or {}).get('duration') + if duration is not None: + result.usage=TranscriptionUsageDurationObject(type='duration',seconds=duration) + result['duration']=duration + return result + + +def install(): + import litellm + from litellm.utils import ProviderConfigManager + original=ProviderConfigManager.get_provider_audio_transcription_config + if getattr(original,'_bailian_asr_adapter',False):return + def select(model,provider): + if provider==litellm.LlmProviders.DASHSCOPE and model==MODEL: + return BailianTranscriptionConfig() + return original(model,provider) + select._bailian_asr_adapter=True + ProviderConfigManager.get_provider_audio_transcription_config=staticmethod(select) diff --git a/servers/bwh-us-dc1/litellm/compose.yaml b/servers/bwh-us-dc1/litellm/compose.yaml new file mode 100644 index 0000000..d7a612a --- /dev/null +++ b/servers/bwh-us-dc1/litellm/compose.yaml @@ -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 diff --git a/servers/bwh-us-dc1/litellm/models.yaml b/servers/bwh-us-dc1/litellm/models.yaml new file mode 100644 index 0000000..ee12d56 --- /dev/null +++ b/servers/bwh-us-dc1/litellm/models.yaml @@ -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 diff --git a/servers/bwh-us-dc1/litellm/provider_routes.py b/servers/bwh-us-dc1/litellm/provider_routes.py new file mode 100644 index 0000000..8777d1f --- /dev/null +++ b/servers/bwh-us-dc1/litellm/provider_routes.py @@ -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'])) diff --git a/servers/bwh-us-dc1/litellm/render_config.py b/servers/bwh-us-dc1/litellm/render_config.py new file mode 100644 index 0000000..193a54e --- /dev/null +++ b/servers/bwh-us-dc1/litellm/render_config.py @@ -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'])) diff --git a/servers/bwh-us-dc1/litellm/test_config.py b/servers/bwh-us-dc1/litellm/test_config.py new file mode 100644 index 0000000..d4a6cdf --- /dev/null +++ b/servers/bwh-us-dc1/litellm/test_config.py @@ -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() diff --git a/servers/bwh-us-dc1/litellm/vertex_dictation.py b/servers/bwh-us-dc1/litellm/vertex_dictation.py new file mode 100644 index 0000000..0acf551 --- /dev/null +++ b/servers/bwh-us-dc1/litellm/vertex_dictation.py @@ -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()) diff --git a/servers/bwh-us-dc1/litellm/zenmux_transcription.py b/servers/bwh-us-dc1/litellm/zenmux_transcription.py new file mode 100644 index 0000000..e7f764b --- /dev/null +++ b/servers/bwh-us-dc1/litellm/zenmux_transcription.py @@ -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)