feat(tools-pve): prepare LiteLLM gateway with persistent OAuth routes
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
LISTEN_ADDRESS=192.168.50.49
|
||||||
|
LITELLM_PORT=4000
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
LITELLM_MASTER_KEY=
|
||||||
|
LITELLM_SALT_KEY=
|
||||||
|
UI_USERNAME=admin
|
||||||
|
UI_PASSWORD=
|
||||||
|
ZENMUX_SUBSCRIPTION_API_KEY=
|
||||||
|
ZENMUX_PAYG_API_KEY=
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
GOOGLE_AI_STUDIO_API_KEY=
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# tools-pve 统一模型代理
|
||||||
|
|
||||||
|
## 部署状态
|
||||||
|
|
||||||
|
2026-10-09:配置已准备,LiteLLM 1.104.2 镜像 OAuth 模块检查通过。
|
||||||
|
Arcane Manager 当前无法连接;尚未启动本项目,也尚未授权 OAuth。
|
||||||
|
不能将本说明中的计划地址当作已上线资产。
|
||||||
|
|
||||||
|
## 架构与入口
|
||||||
|
|
||||||
|
计划部署在 `192.168.50.49`,LiteLLM 与独立 PostgreSQL 17。
|
||||||
|
|
||||||
|
- OpenAI 兼容客户端:`http://192.168.50.49:4000/v1`。
|
||||||
|
- Anthropic SDK:`http://192.168.50.49:4000`,请求 `/v1/messages`。
|
||||||
|
- 管理界面:`http://192.168.50.49:4000/ui`。
|
||||||
|
- PostgreSQL 不发布主机端口。
|
||||||
|
- named volume:`tools-litellm-database`、`tools-litellm-oauth`。
|
||||||
|
|
||||||
|
只使用单个代理 worker,降低 OAuth refresh token 竞争的风险。
|
||||||
|
所有配置从 Git 生成,不允许 UI 中的模型配置成为另一份权威。
|
||||||
|
未提供 key 或未完成 OAuth 登录的路线不会进入运行配置。
|
||||||
|
|
||||||
|
## Provider 与缓存
|
||||||
|
|
||||||
|
ZenMux Claude 使用 Anthropic Messages 上游:
|
||||||
|
`https://zenmux.ai/api/anthropic`。
|
||||||
|
LiteLLM 的 `anthropic/anthropic/claude-sonnet-5.5` 第一个前缀是
|
||||||
|
LiteLLM provider,第二个前缀是 ZenMux 的模型 ID,不是笔误。
|
||||||
|
|
||||||
|
订阅路线为 `claude-sonnet-latest`、`claude-opus-latest`、
|
||||||
|
`claude-haiku-latest`;PAYG 路线在同名末尾加 `-payg`。
|
||||||
|
不自动由订阅切换到 PAYG,也不设置跨模型 fallback。
|
||||||
|
|
||||||
|
Claude 客户端优先使用原生 `/v1/messages`,保留 `cache_control`、
|
||||||
|
稳定的 tools/system 前缀以及原样回传的 thinking/signature。
|
||||||
|
不能保证每次 cache hit;首次创建、最小缓存长度、TTL 和上游路由都会影响结果。
|
||||||
|
上线验收需重复发送足够长且完全相同的前缀,检查
|
||||||
|
`cache_creation_input_tokens` 和 `cache_read_input_tokens`,不能只看 HTTP 200。
|
||||||
|
不把 LiteLLM 的响应缓存当作 Anthropic prompt cache。
|
||||||
|
|
||||||
|
开放模型优先经 OpenRouter;首版 `models.yaml` 来自实际 provider 目录。
|
||||||
|
`latest` 目前只是手动维护的稳定别名,没有自动追踪新版本。
|
||||||
|
Google AI Studio 预留独立 key 与模型表;取得有效 key 后查询目录再添加,
|
||||||
|
不把 Google OAuth/ADC 当成 AI Studio API key。
|
||||||
|
|
||||||
|
## 密钥
|
||||||
|
|
||||||
|
真实 `.env` 权限 `0600`,不提交 Git;模板见 `.env.example`。
|
||||||
|
ZenMux subscription/PAYG 的 key 需要明确确认用途,不能按外观推断。
|
||||||
|
数据库密码使用无特殊 URL 字符的随机值,避免 `DATABASE_URL` 编码问题。
|
||||||
|
`LITELLM_MASTER_KEY` 以 `sk-` 开头,salt 与 UI 密码独立随机生成。
|
||||||
|
日常客户端应使用管理界面创建的应用专属 virtual key,不共享 master key。
|
||||||
|
不要开启输出请求全文的 debug 日志。
|
||||||
|
|
||||||
|
## 独立 OAuth 登录
|
||||||
|
|
||||||
|
不复制 Codex CLI、Grok CLI 或 Hermes 的 refresh token。
|
||||||
|
在目标主机项目目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash oauth-login.sh codex
|
||||||
|
```
|
||||||
|
|
||||||
|
打开终端显示的设备验证网址并输入验证码。
|
||||||
|
|
||||||
|
xAI 需要本机浏览器回调。先在浏览器所在电脑保持 SSH 隧道:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -N -L 56121:127.0.0.1:56121 tools@192.168.50.49
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在 tools-pve 的另一个终端运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash oauth-login.sh xai
|
||||||
|
```
|
||||||
|
|
||||||
|
打开输出的授权 URL;若 56121 被占用,先关闭占用者再重试,
|
||||||
|
不要把临时改用的端口当作已有隧道可达。
|
||||||
|
登录 helper 使用 host network 仅供本地 OAuth 回调,生产代理仍使用独立 bridge。
|
||||||
|
|
||||||
|
确认账号实际支持的模型 ID 后,在 `models.yaml` 的 `chatgpt`/`xai` 表添加别名。
|
||||||
|
Codex 路线由 `chatgpt/` 访问,优先 `/v1/responses`;
|
||||||
|
xAI 路线使用 `xai/` 与 `use_xai_oauth: true`,不能设置 XAI API key 覆盖它。
|
||||||
|
新增 token 后需重启代理,运行时 refresh 会写回同一个持久卷。
|
||||||
|
|
||||||
|
## 部署及验收
|
||||||
|
|
||||||
|
正常走 Arcane Git Sync;当前 Manager 不可达,不绕过管理层静默部署。
|
||||||
|
如用户明确同意临时直接部署,将本目录放到 tools-pve,准备 `.env` 后执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose config --quiet
|
||||||
|
docker compose up -d
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复 Arcane 后应纳管这套已有卷和项目,避免生成第二套数据库。
|
||||||
|
上线必须验证健康、带 virtual key 的模型列表、实际请求、stream 和工具调用;
|
||||||
|
OAuth 与 Claude cache hit 分别验收,未通过不能写成可用。
|
||||||
|
|
||||||
|
回滚只停止新增项目或回退配置/image pin,不删除 named volume。
|
||||||
|
定期 `pg_dump` 并保留独立备份,数据库卷不是备份。
|
||||||
|
|
||||||
|
参考:[LiteLLM ChatGPT OAuth](https://docs.litellm.ai/docs/providers/chatgpt)、
|
||||||
|
[xAI OAuth 官方源码](https://github.com/BerriAI/litellm/blob/main/litellm/llms/xai/oauth.py)、
|
||||||
|
[ZenMux prompt cache](https://zenmux.ai/docs/guide/advanced/prompt-cache.html)。
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: tools-litellm
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine@sha256:b0f9560a2de083e2cc7382e75f808c7381a32852a7ec49117deedb300e552b24
|
||||||
|
container_name: tools-litellm-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: litellm
|
||||||
|
POSTGRES_USER: litellm
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?required}
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
volumes:
|
||||||
|
- database:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD-SHELL, 'pg_isready -U litellm -d litellm']
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: {max-size: 10m, max-file: "3"}
|
||||||
|
|
||||||
|
litellm:
|
||||||
|
image: ghcr.io/berriai/litellm:main-stable@sha256:3d88bd757134a00e8c00237b85a021ab1f6d54abb9c1df750f411dbe7256a500
|
||||||
|
container_name: tools-litellm
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- '${LISTEN_ADDRESS:-192.168.50.49}:${LITELLM_PORT:-4000}:4000'
|
||||||
|
environment:
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
DATABASE_URL: postgresql://litellm:${POSTGRES_PASSWORD:?required}@postgres:5432/litellm
|
||||||
|
LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:?required}
|
||||||
|
LITELLM_SALT_KEY: ${LITELLM_SALT_KEY:?required}
|
||||||
|
UI_USERNAME: ${UI_USERNAME:-admin}
|
||||||
|
UI_PASSWORD: ${UI_PASSWORD:?required}
|
||||||
|
ZENMUX_SUBSCRIPTION_API_KEY: ${ZENMUX_SUBSCRIPTION_API_KEY:-}
|
||||||
|
ZENMUX_PAYG_API_KEY: ${ZENMUX_PAYG_API_KEY:-}
|
||||||
|
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||||
|
GOOGLE_AI_STUDIO_API_KEY: ${GOOGLE_AI_STUDIO_API_KEY:-}
|
||||||
|
CHATGPT_TOKEN_DIR: /tokens/chatgpt
|
||||||
|
XAI_OAUTH_TOKEN_DIR: /tokens/xai
|
||||||
|
LITELLM_LOG: WARNING
|
||||||
|
volumes:
|
||||||
|
- ./models.yaml:/etc/litellm/models.yaml:ro
|
||||||
|
- ./render_config.py:/etc/litellm/render_config.py:ro
|
||||||
|
- oauth:/tokens
|
||||||
|
entrypoint: [/bin/sh, -c]
|
||||||
|
command:
|
||||||
|
- 'python /etc/litellm/render_config.py && exec litellm --config /tmp/litellm.yaml --host 0.0.0.0 --port 4000 --num_workers 1'
|
||||||
|
healthcheck:
|
||||||
|
test: [CMD, python, -c, 'import urllib.request; urllib.request.urlopen("http://127.0.0.1:4000/health/liveliness", timeout=5)']
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 6
|
||||||
|
start_period: 120s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: {max-size: 10m, max-file: "3"}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
database:
|
||||||
|
name: tools-litellm-database
|
||||||
|
oauth:
|
||||||
|
name: tools-litellm-oauth
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# 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-0813
|
||||||
|
glm-latest: z-ai/glm-5.3-prime
|
||||||
|
kimi-latest: moonshotai/kimi-k3
|
||||||
|
mimo-flash-latest: xiaomi/mimo-v2.6-flash
|
||||||
|
google_ai_studio: {}
|
||||||
|
# OAuth 模型需完成独立登录,并根据账号实际模型目录确认后启用。
|
||||||
|
chatgpt: {}
|
||||||
|
xai: {}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
IMAGE='ghcr.io/berriai/litellm:main-stable@sha256:3d88bd757134a00e8c00237b85a021ab1f6d54abb9c1df750f411dbe7256a500'
|
||||||
|
case "${1:-}" in
|
||||||
|
codex)
|
||||||
|
CODE='from litellm.llms.chatgpt.authenticator import Authenticator; Authenticator().get_access_token(); print("Codex login complete")'
|
||||||
|
;;
|
||||||
|
xai)
|
||||||
|
CODE='from litellm.llms.xai.oauth import XAIOAuthAuthenticator; XAIOAuthAuthenticator().login(no_browser=True); print("xAI login complete")'
|
||||||
|
;;
|
||||||
|
*) printf 'Usage: bash oauth-login.sh codex|xai\n' >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
# The helper shares only the proxy token volume, not its API keys or database.
|
||||||
|
sudo docker run --rm -it --network host \
|
||||||
|
--mount type=volume,src=tools-litellm-oauth,dst=/tokens \
|
||||||
|
-e CHATGPT_TOKEN_DIR=/tokens/chatgpt -e XAI_OAUTH_TOKEN_DIR=/tokens/xai \
|
||||||
|
--entrypoint python "$IMAGE" -c "$CODE"
|
||||||
|
printf 'Login persisted. Verify model IDs, then restart only the litellm service.\n'
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Render only credential-ready routes; credentials remain environment references."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def build_config(catalog, env, exists):
|
||||||
|
routes = []
|
||||||
|
|
||||||
|
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('OPENROUTER_API_KEY'):
|
||||||
|
for alias, model in catalog['openrouter'].items():
|
||||||
|
add(alias, 'openrouter/' + model, 'OPENROUTER_API_KEY')
|
||||||
|
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')
|
||||||
|
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)
|
||||||
|
return {
|
||||||
|
'model_list': routes,
|
||||||
|
'general_settings': {
|
||||||
|
'master_key': 'os.environ/LITELLM_MASTER_KEY',
|
||||||
|
'database_url': 'os.environ/DATABASE_URL',
|
||||||
|
'store_model_in_db': False,
|
||||||
|
},
|
||||||
|
'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},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
catalog = yaml.safe_load(Path('/etc/litellm/models.yaml').read_text())
|
||||||
|
config = build_config(catalog, os.environ, Path.is_file)
|
||||||
|
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']))
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import unittest
|
||||||
|
from render_config import build_config
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigTests(unittest.TestCase):
|
||||||
|
catalog = {
|
||||||
|
'zenmux': {'claude-sonnet-latest': 'anthropic/claude-sonnet-5.5'},
|
||||||
|
'openrouter': {'glm-latest': 'z-ai/glm-5.3-prime'},
|
||||||
|
'google_ai_studio': {'gemini-latest': 'gemini-test'},
|
||||||
|
'chatgpt': {'codex-latest': 'gpt-test'},
|
||||||
|
'xai': {'grok-latest': 'grok-test'},
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_missing_credentials_exclude_routes(self):
|
||||||
|
self.assertEqual(build_config(self.catalog, {}, lambda _: False)['model_list'], [])
|
||||||
|
|
||||||
|
def test_subscription_preserves_vendor_prefix(self):
|
||||||
|
routes = build_config(self.catalog, {'ZENMUX_SUBSCRIPTION_API_KEY': 'secret'}, lambda _: False)['model_list']
|
||||||
|
self.assertEqual(routes[0]['litellm_params']['model'], 'anthropic/anthropic/claude-sonnet-5.5')
|
||||||
|
self.assertNotIn('secret', str(routes))
|
||||||
|
|
||||||
|
def test_payg_separate_alias(self):
|
||||||
|
routes = build_config(self.catalog, {'ZENMUX_PAYG_API_KEY': 'secret'}, lambda _: False)['model_list']
|
||||||
|
self.assertEqual(routes[0]['model_name'], 'claude-sonnet-latest-payg')
|
||||||
|
|
||||||
|
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.assertTrue(routes[0]['litellm_params']['use_xai_oauth'])
|
||||||
|
self.assertNotIn('api_key', routes[0]['litellm_params'])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user