Retire ZenMux subscription and keep BYOK as terminal fallback

This commit is contained in:
Codex
2026-11-01 23:25:00 -08:00
parent be589ff5e0
commit c42456be22
13 changed files with 338 additions and 13 deletions
+11
View File
@@ -1,5 +1,16 @@
# DC1 公网模型代理
## 2026-11-02:取消 ZenMux 订阅
已禁用订阅 Key。Claude/开源模型采用 OpenRouter → ZenMux BYOK,后者只作最后备用;
显式 `zenmux-payg/...` 仍直连 BYOK。旧 `zenmux/...` 名称保留作兼容入口,
不代表订阅后端仍存在;新配置优先用 `auto/...`。
`asr-gpt`:OpenAI 官方 → ZenMux BYOK;`asr-qwen`:已配置的百炼北京 → ZenMux BYOK。
豆包/MiMo 没有已配置的同模型替代后端,因此只能经 BYOK,不跨型号替换。
SMART、个人词库及会议处理配置不变。BYOK 是按量付费,不与 OpenRouter 共享缓存。
OpenRouter Claude 合成直连在两台生产主机均返回条款限制类 403,首选上游未通过验收。
2026-10-29从HK迁移,保留`https://api.opfo.xyz/v1`、原客户端认证、全部旧别名、
Vertex ADC与SMART个人听写、四款ZenMux ASR、百炼及开源模型主备。
无数据库,不迁移`.49`的OAuth或数据库,不从其他Agent复制refresh token。
+1 -1
View File
@@ -34,7 +34,6 @@ services:
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:-}
@@ -43,6 +42,7 @@ services:
volumes:
- ./models.yaml:/etc/litellm/models.yaml:ro
- ./provider_routes.py:/etc/litellm/provider_routes.py:ro
- ./provider_policy.py:/etc/litellm/provider_policy.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
+2 -1
View File
@@ -1,4 +1,5 @@
# 除 Google 自带 latest 别名外,本项目映射不会自行追踪上游发布。
zenmux_subscription_enabled: false
zenmux:
claude-sonnet-latest: anthropic/claude-sonnet-5.5
claude-opus-latest: anthropic/claude-opus-5.5
@@ -10,7 +11,7 @@ openrouter:
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.
# OpenRouter first; ZenMux BYOK is the final same-model fallback.
zenmux_open_models:
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
deepseek-pro-latest: deepseek/deepseek-v4-pro
@@ -0,0 +1,118 @@
"""Subscription retirement: existing aliases stay usable, BYOK is terminal only."""
from copy import deepcopy
def subscription_disabled(catalog):
return catalog.get('zenmux_subscription_enabled') is False
def without_subscription(catalog, env):
if not subscription_disabled(catalog):
return env
return {key: value for key, value in env.items()
if key != 'ZENMUX_SUBSCRIPTION_API_KEY'}
def apply_provider_policy(config, catalog, env):
if not subscription_disabled(catalog):
return config
routes = config['model_list']
if any(r['litellm_params'].get('api_key') ==
'os.environ/ZENMUX_SUBSCRIPTION_API_KEY' for r in routes):
raise ValueError('Retired subscription route remains')
indexed = {r['model_name']: r for r in routes}
groups = {k: list(v) for row in config['router_settings']['fallbacks']
for k, v in row.items()}
def add(name, params):
if name in indexed:
return indexed[name]
route = {'model_name': name, 'litellm_params': deepcopy(params)}
indexed[name] = route
routes.append(route)
return route
def fallback(source, target):
if source == target or source not in indexed or target not in indexed:
return
targets = groups.setdefault(source, [])
if target not in targets:
targets.append(target)
def payg_params(model, anthropic=False):
return {'model': ('anthropic/' if anthropic else 'openai/') + model,
'api_key': 'os.environ/ZENMUX_PAYG_API_KEY',
'api_base': 'https://zenmux.ai/api/anthropic' if anthropic
else 'https://zenmux.ai/api/v1',
'max_retries': 0}
# Explicit BYOK aliases remain direct; automatic/legacy aliases prefer OpenRouter.
for family in ('zenmux', 'zenmux_open_models'):
for alias, model in catalog.get(family, {}).items():
byok = 'zenmux-payg/' + alias
if env.get('ZENMUX_PAYG_API_KEY'):
add(byok, payg_params(model, anthropic=family == 'zenmux'))
primary = None
if env.get('OPENROUTER_API_KEY'):
primary = {'model': 'openrouter/' + model,
'api_key': 'os.environ/OPENROUTER_API_KEY', 'max_retries': 0}
elif byok in indexed:
primary = indexed[byok]['litellm_params']
if primary is None:
continue
names = [alias, 'auto/' + alias, 'zenmux/' + alias]
if env.get('OPENROUTER_API_KEY'):
names += ['openrouter/' + alias]
if family == 'zenmux_open_models':
names += [alias + '-openrouter']
for name in names:
route = add(name, primary)
if route['litellm_params'].get('api_key') != 'os.environ/ZENMUX_PAYG_API_KEY':
fallback(name, byok)
# Former Gemini subscription aliases follow the existing Google primary.
for alias, model in catalog.get('zenmux_chat', {}).items():
byok = 'zenmux-payg/' + alias.replace('-zenmux', '-latest')
if env.get('ZENMUX_PAYG_API_KEY'):
add(byok, payg_params(model))
google = indexed.get('aistudio/gemini-flash-latest') or indexed.get('vertex/gemini-flash-latest')
primary = google or indexed.get(byok)
if primary:
for name in [alias, 'zenmux/' + alias.replace('-zenmux', '-latest')]:
route = add(name, primary['litellm_params'])
if route['litellm_params'].get('api_key') != 'os.environ/ZENMUX_PAYG_API_KEY':
fallback(name, byok)
# Public ASR compatibility aliases use their same-model direct provider first.
asr = {'asr-gpt': ('openai/gpt-transcribe', 'openai/gpt-transcribe'),
'asr-qwen': ('bailian-cn/qwen-audio-3.1-asr-flash', 'qwen/qwen-audio-3.1-asr-flash'),
'asr-doubao': (None, 'bytedance/doubao-seed-asr-2.0'),
'asr-mimo': (None, 'xiaomi/mimo-v2.5-asr')}
for alias, (direct, model) in asr.items():
# Do not introduce new ASR models to the LAN instance.
if not config.get('_public_gateway'):
break
byok = 'zenmux-payg/' + model.split('/', 1)[1]
if env.get('ZENMUX_PAYG_API_KEY'):
add(byok, payg_params(model))
primary = indexed.get(direct) or indexed.get(byok)
if not primary:
continue
if alias in indexed:
indexed[alias]['litellm_params'] = deepcopy(primary['litellm_params'])
else:
add(alias, primary['litellm_params'])
# Old explicit ZenMux names are compatibility aliases, no subscription calls.
add('zenmux/' + model.split('/', 1)[1], primary['litellm_params'])
if primary['litellm_params'].get('api_key') != 'os.environ/ZENMUX_PAYG_API_KEY':
fallback(alias, byok)
fallback('zenmux/' + model.split('/', 1)[1], byok)
config.pop('_public_gateway', None)
config['router_settings']['fallbacks'] = [{k: v} for k, v in groups.items() if v]
for source, targets in groups.items():
if source not in indexed or any(t not in indexed for t in targets):
raise ValueError('Dangling fallback: ' + source)
if indexed[source]['litellm_params'].get('api_key') == 'os.environ/ZENMUX_PAYG_API_KEY':
raise ValueError('BYOK must be terminal: ' + source)
return config
@@ -5,6 +5,7 @@ from copy import deepcopy
from pathlib import Path
import yaml
from provider_policy import without_subscription
def oauth_ready(path):
@@ -92,6 +93,7 @@ def add_bailian_routes(config, env, vocabulary_path):
def build_config(catalog, env, exists, namespaced=True):
env = without_subscription(catalog, env)
routes = []
fallbacks = []
+4 -1
View File
@@ -5,9 +5,11 @@ from pathlib import Path
import yaml
from provider_routes import build_config, namespace_config, add_bailian_routes
from provider_policy import without_subscription, apply_provider_policy
def build_hk_config(catalog, env):
env = without_subscription(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']
@@ -62,7 +64,8 @@ def build_hk_config(catalog, env):
'api_key': 'os.environ/OPENAI_API_KEY', 'max_retries': 0}})
if env.get('ZENMUX_SUBSCRIPTION_API_KEY'):
config['router_settings']['fallbacks'].append({'asr-gpt': ['zenmux/gpt-transcribe']})
return config
config['_public_gateway'] = True
return apply_provider_policy(config, catalog, env)
if __name__ == '__main__':
+9 -7
View File
@@ -9,13 +9,13 @@ 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'}
env = {'ZENMUX_SUBSCRIPTION_API_KEY': 'synthetic', 'ZENMUX_PAYG_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['zenmux/gpt-transcribe']['api_base'], 'https://api.openai.com/v1')
self.assertEqual(routes['asr-gpt'], routes['openai/gpt-transcribe'])
self.assertIn({'asr-gpt': ['zenmux/gpt-transcribe']}, config['router_settings']['fallbacks'])
self.assertIn({'asr-gpt': ['zenmux-payg/gpt-transcribe']}, config['router_settings']['fallbacks'])
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))
@@ -25,14 +25,14 @@ class RoutingTests(unittest.TestCase):
config = build_hk_config(catalog, {'OPENAI_API_KEY': 'synthetic'})
routes = {r['model_name']: r['litellm_params'] for r in config['model_list']}
self.assertEqual(routes['asr-gpt']['model'], 'openai/gpt-transcribe')
self.assertNotIn('zenmux/gpt-transcribe', routes)
self.assertEqual(routes['zenmux/gpt-transcribe'], routes['asr-gpt'])
self.assertNotIn({'asr-gpt': ['zenmux/gpt-transcribe']}, config['router_settings']['fallbacks'])
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(len(routes),4)
self.assertEqual(routes[1]['litellm_params']['bailian_vocabulary_path'],
'/credentials/personal-vocabulary.json')
@@ -56,10 +56,12 @@ class RoutingTests(unittest.TestCase):
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[alias]['api_key'], 'os.environ/ZENMUX_PAYG_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.assertTrue(result['router_settings']['fallbacks'])
self.assertNotIn('os.environ/ZENMUX_SUBSCRIPTION_API_KEY', yaml.safe_dump(result))
self.assertEqual(routes['auto/glm-latest']['api_key'], 'os.environ/OPENROUTER_API_KEY')
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'])
+17
View File
@@ -1,5 +1,22 @@
# tools-pve 统一模型代理
## ZenMux 订阅退役(2026-11-02)
本节覆盖下文历史的订阅优先策略。`zenmux_subscription_enabled: false`,Compose 不再
传入订阅 Key;即使旧环境中仍有该变量,renderer 也忽略它。
- Claude、DeepSeek、GLM、Kimi、MiMo:OpenRouter 优先,ZenMux BYOK/PAYG 最后备用。
- `auto/...`、无前缀兼容名使用上述顺序;旧 `zenmux/...` 为避免客户端中断保留,
现在也遵循主备,不代表仍使用 ZenMux 订阅。新配置推荐用 `auto/...`。
- `zenmux-payg/...` 显式指定 BYOK,仍可直调;不能给显式 provider 路由虚构优先级。
- Gemini 旧 ZenMux 兼容名优先使用已有 Google 路由,BYOK 最后。
- OpenAI 官方 API、Codex OAuth、xAI OAuth、Google 路由顺序不变。
- 没有其他同模型后端的路由,只能用保留的 BYOK,不做跨型号伪回退。
- BYOK 按量付费,不再受已取消订阅配额保护;跨 provider 缓存不共享。
OpenRouter Claude 合成直连探针在两台生产主机均返回条款限制类 403,
不是订阅退役是否生效的判断依据,也不能宣称首选上游已通过。
## OpenAI 官方 API 与订阅回退(2026-10-29)
官方 API key 由用户生成,经过明确授权,仅加入本 tools-pve 项目的 Arcane 私有
+1 -1
View File
@@ -34,7 +34,6 @@ services:
DATABASE_URL: postgresql://litellm:${POSTGRES_PASSWORD:?required}@postgres:5432/litellm
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:?required}
LITELLM_SALT_KEY: ${LITELLM_SALT_KEY:?required}
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:-}
@@ -51,6 +50,7 @@ services:
volumes:
- ./models.yaml:/etc/litellm/models.yaml:ro
- ./render_config.py:/etc/litellm/render_config.py:ro
- ./provider_policy.py:/etc/litellm/provider_policy.py:ro
- ./personal_transcription.py:/etc/litellm/personal_transcription.py:ro
- ./bailian_transcription.py:/etc/litellm/bailian_transcription.py:ro
- /opt/litellm/personal-vocabulary.json:/etc/litellm/personal-vocabulary.json:ro
+2 -1
View File
@@ -1,4 +1,5 @@
# 除 Google 自带 latest 别名外,本项目映射不会自行追踪上游发布。
zenmux_subscription_enabled: false
zenmux:
claude-sonnet-latest: anthropic/claude-sonnet-5.5
claude-opus-latest: anthropic/claude-opus-5.5
@@ -10,7 +11,7 @@ openrouter:
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.
# OpenRouter first; ZenMux BYOK is the final same-model fallback.
zenmux_open_models:
deepseek-flash-latest: deepseek/deepseek-v4.1-flash
deepseek-pro-latest: deepseek/deepseek-v4-pro
@@ -0,0 +1,118 @@
"""Subscription retirement: existing aliases stay usable, BYOK is terminal only."""
from copy import deepcopy
def subscription_disabled(catalog):
return catalog.get('zenmux_subscription_enabled') is False
def without_subscription(catalog, env):
if not subscription_disabled(catalog):
return env
return {key: value for key, value in env.items()
if key != 'ZENMUX_SUBSCRIPTION_API_KEY'}
def apply_provider_policy(config, catalog, env):
if not subscription_disabled(catalog):
return config
routes = config['model_list']
if any(r['litellm_params'].get('api_key') ==
'os.environ/ZENMUX_SUBSCRIPTION_API_KEY' for r in routes):
raise ValueError('Retired subscription route remains')
indexed = {r['model_name']: r for r in routes}
groups = {k: list(v) for row in config['router_settings']['fallbacks']
for k, v in row.items()}
def add(name, params):
if name in indexed:
return indexed[name]
route = {'model_name': name, 'litellm_params': deepcopy(params)}
indexed[name] = route
routes.append(route)
return route
def fallback(source, target):
if source == target or source not in indexed or target not in indexed:
return
targets = groups.setdefault(source, [])
if target not in targets:
targets.append(target)
def payg_params(model, anthropic=False):
return {'model': ('anthropic/' if anthropic else 'openai/') + model,
'api_key': 'os.environ/ZENMUX_PAYG_API_KEY',
'api_base': 'https://zenmux.ai/api/anthropic' if anthropic
else 'https://zenmux.ai/api/v1',
'max_retries': 0}
# Explicit BYOK aliases remain direct; automatic/legacy aliases prefer OpenRouter.
for family in ('zenmux', 'zenmux_open_models'):
for alias, model in catalog.get(family, {}).items():
byok = 'zenmux-payg/' + alias
if env.get('ZENMUX_PAYG_API_KEY'):
add(byok, payg_params(model, anthropic=family == 'zenmux'))
primary = None
if env.get('OPENROUTER_API_KEY'):
primary = {'model': 'openrouter/' + model,
'api_key': 'os.environ/OPENROUTER_API_KEY', 'max_retries': 0}
elif byok in indexed:
primary = indexed[byok]['litellm_params']
if primary is None:
continue
names = [alias, 'auto/' + alias, 'zenmux/' + alias]
if env.get('OPENROUTER_API_KEY'):
names += ['openrouter/' + alias]
if family == 'zenmux_open_models':
names += [alias + '-openrouter']
for name in names:
route = add(name, primary)
if route['litellm_params'].get('api_key') != 'os.environ/ZENMUX_PAYG_API_KEY':
fallback(name, byok)
# Former Gemini subscription aliases follow the existing Google primary.
for alias, model in catalog.get('zenmux_chat', {}).items():
byok = 'zenmux-payg/' + alias.replace('-zenmux', '-latest')
if env.get('ZENMUX_PAYG_API_KEY'):
add(byok, payg_params(model))
google = indexed.get('aistudio/gemini-flash-latest') or indexed.get('vertex/gemini-flash-latest')
primary = google or indexed.get(byok)
if primary:
for name in [alias, 'zenmux/' + alias.replace('-zenmux', '-latest')]:
route = add(name, primary['litellm_params'])
if route['litellm_params'].get('api_key') != 'os.environ/ZENMUX_PAYG_API_KEY':
fallback(name, byok)
# Public ASR compatibility aliases use their same-model direct provider first.
asr = {'asr-gpt': ('openai/gpt-transcribe', 'openai/gpt-transcribe'),
'asr-qwen': ('bailian-cn/qwen-audio-3.1-asr-flash', 'qwen/qwen-audio-3.1-asr-flash'),
'asr-doubao': (None, 'bytedance/doubao-seed-asr-2.0'),
'asr-mimo': (None, 'xiaomi/mimo-v2.5-asr')}
for alias, (direct, model) in asr.items():
# Do not introduce new ASR models to the LAN instance.
if not config.get('_public_gateway'):
break
byok = 'zenmux-payg/' + model.split('/', 1)[1]
if env.get('ZENMUX_PAYG_API_KEY'):
add(byok, payg_params(model))
primary = indexed.get(direct) or indexed.get(byok)
if not primary:
continue
if alias in indexed:
indexed[alias]['litellm_params'] = deepcopy(primary['litellm_params'])
else:
add(alias, primary['litellm_params'])
# Old explicit ZenMux names are compatibility aliases, no subscription calls.
add('zenmux/' + model.split('/', 1)[1], primary['litellm_params'])
if primary['litellm_params'].get('api_key') != 'os.environ/ZENMUX_PAYG_API_KEY':
fallback(alias, byok)
fallback('zenmux/' + model.split('/', 1)[1], byok)
config.pop('_public_gateway', None)
config['router_settings']['fallbacks'] = [{k: v} for k, v in groups.items() if v]
for source, targets in groups.items():
if source not in indexed or any(t not in indexed for t in targets):
raise ValueError('Dangling fallback: ' + source)
if indexed[source]['litellm_params'].get('api_key') == 'os.environ/ZENMUX_PAYG_API_KEY':
raise ValueError('BYOK must be terminal: ' + source)
return config
+4 -1
View File
@@ -5,6 +5,7 @@ from copy import deepcopy
from pathlib import Path
import yaml
from provider_policy import without_subscription, apply_provider_policy
def oauth_ready(path):
@@ -125,6 +126,7 @@ def add_openai_routes(config, catalog, env):
def build_config(catalog, env, exists, namespaced=True):
env = without_subscription(catalog, env)
routes = []
fallbacks = []
@@ -198,7 +200,8 @@ def build_config(catalog, env, exists, namespaced=True):
if namespaced:
config = add_bailian_routes(namespace_config(config), env,
'/etc/litellm/personal-vocabulary.json')
return add_openai_routes(config, catalog, env)
config = add_openai_routes(config, catalog, env)
return apply_provider_policy(config, catalog, env) if namespaced else config
if __name__ == '__main__':
@@ -0,0 +1,49 @@
import unittest
from pathlib import Path
import yaml
from render_config import build_config
class PolicyTests(unittest.TestCase):
def build(self, **env):
catalog = yaml.safe_load(Path(__file__).with_name('models.yaml').read_text())
return build_config(catalog, env, lambda _: True)
def test_subscription_is_ignored_even_if_old_env_still_exists(self):
c = self.build(ZENMUX_SUBSCRIPTION_API_KEY='retired-test')
self.assertNotIn('ZENMUX_SUBSCRIPTION_API_KEY', yaml.safe_dump(c))
self.assertFalse(any('zenmux.ai' in r['litellm_params'].get('api_base', '') for r in c['model_list']))
def test_openrouter_primary_and_byok_last_for_all_supported_aliases(self):
c = self.build(ZENMUX_SUBSCRIPTION_API_KEY='retired', ZENMUX_PAYG_API_KEY='test', OPENROUTER_API_KEY='test')
routes = {r['model_name']: r['litellm_params'] for r in c['model_list']}
groups = {k:v for row in c['router_settings']['fallbacks'] for k,v in row.items()}
for alias in ['claude-sonnet-latest', 'claude-opus-latest', 'claude-haiku-latest',
'deepseek-flash-latest', 'glm-latest', 'kimi-latest', 'mimo-flash-latest']:
for name in [alias, 'auto/'+alias, 'zenmux/'+alias, 'openrouter/'+alias]:
self.assertEqual(routes[name]['api_key'], 'os.environ/OPENROUTER_API_KEY')
self.assertEqual(groups[name][-1], 'zenmux-payg/'+alias)
self.assertEqual(routes['zenmux-payg/'+alias]['api_key'], 'os.environ/ZENMUX_PAYG_API_KEY')
for source, targets in groups.items():
self.assertTrue(all(target in routes for target in targets))
self.assertNotEqual(routes[source].get('api_key'), 'os.environ/ZENMUX_PAYG_API_KEY')
self.assertEqual(len(routes), len(c['model_list']))
def test_no_byok_key_no_dangling_fallback_and_no_subscription(self):
c = self.build(OPENROUTER_API_KEY='test', ZENMUX_SUBSCRIPTION_API_KEY='retired')
self.assertEqual(c['router_settings']['fallbacks'], [])
self.assertNotIn('ZENMUX_SUBSCRIPTION_API_KEY', yaml.safe_dump(c))
def test_oauth_official_fallback_stays_unchanged(self):
c = self.build(OPENAI_API_KEY='test', OPENROUTER_API_KEY='test', ZENMUX_PAYG_API_KEY='test')
groups = {k:v for row in c['router_settings']['fallbacks'] for k,v in row.items()}
self.assertEqual(groups['codex/gpt-sol-latest'], ['openai/gpt-sol-latest'])
def test_google_primary_before_gemini_byok(self):
c = self.build(GOOGLE_AI_STUDIO_API_KEY='test', ZENMUX_PAYG_API_KEY='test')
routes = {r['model_name']: r['litellm_params'] for r in c['model_list']}
self.assertEqual(routes['zenmux/gemini-flash-latest']['api_key'], 'os.environ/GOOGLE_AI_STUDIO_API_KEY')
if __name__ == '__main__':
unittest.main()