diff --git a/servers/bwh-us-dc1/litellm/README.md b/servers/bwh-us-dc1/litellm/README.md index c77ba73..4163e14 100644 --- a/servers/bwh-us-dc1/litellm/README.md +++ b/servers/bwh-us-dc1/litellm/README.md @@ -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。 diff --git a/servers/bwh-us-dc1/litellm/compose.yaml b/servers/bwh-us-dc1/litellm/compose.yaml index d7a612a..28caa8b 100644 --- a/servers/bwh-us-dc1/litellm/compose.yaml +++ b/servers/bwh-us-dc1/litellm/compose.yaml @@ -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 diff --git a/servers/bwh-us-dc1/litellm/models.yaml b/servers/bwh-us-dc1/litellm/models.yaml index ee12d56..0ff99fb 100644 --- a/servers/bwh-us-dc1/litellm/models.yaml +++ b/servers/bwh-us-dc1/litellm/models.yaml @@ -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 diff --git a/servers/bwh-us-dc1/litellm/provider_policy.py b/servers/bwh-us-dc1/litellm/provider_policy.py new file mode 100644 index 0000000..01a3c9b --- /dev/null +++ b/servers/bwh-us-dc1/litellm/provider_policy.py @@ -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 diff --git a/servers/bwh-us-dc1/litellm/provider_routes.py b/servers/bwh-us-dc1/litellm/provider_routes.py index 8777d1f..59de821 100644 --- a/servers/bwh-us-dc1/litellm/provider_routes.py +++ b/servers/bwh-us-dc1/litellm/provider_routes.py @@ -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 = [] diff --git a/servers/bwh-us-dc1/litellm/render_config.py b/servers/bwh-us-dc1/litellm/render_config.py index 6d027e8..627a8a9 100644 --- a/servers/bwh-us-dc1/litellm/render_config.py +++ b/servers/bwh-us-dc1/litellm/render_config.py @@ -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__': diff --git a/servers/bwh-us-dc1/litellm/test_config.py b/servers/bwh-us-dc1/litellm/test_config.py index a0ba76c..1700d3d 100644 --- a/servers/bwh-us-dc1/litellm/test_config.py +++ b/servers/bwh-us-dc1/litellm/test_config.py @@ -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']) diff --git a/servers/tools-pve/litellm/README.md b/servers/tools-pve/litellm/README.md index 7568830..d5c9aa2 100644 --- a/servers/tools-pve/litellm/README.md +++ b/servers/tools-pve/litellm/README.md @@ -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 私有 diff --git a/servers/tools-pve/litellm/compose.yaml b/servers/tools-pve/litellm/compose.yaml index ff9b320..042874f 100644 --- a/servers/tools-pve/litellm/compose.yaml +++ b/servers/tools-pve/litellm/compose.yaml @@ -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 diff --git a/servers/tools-pve/litellm/models.yaml b/servers/tools-pve/litellm/models.yaml index 31bbc60..e3da94b 100644 --- a/servers/tools-pve/litellm/models.yaml +++ b/servers/tools-pve/litellm/models.yaml @@ -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 diff --git a/servers/tools-pve/litellm/provider_policy.py b/servers/tools-pve/litellm/provider_policy.py new file mode 100644 index 0000000..01a3c9b --- /dev/null +++ b/servers/tools-pve/litellm/provider_policy.py @@ -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 diff --git a/servers/tools-pve/litellm/render_config.py b/servers/tools-pve/litellm/render_config.py index 97f27e9..14bec6b 100644 --- a/servers/tools-pve/litellm/render_config.py +++ b/servers/tools-pve/litellm/render_config.py @@ -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__': diff --git a/servers/tools-pve/litellm/test_provider_policy.py b/servers/tools-pve/litellm/test_provider_policy.py new file mode 100644 index 0000000..f03e71b --- /dev/null +++ b/servers/tools-pve/litellm/test_provider_policy.py @@ -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()