From cd50972093f578485922860f0da04bd52bea399b Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 11 Oct 2026 18:40:45 -0700 Subject: [PATCH] Route open models through ZenMux with OpenRouter fallback --- servers/tools-pve/litellm/README.md | 34 +++++++++++++++++++++- servers/tools-pve/litellm/models.yaml | 9 +++++- servers/tools-pve/litellm/render_config.py | 26 +++++++++++++++-- servers/tools-pve/litellm/test_config.py | 29 ++++++++++++++++++ 4 files changed, 93 insertions(+), 5 deletions(-) diff --git a/servers/tools-pve/litellm/README.md b/servers/tools-pve/litellm/README.md index 390c271..63df880 100644 --- a/servers/tools-pve/litellm/README.md +++ b/servers/tools-pve/litellm/README.md @@ -56,7 +56,7 @@ Claude 客户端优先使用原生 `/v1/messages`,保留 `cache_control`、 实测 ZenMux Sonnet 5.5 会拒绝强制 `tool_choice.type=tool/any`, 错误由上游返回;使用支持的 `auto` 模式,不在代理中静默改写调用者的要求。 -开放模型优先经 OpenRouter;首版 `models.yaml` 来自实际 provider 目录。 +开放模型已改为 ZenMux 订阅优先、OpenRouter 回退;`models.yaml` 来自实际 provider 目录。 除 Google 自带 latest 映射由其上游维护外,其他 `latest` 都是本项目手动维护的稳定别名。 Google AI Studio 使用独立 key 与模型表,已实际查询模型目录, 不把 Google OAuth/ADC 当成 AI Studio API key。 @@ -65,6 +65,38 @@ Google 三条模型为 gemini-flash-latest、gemini-flash-lite-latest、gemini-p 不要用 .145 首次生成的 tools-litellm.env 整体覆盖它,那份文件可能还含旧 provider 值。 该本机文件主要用于 master key 管理与数据库凭据恢复;提供商配置必须先从 Arcane 实查。 +## 开源模型主备路由(2026-10-12) + +| 客户端稳定别名 | 两侧具体模型 | +| --- | --- | +| `deepseek-flash-latest` | `deepseek/deepseek-v4.1-flash` | +| `deepseek-pro-latest` | `deepseek/deepseek-v4-pro` | +| `glm-latest` | `z-ai/glm-5.3-prime` | +| `kimi-latest` | `moonshotai/kimi-k3` | +| `mimo-flash-latest` | `xiaomi/mimo-v2.6-flash` | + +这些原有别名先调用 ZenMux `/api/v1`,失败后由 Router 转向同名末尾加 +`-openrouter` 的备用组;不是随机分流,不切到 PAYG,也不改变 Claude/OAuth。 +DeepSeek Pro 原 OpenRouter 日期版 `v4-pro-0813` 改为双方共有的 `v4-pro`, +两侧返回模型 ID 已核验一致,但不能仅据 ID 保证供应商权重实现完全相同。 +只配置现有五组,不把整个供应商目录自动开放。 + +缺 ZenMux key 时原别名仍直接走 OpenRouter;缺 OpenRouter key 时不生成悬空 +fallback。普通 virtual key 需同时允许主组与备用组,现有 lan-agents key +通过 `/key/update` 增补五个备用别名,无需更换 key。 + +部署所用 SDK 的回环 mock 已验证 402、403、429、500 自动回退。 +显式部署级异常策略让 429/500 在第一次失败后冷却 60 秒;402/403 仍会在 +下一请求尝试主组,不宣称已按订阅窗口熔断。Router 默认最多一次重试, +不是耗尽后无限等待。两家共享订阅额度的跨模型熔断尚未实现。 +请求开始前的失败已覆盖;流式输出中途断开不承诺无缝拼接或恰好一次执行工具。 + +ZenMux 恢复后重新优先使用它。OpenRouter 会产生其账户费用;缓存不跨 +供应商共享。上游工具/推理参数兼容性仍按调用者实际用法验收,不能只凭 +短文本成功宣称所有 Agent 工作流均兼容。 + +参考:[LiteLLM Fallbacks](https://docs.litellm.ai/docs/proxy/reliability)。 + ## 密钥 真实 `.env` 权限 `0600`,不提交 Git;模板见 `.env.example`。 diff --git a/servers/tools-pve/litellm/models.yaml b/servers/tools-pve/litellm/models.yaml index 76e5b6b..ba510ce 100644 --- a/servers/tools-pve/litellm/models.yaml +++ b/servers/tools-pve/litellm/models.yaml @@ -5,7 +5,14 @@ zenmux: claude-haiku-latest: anthropic/claude-haiku-5.5 openrouter: deepseek-flash-latest: deepseek/deepseek-v4.1-flash - deepseek-pro-latest: deepseek/deepseek-v4-pro-0813 + deepseek-pro-latest: deepseek/deepseek-v4-pro + glm-latest: z-ai/glm-5.3-prime + 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 kimi-latest: moonshotai/kimi-k3 mimo-flash-latest: xiaomi/mimo-v2.6-flash diff --git a/servers/tools-pve/litellm/render_config.py b/servers/tools-pve/litellm/render_config.py index e31a34e..09148b3 100644 --- a/servers/tools-pve/litellm/render_config.py +++ b/servers/tools-pve/litellm/render_config.py @@ -16,6 +16,7 @@ def oauth_ready(path): def build_config(catalog, env, exists): routes = [] + fallbacks = [] def add(alias, model, key=None, **params): if key: @@ -30,7 +31,26 @@ def build_config(catalog, env, exists): api_base='https://zenmux.ai/api/anthropic') if env.get('OPENROUTER_API_KEY'): for alias, model in catalog['openrouter'].items(): - add(alias, 'openrouter/' + model, 'OPENROUTER_API_KEY') + 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'): for alias, model in catalog['google_ai_studio'].items(): add(alias, 'gemini/' + model, 'GOOGLE_AI_STUDIO_API_KEY') @@ -56,8 +76,8 @@ def build_config(catalog, env, exists): 'disable_env_credential_login': True, }, 'litellm_settings': {'drop_params': False, 'set_verbose': False}, - # No cross-provider or subscription-to-PAYG fallback without an explicit decision. - 'router_settings': {'num_retries': 1, 'timeout': 180}, + # Only explicitly approved open-model routes have cross-provider fallbacks. + 'router_settings': {'num_retries': 1, 'timeout': 180, 'fallbacks': fallbacks}, } diff --git a/servers/tools-pve/litellm/test_config.py b/servers/tools-pve/litellm/test_config.py index 718c120..8b439de 100644 --- a/servers/tools-pve/litellm/test_config.py +++ b/servers/tools-pve/litellm/test_config.py @@ -54,6 +54,35 @@ class ConfigTests(unittest.TestCase): self.assertEqual(routes[0]['litellm_params']['vertex_project'], 'os.environ/VERTEXAI_PROJECT') self.assertNotIn('api_key', routes[0]['litellm_params']) + def test_open_models_subscription_first(self): + catalog = {**self.catalog, 'zenmux_open_models': self.catalog['openrouter']} + config = build_config(catalog, {'ZENMUX_SUBSCRIPTION_API_KEY': 'a', + 'OPENROUTER_API_KEY': 'b', 'ZENMUX_PAYG_API_KEY': 'c'}, lambda _: False) + routes = {r['model_name']: r for r in config['model_list']} + self.assertEqual(routes['glm-latest']['litellm_params']['model'], 'openai/z-ai/glm-5.3-prime') + self.assertEqual(routes['glm-latest-openrouter']['litellm_params']['model'], 'openrouter/z-ai/glm-5.3-prime') + self.assertEqual(config['router_settings']['fallbacks'], [{'glm-latest': ['glm-latest-openrouter']}]) + self.assertEqual(routes['glm-latest']['model_info']['cooldown_time'], 60) + self.assertNotIn('glm-latest-payg', routes) + + def test_openrouter_only_remains_usable(self): + catalog = {**self.catalog, 'zenmux_open_models': self.catalog['openrouter']} + config = build_config(catalog, {'OPENROUTER_API_KEY': 'b'}, lambda _: False) + self.assertEqual(config['model_list'][0]['litellm_params']['model'], 'openrouter/z-ai/glm-5.3-prime') + self.assertEqual(config['router_settings']['fallbacks'], []) + + def test_zenmux_only_no_broken_fallback(self): + catalog = {**self.catalog, 'zenmux_open_models': self.catalog['openrouter']} + config = build_config(catalog, {'ZENMUX_SUBSCRIPTION_API_KEY': 'a'}, lambda _: False) + self.assertIn('glm-latest', [r['model_name'] for r in config['model_list']]) + self.assertEqual(config['router_settings']['fallbacks'], []) + + def test_mismatched_model_rejected(self): + catalog = {**self.catalog, 'zenmux_open_models': {'glm-latest': 'different-model'}} + with self.assertRaises(ValueError): + build_config(catalog, {'ZENMUX_SUBSCRIPTION_API_KEY': 'a', + 'OPENROUTER_API_KEY': 'b'}, lambda _: False) + if __name__ == '__main__': unittest.main()