feat(litellm): add provider namespaces with legacy compatibility
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""Render only credential-ready routes; credentials remain environment references."""
|
||||
import os
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
@@ -14,7 +15,67 @@ def oauth_ready(path):
|
||||
return False
|
||||
|
||||
|
||||
def build_config(catalog, env, exists):
|
||||
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 build_config(catalog, env, exists, namespaced=True):
|
||||
routes = []
|
||||
fallbacks = []
|
||||
|
||||
@@ -73,7 +134,7 @@ def build_config(catalog, env, exists):
|
||||
for alias, model in catalog[provider].items():
|
||||
extra = {'use_xai_oauth': True} if provider == 'xai' else {}
|
||||
add(alias, provider + '/' + model, **extra)
|
||||
return {
|
||||
config = {
|
||||
'model_list': routes,
|
||||
'general_settings': {
|
||||
'master_key': 'os.environ/LITELLM_MASTER_KEY',
|
||||
@@ -85,6 +146,7 @@ def build_config(catalog, env, exists):
|
||||
# Only explicitly approved open-model routes have cross-provider fallbacks.
|
||||
'router_settings': {'num_retries': 1, 'timeout': 180, 'fallbacks': fallbacks},
|
||||
}
|
||||
return namespace_config(config) if namespaced else config
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -4,11 +4,11 @@ from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from provider_routes import build_config
|
||||
from provider_routes import build_config, namespace_config
|
||||
|
||||
|
||||
def build_hk_config(catalog, env):
|
||||
config = build_config(catalog, env, lambda path: False)
|
||||
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']
|
||||
@@ -47,7 +47,7 @@ def build_hk_config(catalog, env):
|
||||
'model': 'vertex_ai/' + model,
|
||||
'vertex_project': 'os.environ/VERTEXAI_PROJECT',
|
||||
'vertex_location': 'os.environ/VERTEXAI_LOCATION'}})
|
||||
return config
|
||||
return namespace_config(config)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -30,7 +30,10 @@ class RoutingTests(unittest.TestCase):
|
||||
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']), 6)
|
||||
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))
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 模型命名空间
|
||||
|
||||
2026-10-27 起新增客户端命名空间,保留旧名兼容。实际型号、协议、凭据、个人热词和 SMART 均不改变;latest 不代表自动跟踪发布。
|
||||
|
||||
| 前缀 | 含义 |
|
||||
| --- | --- |
|
||||
| zenmux/ | ZenMux 订阅直连,不跨 provider 回退 |
|
||||
| zenmux-payg/ | ZenMux PAYG,仅显式请求 |
|
||||
| openrouter/ | OpenRouter 直连 |
|
||||
| auto/ | 开源模型 ZenMux 订阅优先,故障回退 OpenRouter |
|
||||
| aistudio/ | Google AI Studio |
|
||||
| vertex/ | Google Vertex |
|
||||
| codex/ | Codex OAuth 订阅 |
|
||||
| xai/ | xAI OAuth 订阅 |
|
||||
|
||||
常规旧别名加对应前缀。特殊转换:`gemini-flash-zenmux` → `zenmux/gemini-flash-latest`;`gemini-*-vertex` → `vertex/gemini-*-latest`;`gemini-embedding-2-studio` → `aistudio/gemini-embedding-2`;`google-rerank-default/fast` → `vertex/rerank-default/fast-latest`;`gpt-work-sol` → `codex/gpt-6-sol`;`gpt-review-5.5` → `codex/gpt-5.5`。PAYG 去掉旧 `-payg` 后缀,改加 `zenmux-payg/` 前缀。
|
||||
|
||||
六组开源模型 `deepseek-flash-latest`、`deepseek-pro-latest`、`glm-latest`、`glm-flash-latest`、`kimi-latest`、`mimo-flash-latest` 各提供 auto/、zenmux/、openrouter/ 三种选择。旧自动回退名和旧 -openrouter 名保留。
|
||||
|
||||
tools-pve 的个人语音名为 `aistudio/gemini-transcribe-personal`;HK 使用 `vertex/gemini-transcribe-personal`,不能互换两端 provider。HK 四款 ZenMux ASR 别名为 `zenmux/gpt-transcribe`、`zenmux/qwen-audio-3.1-asr-flash`、`zenmux/doubao-seed-asr-2.0`、`zenmux/mimo-v2.5-asr`。HK 的四个历史 Google 兼容名保留。
|
||||
|
||||
只有凭据已就绪的路线才生成。新旧别名的 litellm_params 必须完全相同,不能把客户端前缀当作上游 provider 参数。受模型白名单限制的 virtual key 需要另行授权新名;不扩大既有 key 权限。旧名暂不删除,待所有客户端完成迁移后单独处理。
|
||||
|
||||
修改 renderer 后,通过 Arcane Git Sync 同步,并重启对应 LiteLLM 进程使 /tmp/litellm.yaml 重建;仅同步代码不代表进程已加载。逐节点确认 /v1/models、新旧路由参数等价、fallback 配置及实际请求。
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Render only credential-ready routes; credentials remain environment references."""
|
||||
import os
|
||||
import json
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
@@ -14,7 +15,67 @@ def oauth_ready(path):
|
||||
return False
|
||||
|
||||
|
||||
def build_config(catalog, env, exists):
|
||||
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 build_config(catalog, env, exists, namespaced=True):
|
||||
routes = []
|
||||
fallbacks = []
|
||||
|
||||
@@ -73,7 +134,7 @@ def build_config(catalog, env, exists):
|
||||
for alias, model in catalog[provider].items():
|
||||
extra = {'use_xai_oauth': True} if provider == 'xai' else {}
|
||||
add(alias, provider + '/' + model, **extra)
|
||||
return {
|
||||
config = {
|
||||
'model_list': routes,
|
||||
'general_settings': {
|
||||
'master_key': 'os.environ/LITELLM_MASTER_KEY',
|
||||
@@ -85,6 +146,7 @@ def build_config(catalog, env, exists):
|
||||
# Only explicitly approved open-model routes have cross-provider fallbacks.
|
||||
'router_settings': {'num_retries': 1, 'timeout': 180, 'fallbacks': fallbacks},
|
||||
}
|
||||
return namespace_config(config) if namespaced else config
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import unittest
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from render_config import build_config, oauth_ready
|
||||
@@ -17,6 +18,29 @@ class ConfigTests(unittest.TestCase):
|
||||
def test_missing_credentials_exclude_routes(self):
|
||||
self.assertEqual(build_config(self.catalog, {}, lambda _: False)['model_list'], [])
|
||||
|
||||
def test_namespaces_preserve_every_legacy_route(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'
|
||||
old = build_config(catalog, env, lambda _: True, namespaced=False)
|
||||
new = build_config(catalog, env, lambda _: True)
|
||||
routes = {r['model_name']: r for r in new['model_list']}
|
||||
self.assertEqual(len(routes), len(new['model_list']))
|
||||
for route in old['model_list']:
|
||||
self.assertEqual(route, routes[route['model_name']])
|
||||
for old_name, new_name in {
|
||||
'claude-sonnet-latest': 'zenmux/claude-sonnet-latest',
|
||||
'claude-opus-latest-payg': 'zenmux-payg/claude-opus-latest',
|
||||
'grok-latest': 'xai/grok-latest', 'gpt-sol-latest': 'codex/gpt-sol-latest',
|
||||
'gpt-work-sol': 'codex/gpt-6-sol', 'gpt-review-5.5': 'codex/gpt-5.5',
|
||||
'gemini-transcribe-personal': 'aistudio/gemini-transcribe-personal',
|
||||
'gemini-embedding-2-studio': 'aistudio/gemini-embedding-2',
|
||||
'google-rerank-fast': 'vertex/rerank-fast-latest',
|
||||
}.items():
|
||||
self.assertEqual(routes[old_name]['litellm_params'], routes[new_name]['litellm_params'])
|
||||
|
||||
def test_env_ui_login_disabled(self):
|
||||
settings = build_config(self.catalog, {}, lambda _: False)['general_settings']
|
||||
self.assertTrue(settings['disable_env_credential_login'])
|
||||
@@ -32,7 +56,7 @@ class ConfigTests(unittest.TestCase):
|
||||
|
||||
def test_oauth_requires_persistent_file(self):
|
||||
routes = build_config(self.catalog, {}, lambda path: str(path) == '/tokens/xai/auth.json')['model_list']
|
||||
self.assertEqual(len(routes), 1)
|
||||
self.assertEqual(len(routes), 2)
|
||||
self.assertTrue(routes[0]['litellm_params']['use_xai_oauth'])
|
||||
self.assertNotIn('api_key', routes[0]['litellm_params'])
|
||||
|
||||
@@ -61,7 +85,12 @@ class ConfigTests(unittest.TestCase):
|
||||
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(config['router_settings']['fallbacks'], [
|
||||
{'glm-latest': ['glm-latest-openrouter']},
|
||||
{'auto/glm-latest': ['openrouter/glm-latest']}])
|
||||
self.assertEqual(routes['auto/glm-latest']['litellm_params'], routes['glm-latest']['litellm_params'])
|
||||
self.assertEqual(routes['zenmux/glm-latest']['litellm_params'], routes['glm-latest']['litellm_params'])
|
||||
self.assertNotIn('zenmux/glm-latest', str(config['router_settings']['fallbacks']))
|
||||
self.assertEqual(routes['glm-latest']['model_info']['cooldown_time'], 60)
|
||||
self.assertNotIn('glm-latest-payg', routes)
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# 模型命名空间
|
||||
|
||||
2026-10-27 起新增客户端命名空间,保留旧名兼容。实际型号、协议、凭据、个人热词和 SMART 均不改变;latest 不代表自动跟踪发布。
|
||||
|
||||
| 前缀 | 含义 |
|
||||
| --- | --- |
|
||||
| zenmux/ | ZenMux 订阅直连,不跨 provider 回退 |
|
||||
| zenmux-payg/ | ZenMux PAYG,仅显式请求 |
|
||||
| openrouter/ | OpenRouter 直连 |
|
||||
| auto/ | 开源模型 ZenMux 订阅优先,故障回退 OpenRouter |
|
||||
| aistudio/ | Google AI Studio |
|
||||
| vertex/ | Google Vertex |
|
||||
| codex/ | Codex OAuth 订阅 |
|
||||
| xai/ | xAI OAuth 订阅 |
|
||||
|
||||
常规旧别名加对应前缀。特殊转换:`gemini-flash-zenmux` → `zenmux/gemini-flash-latest`;`gemini-*-vertex` → `vertex/gemini-*-latest`;`gemini-embedding-2-studio` → `aistudio/gemini-embedding-2`;`google-rerank-default/fast` → `vertex/rerank-default/fast-latest`;`gpt-work-sol` → `codex/gpt-6-sol`;`gpt-review-5.5` → `codex/gpt-5.5`。PAYG 去掉旧 `-payg` 后缀,改加 `zenmux-payg/` 前缀。
|
||||
|
||||
六组开源模型 `deepseek-flash-latest`、`deepseek-pro-latest`、`glm-latest`、`glm-flash-latest`、`kimi-latest`、`mimo-flash-latest` 各提供 auto/、zenmux/、openrouter/ 三种选择。旧自动回退名和旧 -openrouter 名保留。
|
||||
|
||||
tools-pve 的个人语音名为 `aistudio/gemini-transcribe-personal`;HK 使用 `vertex/gemini-transcribe-personal`,不能互换两端 provider。HK 四款 ZenMux ASR 别名为 `zenmux/gpt-transcribe`、`zenmux/qwen-audio-3.1-asr-flash`、`zenmux/doubao-seed-asr-2.0`、`zenmux/mimo-v2.5-asr`。HK 的四个历史 Google 兼容名保留。
|
||||
|
||||
只有凭据已就绪的路线才生成。新旧别名的 litellm_params 必须完全相同,不能把客户端前缀当作上游 provider 参数。受模型白名单限制的 virtual key 需要另行授权新名;不扩大既有 key 权限。旧名暂不删除,待所有客户端完成迁移后单独处理。
|
||||
|
||||
修改 renderer 后,通过 Arcane Git Sync 同步,并重启对应 LiteLLM 进程使 /tmp/litellm.yaml 重建;仅同步代码不代表进程已加载。逐节点确认 /v1/models、新旧路由参数等价、fallback 配置及实际请求。
|
||||
Reference in New Issue
Block a user