Skip to content

Commit f655e3f

Browse files
committed
fix(coding): 使用 uv 管理窗口预热依赖
1 parent 3bbef49 commit f655e3f

14 files changed

Lines changed: 1941 additions & 63 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,5 @@ vitest-report.xml
4949

5050

5151
# python
52-
*.pyc
52+
*.pyc
53+
.venv/

.trellis/spec/infra/coding-plan-window-warmer.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,16 @@
1515
### 2. Signatures
1616

1717
- Direct run:
18-
- `uv run --script ai/coding/window-warmer/window_warmer.py --config ai/coding/window-warmer/window-warmer.toml`
19-
- `uv run --script ai/coding/window-warmer/window_warmer.py --config ai/coding/window-warmer/window-warmer.toml --print-next`
20-
- `uv run --script ai/coding/window-warmer/window_warmer.py --config ai/coding/window-warmer/window-warmer.toml --once --dry-run`
18+
- From `ai/coding/window-warmer`: `uv run python window_warmer.py --config window-warmer.toml`
19+
- From `ai/coding/window-warmer`: `uv run python window_warmer.py --config window-warmer.toml --print-next`
20+
- From `ai/coding/window-warmer`: `uv run python window_warmer.py --config window-warmer.toml --once --dry-run`
2121
- PM2:
2222
- `pm2 start ai/coding/window-warmer/window-warmer.pm2.config.cjs`
2323
- PM2 app name: `coding-window-warmer`
2424
- Python script:
2525
- Entry file: `ai/coding/window-warmer/window_warmer.py`
26-
- Dependency declaration: PEP 723 script metadata with `litellm>=1.81.0`
26+
- Dependency declaration: `ai/coding/window-warmer/pyproject.toml`
27+
- Locked dependencies: `ai/coding/window-warmer/uv.lock`
2728
- Helper package: `ai/coding/window-warmer/window_warmer_lib/`
2829

2930
### 3. Contracts
@@ -33,7 +34,7 @@
3334
- `base_url`: direct upstream OpenAI-compatible API base URL. Default points to `https://open.bigmodel.cn/api/coding/paas/v4`, not local LiteLLM Proxy.
3435
- `container_name`: optional local Docker readiness gate. When set to `litellm`, it only proves the local gateway container is running; it must not change the warm request destination.
3536
- `api_key_env`: optional environment variable for upstream API key. Default for Z.ai Coding Plan is `Z_AI_API_KEY`.
36-
- `env_file`: optional dotenv-style file path, resolved relative to the TOML file.
37+
- `env_file`: optional dotenv file path, resolved relative to the TOML file. Default is `.env.local` in the warmer directory.
3738
- `health_path`: optional direct target health path. Default is `/models`.
3839
- `request_timeout_seconds`: timeout used by health check and LiteLLM SDK completion.
3940
- Plan config `[[plans]]`:
@@ -65,7 +66,8 @@
6566
### 5. Good/Base/Bad Cases
6667

6768
- Good: Default config checks optional local `litellm` container but sends `openai/GLM-5.1` to `https://open.bigmodel.cn/api/coding/paas/v4` through LiteLLM SDK.
68-
- Good: `uv run --script` handles LiteLLM SDK dependency without creating repo-level `requirements.txt`, `pyproject.toml`, or a committed virtual environment.
69+
- Good: `uv add litellm` records the direct dependency in tool-local `pyproject.toml` and locks it in `uv.lock`; `uv run` syncs the environment before execution.
70+
- Good: Put real API keys in ignored `.env.local`; commit only `.env.example`.
6971
- Good: Time calculation is pure and unit-tested separately from HTTP/LiteLLM SDK calls.
7072
- Base: `fixed_times = ["08:00", "13:00", "18:00", "23:00"]` with `jitter_seconds = 120` schedules each event within two minutes after the base time.
7173
- Bad: Pointing `[target].base_url` at `http://127.0.0.1:34000` for default GLM warmup, because the request can enter LiteLLM Proxy fallback chains.
@@ -85,8 +87,8 @@
8587
- prompt, max tokens, temperature and timeout are passed.
8688
- Dry-run test asserting readiness checks are skipped.
8789
- Smoke commands:
88-
- `uv run --script ai/coding/window-warmer/window_warmer.py --config ai/coding/window-warmer/window-warmer.toml --print-next`
89-
- `uv run --script ai/coding/window-warmer/window_warmer.py --config ai/coding/window-warmer/window-warmer.toml --once --dry-run`
90+
- From `ai/coding/window-warmer`: `uv run python window_warmer.py --config window-warmer.toml --print-next`
91+
- From `ai/coding/window-warmer`: `uv run python window_warmer.py --config window-warmer.toml --once --dry-run`
9092
- `node -c ai/coding/window-warmer/window-warmer.pm2.config.cjs`
9193

9294
### 7. Wrong vs Correct
@@ -115,6 +117,7 @@ name = "z-ai-coding-plan"
115117
base_url = "https://open.bigmodel.cn/api/coding/paas/v4"
116118
container_name = "litellm"
117119
api_key_env = "Z_AI_API_KEY"
120+
env_file = ".env.local"
118121
health_path = "/models"
119122

120123
[[plans]]

.trellis/tasks/05-13-litellm-coding-plan-window-warmer/prd.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
* 用户选择长期 watch 模式:脚本启动后常驻运行,自行等待并触发后续预热。
1515
* 脚本需要支持窗口配置以适配多种套餐:一种按“指定开始时间 + 窗口时长”推导后续发送时间;另一种精确配置发送时间点。
1616
* 用户最初希望尽量减少标准库之外的依赖;后续明确可以引入 LiteLLM SDK,并用 `uv` 安装和运行依赖。
17-
* 用户不打算创建传统 venv 或独立 Python 项目;目标是一个可通过 `uv run --script` 直接运行的脚本工具。
17+
* 用户接受在工具目录内使用 uv 管理依赖;目标是一个可通过 `uv run` 直接运行的轻量脚本工具。
18+
* 用户询问模型与 API key 如何加载;当前约定是模型写入 `[[plans]].model`,API key 按 `api_key_env` 先读进程环境变量,再读 `env_file` 指向的 dotenv 文件。
1819
* 本机 `python3 --version` 为 3.13.5,可使用标准库 `tomllib` 读取 TOML 配置。
1920
* 脚本在宿主机运行,不进入容器;因此它不能天然随 Docker 容器启动,除非由 `start.ps1`、手动命令或宿主机计划任务显式启动。
2021
* 用户倾向用 PM2 启动和管理长期脚本进程,方便查看日志、重启和开机恢复。
@@ -57,11 +58,12 @@
5758
* 配置必须支持多个 `[[plans]]`,每个 plan 独立配置 model、prompt、调度和重试;脚本合并所有 plan 的下一次触发时间统一调度。
5859
* 两种调度模式都应叠加随机偏移窗口,默认整点后 `0-120` 秒。
5960
* 调度计算应能跨天运行,避免 23 点之后无法正确计算次日首个窗口。
60-
* 脚本使用 LiteLLM Python SDK 处理 OpenAI 兼容 completion 调用,通过 PEP 723 script metadata 和 `uv run --script` 管理依赖。
61+
* 脚本使用 LiteLLM Python SDK 处理 OpenAI 兼容 completion 调用,通过工具目录内 `pyproject.toml``uv.lock` 管理依赖。
6162
* 预热相关文件应集中放在 `ai/coding/window-warmer/`,避免堆在 LiteLLM 网关目录。
6263
* 配置文件建议使用 TOML,例如 `ai/coding/window-warmer/window-warmer.toml`
63-
* 不创建 `requirements.txt``pyproject.toml` 或仓库级虚拟环境;脚本入口和模块化 helper 作为轻量工具维护。
64-
* 启动方式采用 PM2 管理宿主机脚本,PM2 调用 `uv run --script`
64+
* 密钥文件使用同目录 `.env.local`,只提交 `.env.example`,不提交真实密钥。
65+
* 不创建仓库级虚拟环境;脚本入口和模块化 helper 作为轻量工具维护。
66+
* 启动方式采用 PM2 管理宿主机脚本,PM2 在工具目录内调用 `uv run python window_warmer.py`
6567
* 仓库内提供 PM2 ecosystem 配置文件,减少用户手写启动命令的概率。
6668

6769
## Acceptance Criteria (evolving)
@@ -78,7 +80,7 @@
7880
* [ ] `fixed_times` 模式可按每日时间点列表触发预热。
7981
* [ ] 多个 `[[plans]]` 可以并存,且脚本会分别触发每个 plan 的预热请求。
8082
* [ ] 长期 watch 模式可从当前时间计算下一次触发时间,并支持跨天。
81-
* [ ] 脚本可以通过 `uv run --script ai/coding/window-warmer/window_warmer.py` 直接启动并自动准备 LiteLLM SDK 依赖。
83+
* [ ] 脚本可以在 `ai/coding/window-warmer/` 下通过 `uv run python window_warmer.py` 直接启动并自动准备 LiteLLM SDK 依赖。
8284
* [ ] 文档提供 PM2 启动、查看日志、重启、停止和持久化命令。
8385
* [ ] 仓库内 PM2 ecosystem 配置可直接启动默认 warmer。
8486
* [ ] PM2 管理脚本时,脚本仍会在每次发送前检查配置的 Docker 前置条件和直连 API 健康端点。
@@ -226,12 +228,12 @@ MVP 实现中,`[[plans]]` 内使用 `schedule_mode`、`start_time`、`window`
226228

227229
* 命令示例:`pm2 start ai/coding/window-warmer/window-warmer.pm2.config.cjs`
228230
* 常用操作:`pm2 logs coding-window-warmer``pm2 restart coding-window-warmer``pm2 stop coding-window-warmer``pm2 save`
229-
* 优点:不创建仓库级 Python 项目;依赖由 `uv run --script` 准备;进程重启、日志、开机恢复交给 PM2。
231+
* 优点:依赖由工具目录内 `pyproject.toml` / `uv.lock` 管理;进程重启、日志、开机恢复交给 PM2。
230232
* 缺点:需要用户本机已有 PM2 与 uv。
231233

232234
**Option B: 手动 / 前台运行**
233235

234-
* 命令:`uv run --script ai/coding/window-warmer/window_warmer.py --config ai/coding/window-warmer/window-warmer.toml`
236+
* 命令:`cd ai/coding/window-warmer && uv run python window_warmer.py --config window-warmer.toml`
235237
* 优点:最少魔法,日志直接在终端可见,停止方式清晰。
236238
* 缺点:用户需要单独启动这个脚本。
237239

.trellis/tasks/05-13-litellm-coding-plan-window-warmer/research/litellm-callback-scheduler.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,9 +110,11 @@ Context7 查询 `/websites/litellm_ai` 后,还查到两类相关但不完全
110110
## 最终实现约束
111111

112112
* 文件位于 `ai/coding/window-warmer/`,不是 LiteLLM 网关子目录。
113-
* 启动命令使用 `uv run --script ai/coding/window-warmer/window_warmer.py`,脚本元数据声明 `litellm` 依赖。
114-
* PM2 配置调用 `uv run --script`,进程名为 `coding-window-warmer`
113+
* `ai/coding/window-warmer/pyproject.toml` 通过 `uv add litellm` 声明 LiteLLM SDK 依赖,并提交 `uv.lock` 锁定解析结果。
114+
* 启动命令在 `ai/coding/window-warmer/` 下使用 `uv run python window_warmer.py`
115+
* PM2 配置调用 `uv run python window_warmer.py`,进程名为 `coding-window-warmer`
115116
* 默认 `[target].base_url` 指向 `https://open.bigmodel.cn/api/coding/paas/v4``api_key_env` 使用 `Z_AI_API_KEY`
117+
* 默认 `[target].env_file` 指向 warmer 同目录 `.env.local`;真实 key 不入库,只提交 `.env.example`
116118
* 默认 plan 模型使用 `openai/GLM-5.1`,避免 LiteLLM SDK provider 推断歧义。
117119
* 代码拆分为 `config``scheduler``target``runner``cli` 等模块,入口脚本保持薄封装。
118120

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# 智谱 Coding Plan API Key;window-warmer.toml 默认通过 Z_AI_API_KEY 读取。
2+
Z_AI_API_KEY=sk-zai-dev-xxxx

ai/coding/window-warmer/README.md

Lines changed: 44 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44

55
## 文件说明
66

7-
- `window_warmer.py`:长期运行的预热脚本,使用 PEP 723 script metadata 声明 LiteLLM 依赖。
7+
- `pyproject.toml` / `uv.lock`:uv 项目依赖声明与锁文件,包含 LiteLLM SDK。
8+
- `window_warmer.py`:长期运行的预热脚本入口。
89
- `window_warmer_lib/`:配置、调度、目标检查和运行循环拆分模块。
910
- `window-warmer.toml`:预热配置,支持多个 Coding Plan。
1011
- `window-warmer.pm2.config.cjs`:PM2 进程管理配置。
12+
- `.env.example`:本地 API key 示例;复制为 `.env.local` 后填写真实密钥。
1113
- `tests/test_window_warmer.py`:标准库 `unittest` 回归测试。
1214

1315
## 工作方式
@@ -20,36 +22,61 @@
2022

2123
只有这些条件满足后,脚本才会通过 `litellm.completion(api_base=base_url, api_key=...)` 直连目标端点发送轻量预热请求。默认配置示例直连智谱 Coding Plan 官方 OpenAI 兼容端点,同时用 `container_name = "litellm"` 作为“本机网关已启动”的可选前置条件。
2224

25+
如果不希望依赖本机 LiteLLM 容器启动状态,可以删除 `container_name`,或把它配置为空字符串。
26+
27+
## 密钥与模型
28+
29+
模型名写在 `window-warmer.toml``[[plans]].model` 中。使用 LiteLLM SDK 直连 OpenAI 兼容上游时,建议写成带 provider 前缀的形式,例如:
30+
31+
```toml
32+
[[plans]]
33+
model = "openai/GLM-5.1"
34+
```
35+
36+
API key 由 `[target].api_key_env` 指定变量名,脚本读取顺序是:
37+
38+
1. 当前进程环境变量,例如 shell 里已有 `Z_AI_API_KEY=...`
39+
2. `[target].env_file` 指向的 dotenv 文件,例如默认 `.env.local`
40+
41+
默认配置等价于读取同目录 `.env.local` 中的 `Z_AI_API_KEY`
42+
43+
```dotenv
44+
Z_AI_API_KEY=sk-zai-dev-xxxx
45+
```
46+
47+
本地第一次使用时可以创建自己的密钥文件:
48+
49+
```bash
50+
cd ai/coding/window-warmer
51+
cp .env.example .env.local
52+
```
53+
2354
## 直接运行
2455

2556
```bash
26-
uv run --script ai/coding/window-warmer/window_warmer.py \
27-
--config ai/coding/window-warmer/window-warmer.toml
57+
cd ai/coding/window-warmer
58+
uv run python window_warmer.py --config window-warmer.toml
2859
```
2960

3061
查看下一次触发时间:
3162

3263
```bash
33-
uv run --script ai/coding/window-warmer/window_warmer.py \
34-
--config ai/coding/window-warmer/window-warmer.toml \
35-
--print-next
64+
cd ai/coding/window-warmer
65+
uv run python window_warmer.py --config window-warmer.toml --print-next
3666
```
3767

3868
立即对所有启用 plan 试跑一次:
3969

4070
```bash
41-
uv run --script ai/coding/window-warmer/window_warmer.py \
42-
--config ai/coding/window-warmer/window-warmer.toml \
43-
--once
71+
cd ai/coding/window-warmer
72+
uv run python window_warmer.py --config window-warmer.toml --once
4473
```
4574

4675
只打印,不发送真实请求:
4776

4877
```bash
49-
uv run --script ai/coding/window-warmer/window_warmer.py \
50-
--config ai/coding/window-warmer/window-warmer.toml \
51-
--once \
52-
--dry-run
78+
cd ai/coding/window-warmer
79+
uv run python window_warmer.py --config window-warmer.toml --once --dry-run
5380
```
5481

5582
## PM2 管理
@@ -132,7 +159,7 @@ retry_count = 1
132159
- `[target].base_url`:直连上游 OpenAI 兼容 API 的基础地址。
133160
- `[target].container_name`:可选 Docker 容器名;默认示例为 `litellm`
134161
- `[target].api_key_env`:可选 API key 环境变量名;默认示例为 `Z_AI_API_KEY`
135-
- `[target].env_file`:相对当前 TOML 文件的环境变量文件路径;默认示例指向 `../../gateway/litellm/.env.local`
162+
- `[target].env_file`:相对当前 TOML 文件的 dotenv 文件路径;默认示例指向同目录 `.env.local`
136163
- `[target].health_path`:可选健康检查路径;默认示例为 `/models`
137164
- `[scheduler].default_jitter_seconds`:plan 未单独配置时使用的随机延迟上限。
138165
- `[scheduler].default_retry_count`:plan 未单独配置时的失败重试次数。
@@ -142,7 +169,8 @@ retry_count = 1
142169
## 测试
143170

144171
```bash
145-
uv run --with litellm python -m unittest discover \
146-
-s ai/coding/window-warmer/tests \
172+
cd ai/coding/window-warmer
173+
uv run python -m unittest discover \
174+
-s tests \
147175
-p 'test_*.py'
148176
```
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
[project]
2+
name = "coding-window-warmer"
3+
version = "0.1.0"
4+
requires-python = ">=3.11"
5+
dependencies = [
6+
"litellm>=1.81.0",
7+
"python-dotenv>=1.0.0",
8+
]

ai/coding/window-warmer/tests/test_window_warmer.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import random
66
import sys
7+
import tempfile
78
import unittest
89
from datetime import datetime, time
910
from pathlib import Path
@@ -251,6 +252,30 @@ def test_dry_run_skips_readiness_checks(self) -> None:
251252

252253
self.assertTrue(warmer.warm_plan(config, plan, dry_run=True))
253254

255+
def test_read_api_key_falls_back_to_dotenv_file(self) -> None:
256+
"""API key 应支持从配置指定的 dotenv 文件读取。
257+
258+
Args:
259+
None.
260+
261+
Returns:
262+
无返回值。
263+
"""
264+
with tempfile.TemporaryDirectory() as temp_dir:
265+
env_path = Path(temp_dir) / ".env.local"
266+
env_path.write_text('Z_AI_API_KEY="sk-from-file"\n', encoding="utf-8")
267+
config = warmer.TargetConfig(
268+
name="z-ai",
269+
base_url="https://open.bigmodel.cn/api/coding/paas/v4",
270+
container_name=None,
271+
api_key_env="Z_AI_API_KEY",
272+
env_file=env_path,
273+
health_path=None,
274+
request_timeout_seconds=30,
275+
)
276+
277+
self.assertEqual(warmer.read_api_key(config), "sk-from-file")
278+
254279

255280
if __name__ == "__main__":
256281
unittest.main()

0 commit comments

Comments
 (0)