forked from luopingyi/xc_agent_platform_demo
Compare commits
5 Commits
v1.0.0
...
49d59b6bd4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49d59b6bd4 | ||
|
|
3adf13ad22 | ||
|
|
68f24e6794 | ||
|
|
bed3bc11ca | ||
|
|
0d0f3b611a |
@@ -1,4 +1,21 @@
|
|||||||
.git
|
.git
|
||||||
__pycache__/
|
.gitignore
|
||||||
*.pyc
|
.venv
|
||||||
.pytest_cache/
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.pytest_cache
|
||||||
|
.mypy_cache
|
||||||
|
reports/*
|
||||||
|
!reports/.gitkeep
|
||||||
|
docs
|
||||||
|
tests
|
||||||
|
scripts
|
||||||
|
config
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|||||||
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Secrets / local env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
config/local.env
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Dry-run / local artifacts (keep directory)
|
||||||
|
reports/*
|
||||||
|
!reports/.gitkeep
|
||||||
|
!reports/live-gate-summary.md
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
*.tar
|
||||||
19
Dockerfile
19
Dockerfile
@@ -1,14 +1,19 @@
|
|||||||
FROM modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim
|
# ModelHub XC adaptation agent — Phase1 skeleton
|
||||||
|
# Default base MUST stay the platform image (do not swap to public mirrors).
|
||||||
ENV PYTHONUNBUFFERED=1
|
ARG BASE_IMAGE=modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY requirements.txt .
|
# Copy only what the runtime needs (see .dockerignore)
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
COPY src/ ./src/
|
||||||
|
COPY templates/ ./templates/
|
||||||
|
|
||||||
COPY . .
|
ENV PORT=8080
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
# STRATEGY_ID and tokens are injected by the platform at run time — never bake them in.
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
CMD ["python", "main.py"]
|
# Start HTTP service only (no register / submit side effects)
|
||||||
|
CMD ["python", "-m", "src.server"]
|
||||||
|
|||||||
44
Makefile
Normal file
44
Makefile
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# ModelHub XC agent contrib — Phase1
|
||||||
|
# Allowed: test / build / smoke-health / dry-run
|
||||||
|
# FORBIDDEN: register / submit / deploy / push targets
|
||||||
|
|
||||||
|
.PHONY: build-config-dry probe-readonly test build smoke-health dry-run dry-run-pending help
|
||||||
|
|
||||||
|
PYTHON ?= python3
|
||||||
|
IMAGE ?= modelhub-agent-contrib:local
|
||||||
|
PORT ?= 8080
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "build-config-dry generate YAML via API (requires token; never creates task)"
|
||||||
|
@echo "probe-readonly authenticated read-only task/page GET"
|
||||||
|
@echo "dry-run-pending read-only adapt pending A→B report"
|
||||||
|
@echo "test stdlib unit tests + offline SIGTERM integration"
|
||||||
|
@echo "build Docker build using platform base (needs Docker + TCR access)"
|
||||||
|
@echo "smoke-health local HTTP smoke (needs sockets; default test STRATEGY_ID)"
|
||||||
|
@echo "dry-run read-only pending report (example fallback)"
|
||||||
|
@echo "No register/submit/deploy/push — live defaults on; ALLOW_LIVE_SUBMIT=0 stops live."
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(PYTHON) -m unittest discover -s tests -v
|
||||||
|
|
||||||
|
build:
|
||||||
|
@command -v docker >/dev/null 2>&1 || { \
|
||||||
|
echo "ERROR: docker not found. See docs/build-notes.md"; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
docker build -t $(IMAGE) .
|
||||||
|
|
||||||
|
smoke-health:
|
||||||
|
$(PYTHON) scripts/smoke_health.py
|
||||||
|
|
||||||
|
dry-run:
|
||||||
|
$(PYTHON) scripts/dry_run.py
|
||||||
|
|
||||||
|
dry-run-pending:
|
||||||
|
PENDING_SUBMIT_MD=/workspace/modelhub-adapt/pending-submit.md PENDING_SUBMIT_TXT=/workspace/modelhub-adapt/pending-submit.txt $(PYTHON) scripts/dry_run.py
|
||||||
|
|
||||||
|
probe-readonly:
|
||||||
|
$(PYTHON) scripts/probe_readonly.py
|
||||||
|
|
||||||
|
build-config-dry:
|
||||||
|
$(PYTHON) scripts/build_config_dry.py
|
||||||
95
README.md
95
README.md
@@ -1,39 +1,84 @@
|
|||||||
# Strategy Platform Demo Repo
|
# modelhub-agent-contrib
|
||||||
|
|
||||||
Minimal strategy repository for testing the platform Kaniko build and deployment flow.
|
ModelHub XC(信创模盒)**适配智能体** Phase1 本地骨架。
|
||||||
|
|
||||||
It satisfies the runtime contract from the parent README:
|
> **不代注册;Victor 已批准队列闸门 live 代提。** 发布智能体本身 **不得分**。积分来自成功适配,数字一律 **待平台核实**。
|
||||||
|
> 冲突裁决:**适配 W1 优先**;本仓默认 live 开启,仅在实时验证队列低于阈值时提交。
|
||||||
|
|
||||||
- Root-level `Dockerfile`
|
## 契约要点
|
||||||
- Listens on port `8080`
|
|
||||||
- Exposes `GET /health`
|
|
||||||
- Reads `EXTERNAL_SERVICE_TOKEN`
|
|
||||||
- Handles `SIGTERM` for graceful shutdown
|
|
||||||
|
|
||||||
## Local Run
|
| 项 | 值 |
|
||||||
|
|----|-----|
|
||||||
|
| 端口 | 8080 |
|
||||||
|
| 健康检查 | `GET /health` → `{"status":"ok"}` |
|
||||||
|
| 启动 | **必须**有非空 `STRATEGY_ID`,否则 exit ≠0 |
|
||||||
|
| Live | 默认开启;`ALLOW_LIVE_SUBMIT=0` 为停交开关;必须提供 `STRATEGY_ID` |
|
||||||
|
| Base 镜像 | 平台 `xc_agent_platform/python:3.11-slim`(保留 FROM) |
|
||||||
|
|
||||||
|
Victor 的确认已内置,`LIVE_CONFIRM` 仍接受但不再必需。每次 task/add 前,
|
||||||
|
通过 Xc-Token 全量分页读取 onlyMine task/page;非终态计入队列,读取失败停交。
|
||||||
|
`LIVE_QUEUE_THRESHOLD=95`(严格小于),`LIVE_MAX_SUBMITS_PER_CYCLE=3`,
|
||||||
|
`LIVE_CYCLE_SECONDS=600`(最小 60 秒)。环境队列提示仅可额外阻塞。
|
||||||
|
候选来自 `PENDING_SUBMIT_MD` / `PENDING_SUBMIT_TXT`,否则使用
|
||||||
|
`src/data/candidates.json`(随镜像内置);A verified_reuse → 其他 A → B。
|
||||||
|
禁提、重复、GPU 未映射、占位符候选跳过。按 try_next_gpus 顺序选择首个已映射且未提交的模型/GPU 对。`LIVE_STATE_FILE` 默认
|
||||||
|
`/tmp/live-submitted.json`,格式为 `model|gpuEnum` 字符串列表,在请求前持久化;失败请求也不自动重试。旧版仅模型名状态文件须先离线迁移,否则 fail closed。
|
||||||
|
去重合并远端全部状态的 `modelId` + `gpuType`、内置 `src/data/submitted-ledger.json` 和持久化状态。A 允许同模型换 GPU;do-not-resubmit 模型跨 GPU 全禁。服务启动重建一次并只记录三类 pair 数;每次提交前刷新远端,失败则 hold `ledger_rebuild_failed`。task/page total 接受非负整数或 ASCII 数字字符串。
|
||||||
|
凭证优先级:`EXTERNAL_SERVICE_TOKEN` → `XC_TOKEN_FILE` → `XC_TOKEN` → `MODELHUB_XC_TOKEN`;跳过空值和占位符。每周期只记录 source 环境变量名,使用 `Xc-Token` 请求头,不记录凭证值。
|
||||||
|
离线再生成:`python3 scripts/bake_candidates.py`、`python3 scripts/bake_ledger.py`,读取 `/workspace/modelhub-adapt` 本地文件;候选 342(A 46、B 296),ledger 206 对及 28 个模型禁提项。
|
||||||
|
配置优先 build-config,失败使用 sample 并记录来源及原因。token 缺失只 hold。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python main.py
|
cd /workspace/modelhub-agent-contrib
|
||||||
curl http://localhost:8080/health
|
make test
|
||||||
curl http://localhost:8080/
|
make smoke-health # 自动起本地 server(需 STRATEGY_ID,脚本可注入测试值)
|
||||||
|
make dry-run # 写出 reports/dry-run-*.md
|
||||||
|
make build # 需 Docker + 可达平台 base;失败见 docs/build-notes.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build Locally
|
本地起服务:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t strategy-demo:v1.0.0 .
|
export STRATEGY_ID=local-dev
|
||||||
|
export ALLOW_LIVE_SUBMIT=0 # 本地开发停交
|
||||||
docker run --rm -p 8080:8080 strategy-demo:v1.0.0
|
python3 -m src.server
|
||||||
|
# → http://127.0.0.1:8080/health
|
||||||
```
|
```
|
||||||
|
|
||||||
## Platform Test
|
## 目录
|
||||||
|
|
||||||
Push this directory to Gitea and keep the `v1.0.0` tag. Then create a strategy with:
|
```text
|
||||||
|
Dockerfile Makefile README.md
|
||||||
```json
|
src/ # server / health / strategy / gate
|
||||||
{
|
config/ # example.env, candidates.example.json
|
||||||
"name": "strategy-demo",
|
scripts/ # smoke_health.py, dry_run.py, tag_release.py
|
||||||
"repo_url": "https://<your-gitea-host>/<user>/strategy-demo",
|
reports/ # dry-run 输出(gitignore)
|
||||||
"tag": "v1.0.0"
|
docs/ # 契约 / runbook / kickoff / issues
|
||||||
}
|
tests/ # stdlib unittest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Makefile 目标
|
||||||
|
|
||||||
|
- `test` / `build` / `smoke-health` / `dry-run`
|
||||||
|
- **没有** `register` / `submit` / `deploy` / `push`
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [`docs/kickoff-for-victor.md`](docs/kickoff-for-victor.md) — 一页开工 + 验收勾选
|
||||||
|
- [`docs/platform-contract-agent.md`](docs/platform-contract-agent.md) — 契约表
|
||||||
|
- [`docs/register-runbook.md`](docs/register-runbook.md) — 仅人工注册
|
||||||
|
- [`docs/api-probe-design.md`](docs/api-probe-design.md) — 写接口禁止代调
|
||||||
|
- [`docs/diff-vs-demo.md`](docs/diff-vs-demo.md) — 对照官方 demo
|
||||||
|
- [`docs/issues.md`](docs/issues.md) — M0–M4 / Phase 清单
|
||||||
|
- [`docs/build-notes.md`](docs/build-notes.md) — 镜像构建说明
|
||||||
|
|
||||||
|
## 资源声明
|
||||||
|
|
||||||
|
按平台量级设计:约 **100m–1 CPU / 256–512Mi**。
|
||||||
|
|
||||||
|
## 许可与边界
|
||||||
|
|
||||||
|
本仓库用于 Victor 本地/Gitea 交付。Victor 已批准上述队列闸门自动提交;不自动注册。
|
||||||
|
`make dry-run-pending` 仍仅生成草稿;测试全部 mock 平台接口。
|
||||||
|
|||||||
21
config/candidates.example.json
Normal file
21
config/candidates.example.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"model_id": "example-org/example-model",
|
||||||
|
"modelAddress": "example-org/example-model",
|
||||||
|
"taskType": "text-generation",
|
||||||
|
"targetGpu": "Iluvatar_bi-100",
|
||||||
|
"framework": "vllm",
|
||||||
|
"notes": "Phase1 example only — not a real submit payload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model_id": "example-org/another-model",
|
||||||
|
"taskType": "text-generation",
|
||||||
|
"notes": "Minimal fields: model_id required; others optional"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"_meta": {
|
||||||
|
"description": "Minimal dry-run input schema for Phase1",
|
||||||
|
"pending_align": "Future Phase2 may read /workspace/modelhub-adapt/pending-submit.md A→B order"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
config/example.env
Normal file
20
config/example.env
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Copy to config/local.env (gitignored) for local runs. Never commit real tokens.
|
||||||
|
|
||||||
|
# Required for process start (Phase1): non-empty STRATEGY_ID
|
||||||
|
STRATEGY_ID=local-dev-strategy-placeholder
|
||||||
|
|
||||||
|
# Optional listen port (default 8080)
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# Implementation is authoritative: ALLOW_LIVE_SUBMIT=1 AND
|
||||||
|
# LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT. No confirmation file is read.
|
||||||
|
# PRD examples ALLOW_LIVE_SUBMIT=true / CONFIRM_LIVE=YES are unsupported.
|
||||||
|
# Even with both set, no real client exists: still blocked. Keep defaults below.
|
||||||
|
# Dual live gate — BOTH required for live; default is dry-run / blocked
|
||||||
|
ALLOW_LIVE_SUBMIT=0
|
||||||
|
LIVE_CONFIRM=
|
||||||
|
|
||||||
|
# Platform injects EXTERNAL_SERVICE_TOKEN at runtime.
|
||||||
|
# Community aliases (presence only; never log values):
|
||||||
|
# XC_TOKEN_FILE / XC_TOKEN / MODELHUB_XC_TOKEN / EXTERNAL_SERVICE_TOKEN
|
||||||
|
# EXTERNAL_SERVICE_TOKEN=
|
||||||
43
docs/api-probe-design.md
Normal file
43
docs/api-probe-design.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# API 探针设计(只读意图 · 禁止代调写接口)
|
||||||
|
|
||||||
|
- 日期:2026-10-16 Asia/Shanghai
|
||||||
|
- Phase2:客户端已实现但默认不调用;本轮禁止真实 task/add
|
||||||
|
|
||||||
|
## 管理 API `/agent_platform/strategies*`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 本仓态度 |
|
||||||
|
|------|------|----------|
|
||||||
|
| POST | `/agent_platform/strategies` | **禁止代调**(注册) |
|
||||||
|
| POST | `.../{id}/deploy` | **禁止代调** |
|
||||||
|
| POST | `.../{id}/stop` | **禁止代调** |
|
||||||
|
| DELETE | `.../{id}` | **禁止代调** |
|
||||||
|
| GET | `.../{id}` | 未来只读探针可选(Phase3+,需 Victor 授权) |
|
||||||
|
| GET | `.../{id}/logs` | 未来只读探针可选 |
|
||||||
|
|
||||||
|
鉴权(浏览器):`Authorization: Bearer <login token>`(等级 A)。
|
||||||
|
|
||||||
|
## 适配任务 API
|
||||||
|
|
||||||
|
| 方法 | 路径 | 本仓态度 |
|
||||||
|
|------|------|----------|
|
||||||
|
| GET | `/api/adapt/task/page` | 未来只读鉴权探针(对齐 huni-probe,`ALLOW_SUBMIT=0`) |
|
||||||
|
| POST | `/api/adapt/task/build-config` | 设计可引用;Phase1 不实现 |
|
||||||
|
| POST | `/api/adapt/task/add` | 显式注入客户端 + 双闸门;本轮只做 mock 测试 |
|
||||||
|
| GET | `/api/adapt/task/calculate-credit` | 只读索引;勿写死 KPI |
|
||||||
|
|
||||||
|
容器内社区鉴权(C):`Xc-Token` + body `strategyId` — **待 Victor 账号核实**;60014 仍为 **D(unverified)**。
|
||||||
|
|
||||||
|
## Phase2 落地边界
|
||||||
|
|
||||||
|
- `src/gate.py`:闸门逻辑 + `attempt_live_submit(..., client=Fake)` ;真实客户端只能显式注入,测试必须 mock HTTP
|
||||||
|
- `src/task_add_client.py` 使用 urllib;import 和 dry-run 无网络调用,无自动重试
|
||||||
|
- token 优先级:XC_TOKEN_FILE → XC_TOKEN → MODELHUB_XC_TOKEN → EXTERNAL_SERVICE_TOKEN;禁止输出 token
|
||||||
|
- build_payload 中 GPU/framework/strategyId/configParams 占位须人工替换;真实调用要求 STRATEGY_ID 与 payload 一致
|
||||||
|
- 队列 ≥95 阻断;无效队列提示也阻断;每进程一次调用预算在调用前保留,异常也不重试(防止响应丢失造成重复创建)
|
||||||
|
- 名称/族硬门不能证明元数据 PASS;未跳过的 pending 行均标 needs_human
|
||||||
|
- 仍禁止所有 agent_platform 写接口
|
||||||
|
- Makefile **无** register / submit / deploy / push
|
||||||
|
|
||||||
|
## 冲突裁决提醒
|
||||||
|
|
||||||
|
适配 W1:队列 ≥95 → 禁 live;智能体不抢验证空位。
|
||||||
25
docs/autoconfig-probe-plan.md
Normal file
25
docs/autoconfig-probe-plan.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# configParams 配置来源与容器验证
|
||||||
|
|
||||||
|
日期:2026-10-16 Asia/Shanghai。
|
||||||
|
|
||||||
|
结论:**API**。配置来自服务端,前端将响应 `data` 写入 `configParams`。
|
||||||
|
依据:[autoconfig-capture-2026-10-16.md](/workspace/modelhub-adapt/logs/autoconfig-capture-2026-10-16.md)。原「公开面无 build-config」缺口关闭:**未公开文档但已抓包证实**。
|
||||||
|
|
||||||
|
## 已证实契约
|
||||||
|
|
||||||
|
- `POST /api/adapt/task/build-config`
|
||||||
|
- Query:`gpuType=Sunrise_pt-200-x1&framework=vllm&taskType=text-generation`
|
||||||
|
- 空 body,无 JSON;模型地址不进入 query/body,也无需传 strategyId。
|
||||||
|
- HTTP 2xx,JSON `code == 0`,非空字符串 `data` 为 YAML。
|
||||||
|
- 当前 UI 无「自动配置」标签。新建适配弹层选择模型、GPU、文本生成后,选择框架触发 effect 并填入配置。
|
||||||
|
- 抓包观察到 `Authorization` 头,值未记录。容器默认按社区实践发送 `Xc-Token`;`BUILD_CONFIG_USE_AUTHORIZATION=1` 改用同一 resolve_token 值作为 Authorization,不自动加 Bearer。
|
||||||
|
|
||||||
|
## 客户端与验证
|
||||||
|
|
||||||
|
`src/build_config_client.py` 的 `build_config()` 成功返回 YAML,失败抛出 `BuildConfigError`,包含安全归因、HTTP 状态和平台数字码;不返回服务端消息或传输异常细节。缺失/无效凭证不联网,40100、40101、其他平台码、传输失败和无效 data 均可区分。导入无 I/O。
|
||||||
|
|
||||||
|
默认 dry-run 使用 `templates/config_params.sample.yaml`,无网络。`CONFIG_PARAMS_SOURCE=build-config` 强制 API;或 `ALLOW_BUILD_CONFIG=1` 且可解析 token 时使用 API。调用失败直接报错,不回退 sample。调用方须传真实 GPU enum。`make build-config-dry` 仅生成配置,缺 token 非零退出,不输出 YAML 或凭证。
|
||||||
|
|
||||||
|
## 剩余 GAP 与边界
|
||||||
|
|
||||||
|
尚无容器 token 鉴权实证;需运行时 token + API 调用并与只读探针对照,不能宣称鉴权就绪。sample 本身不是 production-ready。队列 ≥95 禁 live。本轮仅 mock 测试,不进行真实 API 调用,不调用 task/add,不保存秘密,不提交任务或推送 tag。
|
||||||
59
docs/build-notes.md
Normal file
59
docs/build-notes.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Build notes
|
||||||
|
|
||||||
|
- 日期:2026-10-16 Asia/Shanghai
|
||||||
|
- 默认 `FROM`:`modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim`
|
||||||
|
- **禁止**因本地拉不下而改默认 FROM 为公网镜像
|
||||||
|
|
||||||
|
## 本机(box)首次尝试
|
||||||
|
|
||||||
|
| 项 | 结果 |
|
||||||
|
|----|------|
|
||||||
|
| `docker` CLI | **未安装 / 不可用**(`which docker` 空) |
|
||||||
|
| `make build` | 预期失败:`docker not found`(Makefile 显式报错) |
|
||||||
|
| 默认 FROM | **未修改** |
|
||||||
|
|
||||||
|
### 后续建议(Victor)
|
||||||
|
|
||||||
|
1. 在有 Docker 且能访问腾讯云 TCR 平台镜像的环境执行 `make build`
|
||||||
|
2. 若 `docker pull` 平台 base 失败:把完整错误贴本文件「拉取失败记录」,仍保持默认 FROM
|
||||||
|
3. 无镜像时用本地验收:`STRATEGY_ID=test make smoke-health` / `make test`
|
||||||
|
|
||||||
|
## 拉取失败记录
|
||||||
|
|
||||||
|
(尚无 Docker,无 pull 日志。有 Docker 后在此追加。)
|
||||||
|
|
||||||
|
## 2026-10-16 13:46 Asia/Shanghai — `make build` 实测
|
||||||
|
|
||||||
|
```text
|
||||||
|
ERROR: docker not found. See docs/build-notes.md
|
||||||
|
make: *** [Makefile:19: build] Error 1
|
||||||
|
```
|
||||||
|
|
||||||
|
- 原因:box 无 Docker 引擎/CLI,**未发生**平台 base `docker pull`(因此无 TCR 拉取错误日志)
|
||||||
|
- 默认 `FROM modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim` **未改**
|
||||||
|
- 替代验收:`make test` 绿;`STRATEGY_ID=test make smoke-health` 绿(本地 `python -m src.server`)
|
||||||
|
|
||||||
|
## 2026-10-16 Asia/Shanghai — Victor Mac `make build`
|
||||||
|
|
||||||
|
- Docker Desktop:已连接
|
||||||
|
- 结果:失败 — `modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim` pull access denied / insufficient_scope
|
||||||
|
- 默认 FROM:未修改
|
||||||
|
|
||||||
|
## 2026-10-16 Asia/Shanghai — Codex 沙箱复测
|
||||||
|
|
||||||
|
- `make build` 实际退出 2:`ERROR: docker not found. See docs/build-notes.md`。
|
||||||
|
- 无 Docker,未尝试拉取 TCR;本机结果不能证明 TCR 可达或不可达。上文 Mac 的拒绝记录为已有人工证据,未在本轮复现。
|
||||||
|
- `STRATEGY_ID=test make smoke-health` 实际退出 2:沙箱创建 socket 被拒,`PermissionError: [Errno 1] Operation not permitted`。
|
||||||
|
- `make test`:优化后 19 项中 18 项通过,SIGTERM 集成项在监听前同样被 socket 权限阻塞;未跳过或标绿。
|
||||||
|
- 默认平台 base / FROM 保持原样;须在允许本地 socket、具备 Docker 与 TCR 权限的环境复验完整 DoD。
|
||||||
|
|
||||||
|
## 2026-10-16 ~15:20 Asia/Shanghai — tcr-then-build 探测(Mac)
|
||||||
|
|
||||||
|
- Docker Desktop:已连接;`docker` CLI 可用
|
||||||
|
- `~/.docker/config.json`:`auths` 空,`credsStore=desktop` → **未登录** TCR
|
||||||
|
- `docker pull modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim`:
|
||||||
|
`Error response from daemon: pull access denied ... may require 'docker login'`
|
||||||
|
- 默认 FROM:**未修改**
|
||||||
|
- 既有 `agent-contrib:local`(sha256:95a4d01e2b55…):**不算**本轮 DoD(CMD=`src.main`,基底公网 python:3.11-slim)
|
||||||
|
- 验收标准:`/Users/vc/modelhub-adapt-logs/agent-image-dod-2026-10-16.md`(box 同源 `/workspace/modelhub-adapt/logs/agent-image-dod-2026-10-16.md`)
|
||||||
|
- 阻塞:需 Victor 提供 TCR 账号并对 `xc_agent_platform/python` 有 pull,执行 `docker login modelhubxc-4pd.tencentcloudcr.com` 后重试 pull + `make build`
|
||||||
40
docs/diff-vs-demo.md
Normal file
40
docs/diff-vs-demo.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 对照官方演示仓 `luopingyi/xc_agent_platform_demo`
|
||||||
|
|
||||||
|
- 日期:2026-10-16 Asia/Shanghai
|
||||||
|
- 官方演示:https://dev.modelhub.org.cn/luopingyi/xc_agent_platform_demo
|
||||||
|
- 安全探针参照:`huni/huni-probe-agent`(只读)
|
||||||
|
- **不要照搬**:`huni/huni-adapt-agent` 默认真提交循环
|
||||||
|
|
||||||
|
## 对齐项(保留)
|
||||||
|
|
||||||
|
| 项 | demo / 规划 | 本仓 |
|
||||||
|
|----|-------------|------|
|
||||||
|
| 根目录 Dockerfile | 是 | 是 |
|
||||||
|
| 默认 base 镜像 | 平台 `python:3.11-slim` | 同(`BASE_IMAGE` 仅 build-arg,默认不改公网) |
|
||||||
|
| `EXPOSE 8080` | 是 | 是 |
|
||||||
|
| `GET /health` → 200 `{"status":"ok"}` | 是 | 是 |
|
||||||
|
| 读 `EXTERNAL_SERVICE_TOKEN` 是否存在 | demo | `/status` 布尔;永不打值 |
|
||||||
|
| stdlib / 轻依赖 | demo 极简 | Phase1 仅 stdlib |
|
||||||
|
|
||||||
|
## 本仓相对 demo 的补强
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| `STRATEGY_ID` 启动校验 | Phase1:**缺失则 exit ≠0**(产品规划;相对「仅读 token 保活」的社区变体) |
|
||||||
|
| 双重 live 闸门 | `ALLOW_LIVE_SUBMIT` + `LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT` |
|
||||||
|
| `GET /status` | 仅布尔字段 |
|
||||||
|
| dry-run 报告 | `scripts/dry_run.py` → `reports/dry-run-*.md` |
|
||||||
|
| 单测 | health / 缺 STRATEGY_ID / 闸门默认拒绝 / SIGTERM |
|
||||||
|
| 无默认真提交 | live 仅闸门 + **注入假客户端**单测;仓库无默认真打 `/api/adapt/task/add` 的 HTTP 客户端 |
|
||||||
|
|
||||||
|
## 明确不照搬
|
||||||
|
|
||||||
|
| 来源 | 行为 | 本仓 |
|
||||||
|
|------|------|------|
|
||||||
|
| huni-adapt-agent | 默认真提交循环 | **禁止** |
|
||||||
|
| CoolBoy / 社区刷分脚本 | 绕队列 / 无闸门 | **禁止** |
|
||||||
|
| 旧对话口径「缺 STRATEGY_ID 保活 + /health 仍 200」 | 与产品规划 Phase1 冲突 | **以产品规划为准:启动失败** |
|
||||||
|
|
||||||
|
## 资源声明
|
||||||
|
|
||||||
|
文档按约 **100m–1 CPU / 256–512Mi** 设计;Phase1 不做 k8s manifests。
|
||||||
52
docs/gpu-perf-order.md
Normal file
52
docs/gpu-perf-order.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# GPU 缺卡序(契约)
|
||||||
|
|
||||||
|
- 日期:2026-10-16 Asia/Shanghai
|
||||||
|
- 对齐:模盒产品经理 + 模盒积分
|
||||||
|
- 正式性能表:**无** → 报告字段 `perf_order=unset`(勿写成官方 TPS 排名)
|
||||||
|
- 缺卡补齐所用序:**经验序 / 待平台核实**(下表)— 产品要求排序器按此序;非站内正式性能榜
|
||||||
|
|
||||||
|
## A. 正式性能序
|
||||||
|
|
||||||
|
| 优先级 | 展示名 | targetGpu | 依据 |
|
||||||
|
|--------|--------|-----------|------|
|
||||||
|
| — | — | — | 模盒积分定稿:无正式卡间性能表 → `perf_order=unset` |
|
||||||
|
|
||||||
|
## B. 缺卡经验序(排序器用 · 待平台核实)
|
||||||
|
|
||||||
|
跨卡总策略优先于本序:已验证/已下载落点 → 只打**未适配卡**;跳过正在验证 / 已验证成功 / 唯一性失败 / 同卡已知失败禁重试;队列 ≥95 停交(live)。
|
||||||
|
|
||||||
|
| 优先级(高→低) | 平台展示名 | targetGpu(已观察,待再核) | 备注 |
|
||||||
|
|-----------------|------------|------------------------------|------|
|
||||||
|
| 1(一梯队并列) | 壁砺166M | `Biren_166m` | ≈ 曦望 S2;社区成功量壁砺更高 |
|
||||||
|
| 1(一梯队并列) | 曦望 S2 | `Sunrise_pt-200-x1`(抓包证实) | 与壁砺并列,勿臆造先后「性能」 |
|
||||||
|
| 2 | 天垓150 | Iluvatar_bi-150 | 条件回退;饱和+唯一性碰撞多 |
|
||||||
|
| 3 | 天垓100 | Iluvatar_bi-100 | 一般不扇出 |
|
||||||
|
| 4 | 智铠 mrv-100 | `Iluvatar_mrv-100` | 我方唯一成功样本族;不当最快卡宣传 |
|
||||||
|
|
||||||
|
并列一梯队时:若 pending `try-next` 已给出相对顺序则保留;否则可按「壁砺 / 曦望」与池内缺口并列候选,报告标 `order_source=experience`。
|
||||||
|
|
||||||
|
## C. 映射备忘(非排名)
|
||||||
|
|
||||||
|
| 展示名 | 提交字段 | 备注 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| 曦望 S2 | `Sunrise_pt-200-x1` | 2026-10-16 build-config query 抓包证实;非性能排名 |
|
||||||
|
| (docs sample) | `MetaX_c-500` | 与中文悬赏卡无已核实映射 |
|
||||||
|
|
||||||
|
## D. 依据文件(工作流/观察,非官方性能榜)
|
||||||
|
|
||||||
|
- `/workspace/modelhub-adapt/pending-submit.md`
|
||||||
|
- `/workspace/modelhub-adapt/submit-plan-2026-10-15-v2.md`
|
||||||
|
- `/workspace/modelhub-adapt/uniqueness-collision-notes-2026-10-15-eve.md`
|
||||||
|
- `/workspace/modelhub-adapt/gpu-skip-patterns-xiwang-vs-bili-2026-10-15-eve2.md`
|
||||||
|
|
||||||
|
## E. configParams 缺口(产品补充)
|
||||||
|
|
||||||
|
- 结论:**API**,未公开文档但已抓包证实,依据 [autoconfig-capture-2026-10-16.md](/workspace/modelhub-adapt/logs/autoconfig-capture-2026-10-16.md)。
|
||||||
|
- `POST /api/adapt/task/build-config`,query 为 `gpuType` / `framework` / `taskType`,空 body;成功 JSON `code=0`,`data` 为 YAML。
|
||||||
|
- UI 无「自动配置」标签;选择框架触发 effect 填入 configParams。模型地址不进入该请求。
|
||||||
|
- 2026-10-17 主机只读探针确认 task/page 使用 Xc-Token 成功;容器继续使用 Xc-Token,Authorization Bearer 属于浏览器路径。
|
||||||
|
- 已核对 GPU 枚举:天垓100=Iluvatar_bi-100,天垓150=Iluvatar_bi-150,智铠100=Iluvatar_mrv-100,MTT S4000/摩尔线程=Mthreads_s4000;保留壁砺及曦望映射。枚举证据不代表官方性能排序。
|
||||||
|
- 默认 dry-run:`resolve_token()` 成功时优先 build-config;失败回退 sample,逐条报告 configParams_source、GAP 和 fallback reason。无 token → sample。CONFIG_PARAMS_SOURCE=sample 强制离线;=build-config 强制 API 路径(dry-run with_meta 仍可回退)。make build-config-dry 保持失败非零退出。
|
||||||
|
- API gpuType 仅映射壁砺/壁砺166M/Biren_166m 与曦望/曦望 S2/Sunrise_pt-200-x1;未知卡不请求 API,报告 GAP: gpu enum unmapped。
|
||||||
|
- 选策优化后仍为 experience_order,perf_order=unset;order_source=experience;无正式性能表。Section A 仅按 foothold 文本将 verified_reuse 提前,其余 A 保持文件序,再接 B。API foothold evidence: GAP (not wired)。
|
||||||
|
- **live 前必须有 token + API 调用实证**;sample 单独不是 production-ready。队列 ≥95 继续禁 live,本轮无 live。
|
||||||
47
docs/issues.md
Normal file
47
docs/issues.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Issue / Phase 清单
|
||||||
|
|
||||||
|
对齐 PRD M0–M4 与产品规划 Phase 0–5。
|
||||||
|
|
||||||
|
## Phase / Milestone 映射
|
||||||
|
|
||||||
|
| Phase(产品规划) | PRD | 状态(本仓) |
|
||||||
|
|-------------------|-----|--------------|
|
||||||
|
| Phase 0 调研冻结 | M0 | 文档已落 `docs/` |
|
||||||
|
| Phase 1 本地可部署样例 | M1 | **本轮交付** |
|
||||||
|
| Phase 2 Dry-run 闭环 | M2 | 已实现 pending A→B 解析、名称硬门/禁提过滤与 dry-run;元数据 PASS 仍需人工核实 |
|
||||||
|
| Phase 3 人工注册 + 只读探针 | M3 | 仅 runbook / api-probe-design;**不代注册** |
|
||||||
|
| Phase 4 一次 live 验证 | M4 | 闸门已备;客户端已实现;默认关闭,必须显式注入 |
|
||||||
|
| Phase 5 规模化 | — | 未开 |
|
||||||
|
|
||||||
|
## P0 Issues
|
||||||
|
|
||||||
|
- [x] docs: 冻结智能体平台契约(health/STRATEGY_ID/SIGTERM/资源)
|
||||||
|
- [x] docs: 对照 xc_agent_platform_demo 写 diff-vs-demo
|
||||||
|
- [x] chore: 初始化 Dockerfile / Makefile / example.env
|
||||||
|
- [x] feat: GET /health 200 + STRATEGY_ID 校验(缺则启动失败)
|
||||||
|
- [x] feat: SIGTERM 优雅退出(≤30s;测试 <5s)
|
||||||
|
- [x] feat: dry-run 报告生成器(默认模式)
|
||||||
|
- [x] feat: live 人工确认闸门 + 单测(默认拒绝)
|
||||||
|
- [x] docs: 注册智能体 runbook — 仅人工
|
||||||
|
- [x] chore: tag_release(只打本地 tag,不 push / 不注册)
|
||||||
|
- [x] docs: api-probe-design(禁止代调写接口)
|
||||||
|
|
||||||
|
Phase1 构建与运行验收须独立记录;环境阻塞不等于通过。
|
||||||
|
|
||||||
|
## P1
|
||||||
|
|
||||||
|
- [x] 对接 `/workspace/modelhub-adapt/pending-submit.md` A→B 只读
|
||||||
|
- [ ] 结构化 JSON 日志(禁密钥)
|
||||||
|
- [ ] evidence-agent 模板填写
|
||||||
|
|
||||||
|
## P2(未做)
|
||||||
|
|
||||||
|
- [ ] 贡献台账 CSV
|
||||||
|
- [ ] 多 Tag 矩阵
|
||||||
|
|
||||||
|
## 硬边界(持续)
|
||||||
|
|
||||||
|
- 无 register / submit / deploy / push Makefile 目标
|
||||||
|
- 无 `/agent_platform/strategies*` 写调用
|
||||||
|
- `/api/adapt/task/add` 客户端默认不调用;双闸门、队列≥95 阻断、每进程最多一次;本轮未授权真网
|
||||||
|
- 适配 W1 优先;不抢队列
|
||||||
43
docs/kickoff-for-victor.md
Normal file
43
docs/kickoff-for-victor.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# 开工包(一页)— Victor
|
||||||
|
|
||||||
|
**仓路径(本地)**:`/workspace/modelhub-agent-contrib/`
|
||||||
|
**远程**:Gitea 命名空间/仓名 **未定** → Phase1 以本地验收为主
|
||||||
|
**模式**:默认 dry-run;**不代注册、不代提交**
|
||||||
|
|
||||||
|
## 闸门
|
||||||
|
|
||||||
|
| 条件 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| `ALLOW_LIVE_SUBMIT` | 默认 `0` |
|
||||||
|
| `LIVE_CONFIRM` | 空;live 须 `I_CONFIRM_LIVE_SUBMIT` |
|
||||||
|
| `STRATEGY_ID` | **启动必需**;缺失 → exit ≠0 |
|
||||||
|
| 真实 `task/add` | Phase1 **未实现**(仅假客户端单测) |
|
||||||
|
|
||||||
|
## 本地命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /workspace/modelhub-agent-contrib
|
||||||
|
make test
|
||||||
|
make build # 需 Docker + 可达平台 base;失败见 docs/build-notes.md
|
||||||
|
make smoke-health # 无镜像时脚本会拉起本地 server
|
||||||
|
make dry-run # 生成 reports/dry-run-*.md
|
||||||
|
```
|
||||||
|
|
||||||
|
入口:`python -m src.server`(Dockerfile `CMD` 同)
|
||||||
|
|
||||||
|
## 验收勾选(Phase1)
|
||||||
|
|
||||||
|
- [ ] `make test` 绿(health / 缺 STRATEGY_ID / 闸门拒绝 / SIGTERM)
|
||||||
|
- [ ] `make build` 绿 **或** `docs/build-notes.md` 记录平台镜像拉取失败且 **未改默认 FROM**
|
||||||
|
- [ ] `make smoke-health` 绿(镜像或本地 server)
|
||||||
|
- [ ] 根目录 Dockerfile + `EXPOSE 8080`
|
||||||
|
- [ ] README 写清:发布 = 0 分;积分待核实;不代注册/提交
|
||||||
|
- [ ] Makefile **无** register/submit/deploy/push
|
||||||
|
- [ ] dry-run 报告含「需 Victor 人工提交」「积分:待平台核实」
|
||||||
|
- [ ] 未调用任何 ModelHub 写接口
|
||||||
|
|
||||||
|
## 人工下一步(非本 Phase 自动化)
|
||||||
|
|
||||||
|
1. 定 Gitea `<namespace>/<repo>`
|
||||||
|
2. push + tag(`scripts/tag_release.py` 仅本地 tag)
|
||||||
|
3. 控制台新增智能体(见 `docs/register-runbook.md`)
|
||||||
67
docs/platform-contract-agent.md
Normal file
67
docs/platform-contract-agent.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# 平台契约 — 适配智能体(Phase1)
|
||||||
|
|
||||||
|
- 日期:2026-10-16 Asia/Shanghai
|
||||||
|
- 依据:产品规划 `agent-product-plan-2026-10-16.md` §1 + PRD `02-agent-contribution-prd.md`
|
||||||
|
- **冲突裁决:适配 W1 优先**(队列 ≥95 禁 live;默认 live 开启,实时队列闸门控制)
|
||||||
|
|
||||||
|
## 核实等级
|
||||||
|
|
||||||
|
| 等级 | 含义 |
|
||||||
|
|------|------|
|
||||||
|
| **A** | 官方演示仓 / 主站前端明文 |
|
||||||
|
| **B** | PRD 已核实(docs/6 + 控制台,基线 2026-10-12) |
|
||||||
|
| **C** | 社区样例,非平台承诺 |
|
||||||
|
| **D** | 待核实 |
|
||||||
|
|
||||||
|
## 运行契约
|
||||||
|
|
||||||
|
| 项 | 内容 | 等级 |
|
||||||
|
|----|------|------|
|
||||||
|
| 代码托管 | `dev.modelhub.org.cn` 用户仓 | B |
|
||||||
|
| 注册 UI | 名称、仓库地址、**Git Tag**(不可用分支名) | B |
|
||||||
|
| 构建 | 平台 Kaniko,`refs/tags/<tag>` | B |
|
||||||
|
| Dockerfile | **仓库根目录**;base `modelhubxc-4pd.tencentcloudcr.com/xc_agent_platform/python:3.11-slim` | A |
|
||||||
|
| 端口 | **8080**,`EXPOSE 8080` | A |
|
||||||
|
| 健康检查 | `GET /health` → 200 `{"status":"ok"}` | A |
|
||||||
|
| 可选状态 | `GET /status` → 布尔 `strategy_id_present` / `external_service_token_present`(**不回传值**) | 本仓 Phase1 |
|
||||||
|
| 优雅退出 | `SIGTERM`,约 **≤30s** 平台窗口;本实现目标 ~1s 退出主循环 | B |
|
||||||
|
| 平台注入 | `STRATEGY_ID`;`EXTERNAL_SERVICE_TOKEN`(及社区别名) | A/B |
|
||||||
|
| **STRATEGY_ID 缺失** | **Phase1:进程启动失败(exit ≠0)** | 产品规划 Phase1 + PRD |
|
||||||
|
| 资源 | 约 100m–1 CPU / 256–512Mi | B |
|
||||||
|
| **发布智能体得分** | **0 分**(发布 ≠ 得分) | B |
|
||||||
|
| Live 默认 | **开**(Victor 已批准);`ALLOW_LIVE_SUBMIT=0` 停交,`LIVE_CONFIRM` 可选 | 本仓 |
|
||||||
|
| 积分 | 来自**成功适配**;智能体代提成功计同一成功分;公式/周报 **待平台核实** | B / D |
|
||||||
|
|
||||||
|
## 鉴权双轨(文档索引,Phase1 不代调)
|
||||||
|
|
||||||
|
| 场景 | 头 | 等级 |
|
||||||
|
|------|----|------|
|
||||||
|
| 浏览器 / `agent_platform` | `Authorization: Bearer <login token>` | A |
|
||||||
|
| 容器内社区实践 | `Xc-Token: <token>`;body 带 `strategyId` | C |
|
||||||
|
|
||||||
|
## 禁止
|
||||||
|
|
||||||
|
- 代注册、绕过实时队列闸门、Makefile 含 register/submit/deploy/push
|
||||||
|
- 日志/响应/镜像层写入 token 或 STRATEGY_ID **值**
|
||||||
|
- 把「智能体已发布」写入千分进度
|
||||||
|
|
||||||
|
## 冲突裁决(强制)
|
||||||
|
|
||||||
|
| 资源 | 裁决 |
|
||||||
|
|------|------|
|
||||||
|
| 异步验证队列 | 适配 W1 优先;≥95 全体停交(含智能体 live) |
|
||||||
|
| Codex | hold 期扩池/归因 > 智能体脚手架 |
|
||||||
|
| 本骨架 | 默认 live;每次 task/add 前实时读取 onlyMine 队列 |
|
||||||
|
|
||||||
|
## 闸门配置以实现为准
|
||||||
|
|
||||||
|
`ALLOW_LIVE_SUBMIT` 默认 1,0 为 kill switch;`STRATEGY_ID` 必需。
|
||||||
|
Victor 确认已内置,`LIVE_CONFIRM` 仍接受但不必需。
|
||||||
|
`LIVE_QUEUE_THRESHOLD` 默认 95,只有完整分页的 onlyMine task/page 非终态数严格小于阈值才提交。
|
||||||
|
缺凭证、HTTP/平台错误、JSON 错误、分页不完整或无效配置均停交。
|
||||||
|
ASYNC_QUEUE_DEPTH / QUEUE_SNAPSHOT_JSON 仅为额外阻塞提示。
|
||||||
|
每周期最多 `LIVE_MAX_SUBMITS_PER_CYCLE`(默认 3)次;周期
|
||||||
|
`LIVE_CYCLE_SECONDS` 默认 600、最小 60 秒,通过 shutdown Event 等待。
|
||||||
|
候选 A verified_reuse 优先,禁提过滤、GPU enum、占位符检查及请求前持久化去重。
|
||||||
|
`LIVE_STATE_FILE` 默认 /tmp/live-submitted.json;保留该文件可跨周期/重启去重。
|
||||||
|
build-config 失败回退 sample 并记录原因;日志不记录响应正文或凭证。
|
||||||
39
docs/register-runbook.md
Normal file
39
docs/register-runbook.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# 注册智能体 Runbook(仅人工 · Victor)
|
||||||
|
|
||||||
|
> Agent / Bot **不代填、不代点、不代调写接口**。
|
||||||
|
|
||||||
|
## 流程
|
||||||
|
|
||||||
|
1. **本地绿**:`make test`(及有 Docker 时 `make build` + `make smoke-health`)
|
||||||
|
2. **Gitea 建仓**(Victor):`dev.modelhub.org.cn/<namespace>/<repo>`
|
||||||
|
- 命名空间/仓名 **未定**时:仅本地验收,不阻塞 Phase1
|
||||||
|
3. **推送代码**(Victor 本人):本 Makefile **无 push 目标**
|
||||||
|
4. **打 Git Tag**(可用本仓 `scripts/tag_release.py` **仅本地 tag**;push tag 由 Victor 执行)
|
||||||
|
5. **控制台新增**:个人主页 → 我的适配智能体 → 新增
|
||||||
|
- 名称
|
||||||
|
- 仓库地址(远程 Git URL)
|
||||||
|
- **Git Tag**(须先出现在远程;`GET .../tags` 下拉可选后再填)
|
||||||
|
6. **Kaniko 构建**:等待状态;证据同时记中文文案与枚举(`building` / `ready` / `failed` / `running` / `stopped`)
|
||||||
|
7. **部署后**:确认容器 `GET /health` → 200;积分应 **无变化**(发布 = 0 分)
|
||||||
|
|
||||||
|
## POST `/agent_platform/strategies`(文档说明 · 禁止代调)
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `name` | 策略显示名 |
|
||||||
|
| `repo_url` | Gitea 仓库 URL |
|
||||||
|
| `tag` | Git Tag(非分支) |
|
||||||
|
|
||||||
|
成功响应含 `.id`(等级 A:前端 bundle)。
|
||||||
|
**本仓与 Agent 禁止代调该写接口。**
|
||||||
|
|
||||||
|
相关写接口(同样禁止代调):`/{id}/deploy`、`/{id}/stop`、`DELETE /{id}`。
|
||||||
|
|
||||||
|
## 环境变量(平台注入 / 控制台)
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `STRATEGY_ID` | 策略 id;本仓启动必需 |
|
||||||
|
| `EXTERNAL_SERVICE_TOKEN` | 服务凭证;只检存在,不记值 |
|
||||||
|
| `ALLOW_LIVE_SUBMIT` | 默认 `0` |
|
||||||
|
| `LIVE_CONFIRM` | live 时须为 `I_CONFIRM_LIVE_SUBMIT` |
|
||||||
14
docs/tag-dryrun-notes-draft.md
Normal file
14
docs/tag-dryrun-notes-draft.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Tag 文案草稿(仍默认 dry-run)
|
||||||
|
|
||||||
|
- 建议 Tag:`v0.2.0-phase2-dryrun`
|
||||||
|
- 基线:控制台当前 `v0.1.0-phase1` @ `0d0f3b6`
|
||||||
|
- 相对 Phase1:pending **Section A** + 经验序;只读 `task/page` 探针;`build-config` 客户端(默认离线 sample,opt-in 才调 API);`task/add` 双重闸门默认关
|
||||||
|
- **不含**:live 开闸、默认真实提交
|
||||||
|
- 推送:仅 Victor 本人;Agent 不代 push
|
||||||
|
- 队列≥95 / 无书面批准:**不开闸**
|
||||||
|
|
||||||
|
检查清单:
|
||||||
|
1. `make test` 42/42
|
||||||
|
2. `ALLOW_LIVE_SUBMIT` 默认非 1
|
||||||
|
3. 重建后 `/health` 200;`/status` token/strategy 布尔
|
||||||
|
4. 不代注册、不代 live
|
||||||
70
main.py
70
main.py
@@ -1,70 +0,0 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
|
|
||||||
|
|
||||||
HOST = "0.0.0.0"
|
|
||||||
PORT = 8080
|
|
||||||
shutdown_requested = False
|
|
||||||
|
|
||||||
|
|
||||||
def _config() -> dict:
|
|
||||||
token = os.getenv("EXTERNAL_SERVICE_TOKEN", "")
|
|
||||||
return {
|
|
||||||
"external_service_token_present": bool(token),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
|
||||||
def do_GET(self) -> None:
|
|
||||||
if self.path == "/health":
|
|
||||||
self._send_json({"status": "ok"})
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.path == "/":
|
|
||||||
self._send_json(
|
|
||||||
{
|
|
||||||
"name": "strategy-demo",
|
|
||||||
"status": "running",
|
|
||||||
"config": _config(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._send_json({"error": "not found"}, status=404)
|
|
||||||
|
|
||||||
def log_message(self, fmt: str, *args: object) -> None:
|
|
||||||
print(f"{self.address_string()} - {fmt % args}", flush=True)
|
|
||||||
|
|
||||||
def _send_json(self, body: dict, status: int = 200) -> None:
|
|
||||||
payload = json.dumps(body).encode()
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Content-Length", str(len(payload)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(payload)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_signal(signum: int, _frame: object) -> None:
|
|
||||||
global shutdown_requested
|
|
||||||
shutdown_requested = True
|
|
||||||
print(f"received signal {signum}, shutting down", flush=True)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
signal.signal(signal.SIGTERM, _handle_signal)
|
|
||||||
signal.signal(signal.SIGINT, _handle_signal)
|
|
||||||
|
|
||||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
||||||
server.timeout = 1
|
|
||||||
print(f"strategy-demo listening on {HOST}:{PORT}", flush=True)
|
|
||||||
|
|
||||||
while not shutdown_requested:
|
|
||||||
server.handle_request()
|
|
||||||
|
|
||||||
server.server_close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
0
reports/.gitkeep
Normal file
0
reports/.gitkeep
Normal file
11
reports/live-gate-summary.md
Normal file
11
reports/live-gate-summary.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Live gate update — 2026-10-17
|
||||||
|
|
||||||
|
Queue snapshots accept integer or ASCII digit-string totals and derive model/GPU pairs from modelId and gpuType across all statuses. Nonterminal tasks count toward the queue gate. Invalid or incomplete snapshots stop submissions with ledger_rebuild_failed.
|
||||||
|
|
||||||
|
Token resolution prefers EXTERNAL_SERVICE_TOKEN, XC_TOKEN_FILE, XC_TOKEN, then MODELHUB_XC_TOKEN; empty and placeholder credentials are skipped. Missing credentials hold even with injected submit clients. Each allowed cycle logs only the selected source name. Xc-Token remains the authentication header.
|
||||||
|
|
||||||
|
Offline generators read local adapt evidence only. Input candidates: A=60, B=296; after pending and banned filters: A=46, B=296, total=342. Verified reuse A precedes other A, then B. Baked ledger: 206 pairs and 28 blocked models. Baked JSON ships through COPY src/; .dockerignore does not exclude src/data/*.json.
|
||||||
|
|
||||||
|
The ledger merges remote pairs, baked pairs, and persisted model|gpu pairs. Explicit do-not-resubmit models are blocked on every GPU; other models may use the next mapped, unsubmitted GPU in candidate order. Reservations are written before task/add, including failed attempts. Legacy model-only state files fail closed and require offline migration. Startup rebuild logs source counts; each prospective submission refreshes remote identities and queue count.
|
||||||
|
|
||||||
|
Validation: make test passes 59 tests using stdlib only. Added coverage for pair dedupe and restart persistence, baked blocks, next-GPU selection, missing tokens, token source logging, digit-string totals, invalid totals, and baked candidate ordering/filtering. SIGTERM uses a mocked HTTP server and real subprocess signal without network calls. Dockerfile FROM unchanged. No git commit, push, tag, live probe, build-config call, or live submission performed.
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
23
scripts/bake_candidates.py
Normal file
23
scripts/bake_candidates.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""Bake the local pending pool without network access."""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from src.pending_pool import build_ordered_candidates
|
||||||
|
from src.banned import banned_rule
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
rows = build_ordered_candidates('/workspace/modelhub-adapt/pending-submit.md', '/workspace/modelhub-adapt/pending-submit.txt')
|
||||||
|
print('input', dict(Counter(r['section'] for r in rows)))
|
||||||
|
rows = [dict(model_address=r['model_address'], section=r['section'], foothold_class=r['foothold_class'], try_next_gpus=r['try_next_gpus'], framework='vllm') for r in rows if not r['skip_reason'] and not banned_rule(r['model_address'])]
|
||||||
|
rows.sort(key=lambda r: (r['section'] != 'A', r['foothold_class'] != 'verified_reuse'))
|
||||||
|
output = Path(__file__).resolve().parents[1] / 'src/data/candidates.json'
|
||||||
|
output.parent.mkdir(exist_ok=True)
|
||||||
|
output.write_text(json.dumps(rows, ensure_ascii=False, indent=2) + '\n')
|
||||||
|
print('baked', dict(Counter(r['section'] for r in rows)), 'total', len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
39
scripts/bake_ledger.py
Normal file
39
scripts/bake_ledger.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Extract only model/GPU identities from local evidence; never copy raw fields."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from src.config_params import GPU_ENUMS
|
||||||
|
from src.live_queue import model_key
|
||||||
|
from src.pending_pool import load_do_not_resubmit
|
||||||
|
|
||||||
|
|
||||||
|
def extract(value, source, pairs):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
model = value.get('modelId') or value.get('model') or value.get('model_address')
|
||||||
|
gpu = value.get('gpuType') or value.get('gpu_code') or value.get('gpu') or value.get('gpuLabel')
|
||||||
|
if isinstance(model, str) and re.fullmatch(r'[\w.-]+/[\w.\-/]+', model) and isinstance(gpu, str) and gpu in GPU_ENUMS:
|
||||||
|
pairs.setdefault((model_key(model), GPU_ENUMS[gpu]), source)
|
||||||
|
for child in value.values():
|
||||||
|
extract(child, source, pairs)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for child in value:
|
||||||
|
extract(child, source, pairs)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
base = Path('/workspace/modelhub-adapt')
|
||||||
|
files = [base / 'logs/task-page-snapshot-2026-10-17.json', *sorted((base / 'logs').glob('submit-session-*.json')), *sorted((base / 'logs').glob('submit-batch-*.json'))]
|
||||||
|
pairs = {}
|
||||||
|
for path in files:
|
||||||
|
extract(json.loads(path.read_text()), path.name, pairs)
|
||||||
|
blocks = sorted(map(model_key, load_do_not_resubmit(base / 'do-not-resubmit.md')))
|
||||||
|
output = Path(__file__).resolve().parents[1] / 'src/data/submitted-ledger.json'
|
||||||
|
output.parent.mkdir(exist_ok=True)
|
||||||
|
output.write_text(json.dumps(dict(pairs=[dict(model=m, gpu=g, source=s) for (m,g),s in sorted(pairs.items())], blocked_models=blocks), indent=2) + '\n')
|
||||||
|
print('baked pairs', len(pairs), 'blocked models', len(blocks))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
26
scripts/build_config_dry.py
Normal file
26
scripts/build_config_dry.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate config only; never creates a task or prints returned YAML/secrets."""
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from src.build_config_client import build_config, BuildConfigError
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--gpu-type', default='Sunrise_pt-200-x1')
|
||||||
|
parser.add_argument('--framework', default='vllm')
|
||||||
|
parser.add_argument('--task-type', default='text-generation')
|
||||||
|
args = parser.parse_args()
|
||||||
|
try:
|
||||||
|
yaml = build_config(args.gpu_type, args.framework, args.task_type)
|
||||||
|
except BuildConfigError as error:
|
||||||
|
print(str(error), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f'configParams_source=build-config; generated YAML characters={len(yaml)}; no task submitted')
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
88
scripts/dry_run.py
Executable file
88
scripts/dry_run.py
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only Phase2 pending draft report. Token-resolvable build-config first; explicit sample fallback."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
from src.pending_pool import build_ordered_candidates, input_path, experience_order
|
||||||
|
from src.config_params import build_config_params_with_meta, SOURCE, GAP
|
||||||
|
from src.queue_depth import queue_depth
|
||||||
|
from src.task_add_client import build_payload
|
||||||
|
|
||||||
|
TZ = ZoneInfo('Asia/Shanghai')
|
||||||
|
|
||||||
|
|
||||||
|
def _load_candidates(path):
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
return data if isinstance(data, list) else list(data['candidates'])
|
||||||
|
|
||||||
|
|
||||||
|
def _render(candidates, ts, src):
|
||||||
|
candidates = [c for c in candidates if not os.environ.get('PENDING_SECTION') or
|
||||||
|
c.get('section', 'B') == os.environ['PENDING_SECTION'].strip().upper()]
|
||||||
|
depth = queue_depth()
|
||||||
|
lines = [f'# 代提候选 / 需 Victor 确认 — Dry-run — {ts} (Asia/Shanghai)', '',
|
||||||
|
'> 模式:dry-run;仅配置生成 API;无任务提交;需 Victor 人工提交/确认',
|
||||||
|
'> 积分:待平台核实', '> perf_order=unset;order_source=experience(经验序 / 待平台核实)',
|
||||||
|
f'> configParams source: {SOURCE}', '> API foothold evidence: GAP (not wired)', f'> {GAP}', '',
|
||||||
|
'## A→B 顺序草稿', '', f'输入:{src};候选条数:{len(candidates)}',
|
||||||
|
f'队列深度:{depth if depth is not None else "未知,需人工确认"}',
|
||||||
|
'队列≥95:禁 live;适配 W1 优先,不抢空位。', '']
|
||||||
|
if depth is not None and depth >= 95:
|
||||||
|
lines.extend(['> **禁 live / 队列≥95**', ''])
|
||||||
|
for i, c in enumerate(candidates, 1):
|
||||||
|
model = c.get('model_address') or c.get('modelAddress') or c.get('model_id') or '<MODEL>'
|
||||||
|
gpus = experience_order(c.get('try_next_gpus', []))
|
||||||
|
target = gpus[0] if gpus else c.get('targetGpu', '<TARGET_GPU:需人工确认>')
|
||||||
|
status = c.get('status', 'needs_human')
|
||||||
|
lines.extend([f'### {i}. Section {c.get("section", "B")} — `{model}`', '',
|
||||||
|
f'状态:{status};needs_human={c.get("needs_human", True)}',
|
||||||
|
f'过滤:{c.get("skip_reason") or "名称硬门未命中;元数据/唯一性待人工核实"}', ''])
|
||||||
|
lines.extend([f'未适配卡(仅 pending 缺口):{", ".join(gpus) or "未提供,需确认"}',
|
||||||
|
'perf_order=unset;order_source=experience;天垓100 一般不扇出', ''])
|
||||||
|
yaml, meta = build_config_params_with_meta(target)
|
||||||
|
lines.extend([f'foothold_class={c.get("foothold_class", "unknown")}; evidence_source={c.get("evidence_source", "GAP")}; no_download={c.get("no_download", False)}',
|
||||||
|
'; '.join(f'{key}={value}' for key, value in meta.items()), ''])
|
||||||
|
draft = build_payload(model, target, 'vllm', strategy_id='<STRATEGY_ID>',
|
||||||
|
config_params=yaml)
|
||||||
|
# Display only; this truncated draft must never be submitted.
|
||||||
|
draft['configParams'] = draft['configParams'][:400] + '\n… [truncated; dry-run only]'
|
||||||
|
lines.extend(['build-config query: gpuType=<targetGpu>&framework=vllm&taskType=text-generation; POST empty body', 'POST 字段草稿(skipped 行禁止提交):', '```json', json.dumps(draft, ensure_ascii=False, indent=2), '```',
|
||||||
|
'需 Victor 人工提交/确认;积分:待平台核实', ''])
|
||||||
|
report = '\n'.join(lines)
|
||||||
|
# Defensive redaction even if an input happens to contain an injected credential.
|
||||||
|
secrets = [os.environ.get(k, '') for k in ('XC_TOKEN', 'MODELHUB_XC_TOKEN', 'EXTERNAL_SERVICE_TOKEN', 'Xc-Token')]
|
||||||
|
if os.environ.get('XC_TOKEN_FILE'):
|
||||||
|
try:
|
||||||
|
secrets.append(Path(os.environ['XC_TOKEN_FILE']).read_text().strip())
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
for secret in secrets:
|
||||||
|
if secret:
|
||||||
|
report = report.replace(secret, '[REDACTED]')
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
md = input_path('PENDING_SUBMIT_MD', 'pending-submit.md')
|
||||||
|
txt = input_path('PENDING_SUBMIT_TXT', 'pending-submit.txt')
|
||||||
|
if md.is_file() or txt.is_file():
|
||||||
|
src = md if md.is_file() else txt
|
||||||
|
candidates = build_ordered_candidates(md, txt)
|
||||||
|
else:
|
||||||
|
src = Path(os.environ.get('DRY_RUN_CANDIDATES', ROOT / 'config/candidates.example.json'))
|
||||||
|
candidates = _load_candidates(src)
|
||||||
|
now = datetime.now(TZ)
|
||||||
|
out = ROOT / 'reports' / f'dry-run-{now:%Y%m%d-%H%M%S}.md'
|
||||||
|
out.parent.mkdir(exist_ok=True)
|
||||||
|
out.write_text(_render(candidates, now.strftime('%Y-%m-%d %H:%M:%S'), src), encoding='utf-8')
|
||||||
|
print(f'Wrote {out}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
17
scripts/probe_readonly.py
Normal file
17
scripts/probe_readonly.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run only the task/page GET; print credential-free summary."""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from src.readonly_probe import probe_readonly
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
result = probe_readonly()
|
||||||
|
print(json.dumps(result, ensure_ascii=False))
|
||||||
|
return 0 if result['auth_ok'] else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
100
scripts/smoke_health.py
Executable file
100
scripts/smoke_health.py
Executable file
@@ -0,0 +1,100 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Smoke: GET /health must return 200 {"status":"ok"}.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Against already-running server:
|
||||||
|
SMOKE_BASE_URL=http://127.0.0.1:8080 python3 scripts/smoke_health.py
|
||||||
|
|
||||||
|
# Or let this script start a local server (needs STRATEGY_ID):
|
||||||
|
STRATEGY_ID=test python3 scripts/smoke_health.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url: str, timeout: float = 2.0) -> tuple[int, dict]:
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
body = resp.read().decode("utf-8")
|
||||||
|
return resp.status, json.loads(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_health(base: str, attempts: int = 30) -> None:
|
||||||
|
url = base.rstrip("/") + "/health"
|
||||||
|
last_err: Exception | None = None
|
||||||
|
for _ in range(attempts):
|
||||||
|
try:
|
||||||
|
code, data = _get(url)
|
||||||
|
if code == 200 and data.get("status") == "ok":
|
||||||
|
print(f"OK: GET {url} -> {code} {data}")
|
||||||
|
return
|
||||||
|
raise AssertionError(f"unexpected response: {code} {data}")
|
||||||
|
except (urllib.error.URLError, ConnectionError, AssertionError, TimeoutError) as exc:
|
||||||
|
last_err = exc
|
||||||
|
time.sleep(0.2)
|
||||||
|
raise SystemExit(f"smoke-health failed: {last_err}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
external = os.environ.get("SMOKE_BASE_URL", "").strip()
|
||||||
|
if external:
|
||||||
|
_wait_health(external)
|
||||||
|
# optional status check
|
||||||
|
try:
|
||||||
|
code, data = _get(external.rstrip("/") + "/status")
|
||||||
|
print(f"OK: GET /status -> {code} {data}")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f"WARN: /status check skipped: {exc}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.environ.get("STRATEGY_ID", "").strip():
|
||||||
|
os.environ["STRATEGY_ID"] = "smoke-test-strategy"
|
||||||
|
|
||||||
|
port = os.environ.get("SMOKE_PORT", "18080")
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PORT"] = port
|
||||||
|
env["HOST"] = "127.0.0.1"
|
||||||
|
env["STRATEGY_ID"] = env.get("STRATEGY_ID") or "smoke-test-strategy"
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "src.server"],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_health(f"http://127.0.0.1:{port}")
|
||||||
|
code, data = _get(f"http://127.0.0.1:{port}/status")
|
||||||
|
print(f"OK: GET /status -> {code} {data}")
|
||||||
|
assert "strategy_id_present" in data
|
||||||
|
assert data["strategy_id_present"] is True
|
||||||
|
# never assert on secret values — only booleans
|
||||||
|
finally:
|
||||||
|
proc.send_signal(signal.SIGTERM)
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait(timeout=2)
|
||||||
|
if proc.returncode not in (0, -signal.SIGTERM, signal.SIGTERM):
|
||||||
|
# 0 or terminated by SIGTERM is fine
|
||||||
|
err = (proc.stderr.read() or b"").decode("utf-8", errors="replace")
|
||||||
|
if proc.returncode is not None and proc.returncode > 0:
|
||||||
|
print(f"WARN: server exit code {proc.returncode}: {err}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
43
scripts/tag_release.py
Executable file
43
scripts/tag_release.py
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create a local git tag only. Does NOT push. Does NOT register on ModelHub.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/tag_release.py v0.1.0
|
||||||
|
python3 scripts/tag_release.py v0.1.0 --message "Phase1 skeleton"
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Local git tag only (no push, no register)")
|
||||||
|
parser.add_argument("tag", help="Tag name, e.g. v0.1.0")
|
||||||
|
parser.add_argument("--message", "-m", default="", help="Annotated tag message")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not (ROOT / ".git").exists():
|
||||||
|
print(
|
||||||
|
"WARN: not a git repository yet. Init locally if needed; "
|
||||||
|
"this script will not push or register.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
# Still attempt so the failure is clear
|
||||||
|
msg = args.message or f"Release {args.tag} (local tag only — no push / no ModelHub register)"
|
||||||
|
cmd = ["git", "tag", "-a", args.tag, "-m", msg]
|
||||||
|
print(f"Running (cwd={ROOT}): {' '.join(cmd)}")
|
||||||
|
print("NOTE: This does NOT git push and does NOT call ModelHub APIs.")
|
||||||
|
result = subprocess.run(cmd, cwd=str(ROOT))
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise SystemExit(result.returncode)
|
||||||
|
print(f"OK: local tag {args.tag!r} created. Push tag yourself when ready for Kaniko.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
3
src/__init__.py
Normal file
3
src/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""ModelHub XC adaptation agent — Phase1 local skeleton."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
15
src/banned.py
Normal file
15
src/banned.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""Case-insensitive live model exclusions."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
RULES = (
|
||||||
|
('gpt2', r'gpt-?2|dialogpt|distilgpt2'),
|
||||||
|
('phi', r'phi[-_. ]?(?:3(?:\.5)?|4)(?:\D|$)'),
|
||||||
|
('nemotron-h', r'nemotron-h'), ('olmo2', r'olmo-?2'),
|
||||||
|
('lfm2', r'lfm2'), ('spark2.5', r'spark2_5|spark-x2\.5'),
|
||||||
|
('breeze', r'breeze'), ('gemma2', r'gemma-?2'),
|
||||||
|
('mistral-v0.1', r'mistral.*v0\.1'), ('tinyllama', r'tinyllama'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def banned_rule(model):
|
||||||
|
return next((name for name, pattern in RULES if re.search(pattern, model, re.I)), None)
|
||||||
60
src/build_config_client.py
Normal file
60
src/build_config_client.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Explicit configuration generation; import performs no I/O.
|
||||||
|
|
||||||
|
Returns YAML on success, otherwise raises BuildConfigError with safe attribution
|
||||||
|
and HTTP/platform status. Server messages and transport details are never exposed.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from src.task_add_client import resolve_token
|
||||||
|
|
||||||
|
|
||||||
|
class BuildConfigError(RuntimeError):
|
||||||
|
def __init__(self, reason, http_status=None, platform_code=None):
|
||||||
|
self.error_attribution = reason
|
||||||
|
self.http_status = http_status
|
||||||
|
self.platform_code = platform_code
|
||||||
|
super().__init__('build-config: ' + reason)
|
||||||
|
|
||||||
|
|
||||||
|
def build_config(gpu_type, framework='vllm', task_type='text-generation'):
|
||||||
|
try:
|
||||||
|
token = resolve_token()
|
||||||
|
except (ValueError, OSError):
|
||||||
|
raise BuildConfigError('missing/invalid credential') from None
|
||||||
|
if not isinstance(gpu_type, str) or not gpu_type.strip() or '<' in gpu_type:
|
||||||
|
raise BuildConfigError('gpu_type required (platform enum)')
|
||||||
|
query = urllib.parse.urlencode(dict(gpuType=gpu_type, framework=framework, taskType=task_type))
|
||||||
|
header = 'Authorization' if os.environ.get('BUILD_CONFIG_USE_AUTHORIZATION') == '1' else 'Xc-Token'
|
||||||
|
try:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
os.environ.get('MODELHUB_API_BASE', 'https://www.modelhub.org.cn').rstrip('/')
|
||||||
|
+ '/api/adapt/task/build-config?' + query,
|
||||||
|
headers={header: token, 'Accept': 'application/json'}, method='POST')
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as response:
|
||||||
|
status, raw = response.status, response.read()
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
status, raw = error.code, error.read()
|
||||||
|
except (OSError, urllib.error.URLError, ValueError):
|
||||||
|
raise BuildConfigError('transport/timeout') from None
|
||||||
|
try:
|
||||||
|
body = json.loads(raw)
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
raise BuildConfigError('invalid JSON response', status) from None
|
||||||
|
if not isinstance(body, dict):
|
||||||
|
raise BuildConfigError('invalid response object', status)
|
||||||
|
code = body.get('code')
|
||||||
|
# Only numeric codes are retained; arbitrary server strings may echo secrets.
|
||||||
|
code = code if type(code) is int else None
|
||||||
|
if code in (40100, 40101):
|
||||||
|
raise BuildConfigError({40100: '40100 NOT_LOGIN_ERROR', 40101: '40101 NO_AUTH_ERROR'}[code], status, code)
|
||||||
|
if not 200 <= status < 300 or code != 0:
|
||||||
|
raise BuildConfigError('other HTTP/platform code', status, code)
|
||||||
|
data = body.get('data')
|
||||||
|
if not isinstance(data, str) or not data.strip():
|
||||||
|
raise BuildConfigError('empty/non-str data', status, code)
|
||||||
|
if token in data:
|
||||||
|
raise BuildConfigError('credential echoed in data', status, code)
|
||||||
|
return data
|
||||||
71
src/config_params.py
Normal file
71
src/config_params.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""Dry-run configuration: token-resolvable build-config first; failures fall back to sample with GAP."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from src.build_config_client import build_config, BuildConfigError
|
||||||
|
from src.task_add_client import resolve_token
|
||||||
|
|
||||||
|
SOURCE = 'POST /api/adapt/task/build-config (discovered via capture); fallback templates/config_params.sample.yaml'
|
||||||
|
GAP = 'GAP: build-config API discovered via capture; sample is dry-run draft only, NOT production-ready. API foothold evidence: GAP (not wired).'
|
||||||
|
GPU_ENUMS = {
|
||||||
|
'壁砺': 'Biren_166m', '壁砺166M': 'Biren_166m', 'Biren_166m': 'Biren_166m',
|
||||||
|
'曦望': 'Sunrise_pt-200-x1', '曦望 S2': 'Sunrise_pt-200-x1',
|
||||||
|
'Sunrise_pt-200-x1': 'Sunrise_pt-200-x1',
|
||||||
|
'天垓100': 'Iluvatar_bi-100', 'Iluvatar_bi-100': 'Iluvatar_bi-100',
|
||||||
|
'天垓150': 'Iluvatar_bi-150', 'Iluvatar_bi-150': 'Iluvatar_bi-150',
|
||||||
|
'智铠': 'Iluvatar_mrv-100', '智铠100': 'Iluvatar_mrv-100',
|
||||||
|
'智铠 mrv-100': 'Iluvatar_mrv-100', 'Iluvatar_mrv-100': 'Iluvatar_mrv-100',
|
||||||
|
'MTT S4000': 'Mthreads_s4000', '摩尔线程': 'Mthreads_s4000', 'Mthreads_s4000': 'Mthreads_s4000',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def config_params_source():
|
||||||
|
"""Requested source preference for dry-run.
|
||||||
|
|
||||||
|
- CONFIG_PARAMS_SOURCE=sample → force offline sample
|
||||||
|
- CONFIG_PARAMS_SOURCE=build-config → force API path (with soft fallback in with_meta)
|
||||||
|
- default (empty): prefer build-config when resolve_token() succeeds; else sample
|
||||||
|
- ALLOW_BUILD_CONFIG=1 remains a legacy opt-in synonym when source empty and token ok
|
||||||
|
"""
|
||||||
|
source = os.environ.get('CONFIG_PARAMS_SOURCE', '').strip()
|
||||||
|
if source not in ('', 'sample', 'build-config'):
|
||||||
|
raise BuildConfigError('unsupported CONFIG_PARAMS_SOURCE')
|
||||||
|
if source:
|
||||||
|
return source
|
||||||
|
try:
|
||||||
|
resolve_token()
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return 'sample'
|
||||||
|
# Token resolvable → prefer build-config (kickoff select-opt default).
|
||||||
|
return 'build-config'
|
||||||
|
|
||||||
|
|
||||||
|
def build_config_params_with_meta(gpu_type=None, framework='vllm', task_type='text-generation'):
|
||||||
|
"""Return (YAML, metadata); API failures explicitly fall back for dry-run.
|
||||||
|
|
||||||
|
scripts/build_config_dry.py calls build_config directly and remains strict.
|
||||||
|
"""
|
||||||
|
source = config_params_source()
|
||||||
|
meta = {'configParams_source': 'sample'}
|
||||||
|
try:
|
||||||
|
resolve_token()
|
||||||
|
except (ValueError, OSError):
|
||||||
|
meta['auth'] = 'missing'
|
||||||
|
else:
|
||||||
|
meta['auth'] = 'ok'
|
||||||
|
gpu_enum = GPU_ENUMS.get(gpu_type)
|
||||||
|
if source == 'build-config' and gpu_enum is None:
|
||||||
|
meta['configParams_fallback_reason'] = 'GAP: gpu enum unmapped'
|
||||||
|
elif source == 'build-config':
|
||||||
|
try:
|
||||||
|
yaml = build_config(gpu_enum, framework, task_type)
|
||||||
|
except BuildConfigError as error:
|
||||||
|
meta['configParams_fallback_reason'] = 'GAP: ' + error.error_attribution
|
||||||
|
else:
|
||||||
|
meta['configParams_source'] = 'build-config'
|
||||||
|
return yaml, meta
|
||||||
|
return (Path(__file__).resolve().parents[1] / 'templates/config_params.sample.yaml').read_text(), meta
|
||||||
|
|
||||||
|
|
||||||
|
def build_config_params(gpu_type=None, framework='vllm', task_type='text-generation'):
|
||||||
|
"""Compatible YAML-only dry-run wrapper, including explicit fallback."""
|
||||||
|
return build_config_params_with_meta(gpu_type, framework, task_type)[0]
|
||||||
3661
src/data/candidates.json
Normal file
3661
src/data/candidates.json
Normal file
File diff suppressed because it is too large
Load Diff
1064
src/data/submitted-ledger.json
Normal file
1064
src/data/submitted-ledger.json
Normal file
File diff suppressed because it is too large
Load Diff
74
src/gate.py
Normal file
74
src/gate.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Live defaults on with baked-in approval. LIVE_CONFIRM remains optional.
|
||||||
|
|
||||||
|
Environment hints only block; a fresh authenticated API snapshot authorizes each add.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from .strategy import strategy_id_present
|
||||||
|
|
||||||
|
LIVE_CONFIRM_VALUE = 'I_CONFIRM_LIVE_SUBMIT'
|
||||||
|
|
||||||
|
|
||||||
|
def live_enabled():
|
||||||
|
return os.environ.get('ALLOW_LIVE_SUBMIT', '1').strip() == '1'
|
||||||
|
|
||||||
|
|
||||||
|
def queue_threshold():
|
||||||
|
value = int(os.environ.get('LIVE_QUEUE_THRESHOLD', '95'))
|
||||||
|
if value < 1:
|
||||||
|
raise ValueError('invalid threshold')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GateDecision:
|
||||||
|
mode: str
|
||||||
|
allowed: bool
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_gate():
|
||||||
|
if not live_enabled():
|
||||||
|
return GateDecision('dry-run', False, 'kill_switch')
|
||||||
|
if not strategy_id_present():
|
||||||
|
return GateDecision('blocked', False, 'STRATEGY_ID missing or empty')
|
||||||
|
try:
|
||||||
|
threshold = queue_threshold()
|
||||||
|
except ValueError:
|
||||||
|
return GateDecision('blocked', False, 'invalid_threshold')
|
||||||
|
# Evaluate each configured hint independently; neither can override the other.
|
||||||
|
for key in ('ASYNC_QUEUE_DEPTH', 'QUEUE_SNAPSHOT_JSON'):
|
||||||
|
if key not in os.environ:
|
||||||
|
continue
|
||||||
|
if key == 'ASYNC_QUEUE_DEPTH':
|
||||||
|
try:
|
||||||
|
depth = int(os.environ[key])
|
||||||
|
except ValueError:
|
||||||
|
depth = None
|
||||||
|
else:
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
try:
|
||||||
|
data = json.loads(Path(os.environ[key]).read_text())
|
||||||
|
value = next(data[k] for k in ('depth', 'queue_depth', 'async_queue_depth', 'ASYNC_QUEUE_DEPTH') if k in data)
|
||||||
|
depth = None if isinstance(value, bool) else int(str(value))
|
||||||
|
except (OSError, ValueError, TypeError, StopIteration):
|
||||||
|
depth = None
|
||||||
|
if depth is None or depth < 0:
|
||||||
|
return GateDecision('blocked', False, 'queue_hint_invalid')
|
||||||
|
if depth >= threshold:
|
||||||
|
return GateDecision('blocked', False, f'queue>={threshold} hint_count={depth}')
|
||||||
|
return GateDecision('live', True, 'live_enabled')
|
||||||
|
|
||||||
|
|
||||||
|
def attempt_live_submit(payload, client=None, queue_reader=None):
|
||||||
|
"""Compatibility entry point; delegates to the same fresh queue cycle."""
|
||||||
|
if client is None:
|
||||||
|
return dict(ok=False, submitted=False, mode='blocked', reason='no real request without explicit client')
|
||||||
|
from .live_cycle import run_cycle
|
||||||
|
decisions = run_cycle([dict(model_address=payload.get('modelAddress'), targetGpu=payload.get('targetGpu'),
|
||||||
|
framework=payload.get('framework', 'vllm'), configParams=payload.get('configParams'))],
|
||||||
|
client=client, queue_reader=queue_reader)
|
||||||
|
result = decisions[-1]
|
||||||
|
return dict(result, ok=result['action'] == 'submitted', submitted=result['action'] == 'submitted',
|
||||||
|
mode='live' if result['action'] == 'submitted' else 'blocked')
|
||||||
33
src/health.py
Normal file
33
src/health.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Health and status HTTP handlers. Never echo secrets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
|
from .strategy import external_service_token_present, strategy_id_present
|
||||||
|
|
||||||
|
|
||||||
|
def health_payload() -> Dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def status_payload() -> Dict[str, Any]:
|
||||||
|
"""Booleans only — never include STRATEGY_ID or token values."""
|
||||||
|
return {
|
||||||
|
"strategy_id_present": strategy_id_present(),
|
||||||
|
"external_service_token_present": external_service_token_present(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_get(path: str) -> Tuple[int, Dict[str, str], bytes]:
|
||||||
|
"""Return (status_code, headers, body) for GET paths."""
|
||||||
|
headers = {"Content-Type": "application/json; charset=utf-8"}
|
||||||
|
if path == "/health" or path.startswith("/health?"):
|
||||||
|
body = json.dumps(health_payload(), ensure_ascii=False).encode("utf-8")
|
||||||
|
return 200, headers, body
|
||||||
|
if path == "/status" or path.startswith("/status?"):
|
||||||
|
body = json.dumps(status_payload(), ensure_ascii=False).encode("utf-8")
|
||||||
|
return 200, headers, body
|
||||||
|
body = json.dumps({"error": "not_found"}, ensure_ascii=False).encode("utf-8")
|
||||||
|
return 404, headers, body
|
||||||
35
src/ledger.py
Normal file
35
src/ledger.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""Pair reservations and explicit model blocks; no credentials or response bodies."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from .live_queue import model_key, read_queue
|
||||||
|
|
||||||
|
DATA = Path(__file__).parent / 'data/submitted-ledger.json'
|
||||||
|
|
||||||
|
|
||||||
|
def load_baked(path=DATA):
|
||||||
|
data = json.loads(Path(path).read_text())
|
||||||
|
return ({(model_key(r['model']), r['gpu']) for r in data['pairs']},
|
||||||
|
{model_key(m) for m in data['blocked_models']})
|
||||||
|
|
||||||
|
|
||||||
|
def load_persisted(path):
|
||||||
|
rows = json.loads(path.read_text()) if path.exists() else []
|
||||||
|
if not isinstance(rows, list) or any(not isinstance(r, str) or r.count('|') != 1 for r in rows):
|
||||||
|
raise ValueError('Invalid pair ledger')
|
||||||
|
return {(model_key(m), g) for m, g in (r.split('|') for r in rows) if m and g}
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild(state, reader=None):
|
||||||
|
count, reason, remote = (reader or read_queue)()
|
||||||
|
if type(count) is not int or count < 0:
|
||||||
|
raise ValueError('ledger_rebuild_failed')
|
||||||
|
baked, blocked = load_baked()
|
||||||
|
persisted = load_persisted(state)
|
||||||
|
return count, remote | baked | persisted, blocked, (len(remote), len(baked), len(persisted))
|
||||||
|
|
||||||
|
|
||||||
|
def persist(path, pairs):
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_name(path.name + '.tmp')
|
||||||
|
temporary.write_text(json.dumps(sorted(m + '|' + g for m, g in pairs)))
|
||||||
|
temporary.replace(path)
|
||||||
143
src/live_cycle.py
Normal file
143
src/live_cycle.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Queue-gated live cycles; all remote collaborators are injectable."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from .banned import banned_rule
|
||||||
|
from .config_params import GPU_ENUMS, build_config_params_with_meta
|
||||||
|
from .gate import evaluate_gate, queue_threshold
|
||||||
|
from .live_queue import read_queue, model_key
|
||||||
|
from .pending_pool import build_ordered_candidates
|
||||||
|
from .task_add_client import TaskAddClient, build_payload, resolve_token_with_source
|
||||||
|
from .ledger import load_baked, load_persisted, persist, rebuild
|
||||||
|
|
||||||
|
_submitted = set()
|
||||||
|
_cycle_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def run_cycle(candidates=None, client=None, queue_reader=None, now=None):
|
||||||
|
with _cycle_lock:
|
||||||
|
return _run_cycle(candidates, client, queue_reader, now)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_cycle(candidates, client, queue_reader, now):
|
||||||
|
decisions = []
|
||||||
|
|
||||||
|
def emit(action, reason, **fields):
|
||||||
|
decision = dict(action=action, reason=reason, **fields)
|
||||||
|
decisions.append(decision)
|
||||||
|
print('[live] ' + action + ' ' + ' '.join(f'{k}={str(v).replace(chr(10), " ").replace(chr(13), " ")}' for k, v in dict(fields, reason=reason).items()), file=sys.stderr)
|
||||||
|
|
||||||
|
gate = evaluate_gate()
|
||||||
|
if not gate.allowed:
|
||||||
|
emit('hold', gate.reason)
|
||||||
|
return decisions
|
||||||
|
try:
|
||||||
|
_, source = resolve_token_with_source()
|
||||||
|
except (ValueError, OSError, UnicodeError):
|
||||||
|
emit('hold', 'missing/invalid_token')
|
||||||
|
return decisions
|
||||||
|
print('[live] token source=' + source, file=sys.stderr)
|
||||||
|
try:
|
||||||
|
cap = int(os.environ.get('LIVE_MAX_SUBMITS_PER_CYCLE', '3'))
|
||||||
|
if cap < 1:
|
||||||
|
raise ValueError
|
||||||
|
except ValueError:
|
||||||
|
emit('hold', 'invalid_cycle_cap')
|
||||||
|
return decisions
|
||||||
|
if candidates is None:
|
||||||
|
try:
|
||||||
|
if os.environ.get('PENDING_SUBMIT_MD') or os.environ.get('PENDING_SUBMIT_TXT'):
|
||||||
|
candidates = build_ordered_candidates()
|
||||||
|
else:
|
||||||
|
candidates = json.loads((Path(__file__).parent / 'data/candidates.json').read_text())
|
||||||
|
except (OSError, ValueError, IndexError):
|
||||||
|
emit('hold', 'candidate_read_failed')
|
||||||
|
return decisions
|
||||||
|
candidates = sorted(candidates, key=lambda c: (c.get('section', 'B') != 'A', c.get('foothold_class') != 'verified_reuse'))
|
||||||
|
if not candidates:
|
||||||
|
emit('hold', 'no_candidates')
|
||||||
|
return decisions
|
||||||
|
state = Path(os.environ.get('LIVE_STATE_FILE', '/tmp/live-submitted.json'))
|
||||||
|
try:
|
||||||
|
persisted = load_persisted(state) | _submitted
|
||||||
|
baked, blocked = load_baked()
|
||||||
|
known = persisted | baked
|
||||||
|
except (OSError, ValueError, KeyError, TypeError):
|
||||||
|
emit('hold', 'state_read_failed')
|
||||||
|
return decisions
|
||||||
|
reader = queue_reader or read_queue
|
||||||
|
client = client if client is not None else TaskAddClient()
|
||||||
|
attempts = 0
|
||||||
|
for candidate in candidates:
|
||||||
|
model = candidate.get('model_address') or candidate.get('modelAddress') or ''
|
||||||
|
rule = banned_rule(model + ' ' + str(candidate.get('model_name', candidate.get('modelName', ''))))
|
||||||
|
if rule:
|
||||||
|
emit('skip', 'banned:' + rule, model=model)
|
||||||
|
continue
|
||||||
|
if not model or '<' in model or '>' in model:
|
||||||
|
emit('skip', 'placeholder')
|
||||||
|
continue
|
||||||
|
key = model_key(model)
|
||||||
|
if key in blocked:
|
||||||
|
emit('skip', 'do-not-resubmit', model=model)
|
||||||
|
continue
|
||||||
|
if candidate.get('skip_reason'):
|
||||||
|
emit('skip', 'pending_filter', model=model)
|
||||||
|
continue
|
||||||
|
if attempts >= cap:
|
||||||
|
emit('hold', 'cycle_cap')
|
||||||
|
break
|
||||||
|
choices = candidate.get('try_next_gpus') or [candidate.get('targetGpu')]
|
||||||
|
gpu = next((g for g in choices if g in GPU_ENUMS and (key, GPU_ENUMS[g]) not in known), None)
|
||||||
|
if gpu is None:
|
||||||
|
emit('skip', 'all_gpus_done_or_unmapped', model=model)
|
||||||
|
continue
|
||||||
|
# Rebuild remote identities before config calls and every submission.
|
||||||
|
try:
|
||||||
|
count, why, remote = reader()
|
||||||
|
if type(count) is not int or count < 0:
|
||||||
|
raise ValueError
|
||||||
|
known.update(remote)
|
||||||
|
except Exception:
|
||||||
|
emit('hold', 'ledger_rebuild_failed')
|
||||||
|
break
|
||||||
|
gpu = next((g for g in choices if g in GPU_ENUMS and (key, GPU_ENUMS[g]) not in known), None)
|
||||||
|
if gpu is None:
|
||||||
|
emit('skip', 'all_gpus_done_or_unmapped', model=model)
|
||||||
|
continue
|
||||||
|
if count >= queue_threshold():
|
||||||
|
emit('hold', f'queue>={queue_threshold()}', count=count)
|
||||||
|
break
|
||||||
|
framework = candidate.get('framework') or 'vllm'
|
||||||
|
try:
|
||||||
|
yaml, meta = build_config_params_with_meta(gpu, framework)
|
||||||
|
except Exception:
|
||||||
|
emit('skip', 'config_failed', model=model)
|
||||||
|
continue
|
||||||
|
payload = build_payload(model, GPU_ENUMS[gpu], framework, os.environ['STRATEGY_ID'].strip(), yaml)
|
||||||
|
if any(not value or '<' in str(value) or '>' in str(value) for value in payload.values()):
|
||||||
|
emit('skip', 'placeholder', model=model)
|
||||||
|
continue
|
||||||
|
pair = (key, GPU_ENUMS[gpu])
|
||||||
|
known.add(pair)
|
||||||
|
persisted.add(pair)
|
||||||
|
_submitted.add(pair)
|
||||||
|
try:
|
||||||
|
persist(state, persisted)
|
||||||
|
except OSError:
|
||||||
|
emit('hold', 'state_write_failed')
|
||||||
|
break
|
||||||
|
attempts += 1
|
||||||
|
try:
|
||||||
|
result = client.submit(payload)
|
||||||
|
status, code = result.get('http_status'), result.get('platform_code')
|
||||||
|
status = status if type(status) is int else None
|
||||||
|
code = code if type(code) is int else None
|
||||||
|
reason = 'task_add_response' if status is not None else 'transport_error'
|
||||||
|
except Exception:
|
||||||
|
status, code, reason = None, None, 'submit_error'
|
||||||
|
emit('submitted', reason, model=model, gpu=GPU_ENUMS[gpu], queue_before=count,
|
||||||
|
http=status, code=code, **meta)
|
||||||
|
return decisions
|
||||||
68
src/live_queue.py
Normal file
68
src/live_queue.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Fresh authenticated queue snapshots. Never expose response bodies or credentials."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
from .task_add_client import resolve_token
|
||||||
|
|
||||||
|
TERMINAL = {'success', 'failed', 'fail', 'error', 'cancelled', 'canceled'}
|
||||||
|
|
||||||
|
|
||||||
|
def model_key(value):
|
||||||
|
value = str(value or '').strip().lower().rstrip('/')
|
||||||
|
for prefix in ('https://huggingface.co/', 'http://huggingface.co/', 'https://modelscope.cn/models/'):
|
||||||
|
if value.startswith(prefix):
|
||||||
|
value = value[len(prefix):]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def valid_total(value):
|
||||||
|
return (type(value) is int and value >= 0) or (isinstance(value, str) and value.isascii() and value.isdigit())
|
||||||
|
|
||||||
|
|
||||||
|
def read_queue():
|
||||||
|
"""Return (count, reason, model identities); count None means fail closed."""
|
||||||
|
try:
|
||||||
|
token = resolve_token()
|
||||||
|
except (ValueError, OSError, UnicodeError):
|
||||||
|
return None, 'missing/invalid_token', set()
|
||||||
|
count, models, seen, total = 0, set(), 0, None
|
||||||
|
for page in range(1, 21):
|
||||||
|
try:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
os.environ.get('MODELHUB_API_BASE', 'https://modelhub.org.cn').rstrip('/')
|
||||||
|
+ f'/api/adapt/task/page?onlyMine=true&pageSize=100¤t={page}',
|
||||||
|
headers={'Xc-Token': token, 'Accept': 'application/json'}, method='GET')
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as response:
|
||||||
|
if not 200 <= response.status < 300:
|
||||||
|
return None, 'http_error', set()
|
||||||
|
body = json.loads(response.read())
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
return None, 'malformed_json', set()
|
||||||
|
except OSError:
|
||||||
|
return None, 'transport/http_error', set()
|
||||||
|
if not isinstance(body, dict) or type(body.get('code')) not in (int, str) or str(body['code']) != '0':
|
||||||
|
return None, 'platform_error', set()
|
||||||
|
data = body.get('data')
|
||||||
|
if not isinstance(data, dict) or not valid_total(data.get('total')):
|
||||||
|
return None, 'malformed_data', set()
|
||||||
|
data['total'] = int(data['total'])
|
||||||
|
records = data.get('records')
|
||||||
|
if not isinstance(records, list) or any(not isinstance(r, dict) for r in records):
|
||||||
|
return None, 'malformed_records', set()
|
||||||
|
if total is not None and total != data['total']:
|
||||||
|
return None, 'pagination_total_changed', set()
|
||||||
|
total = data['total']
|
||||||
|
seen += len(records)
|
||||||
|
if seen > total:
|
||||||
|
return None, 'pagination_inconsistent', set()
|
||||||
|
for record in records:
|
||||||
|
count += str(record.get('status', '')).strip().lower() not in TERMINAL
|
||||||
|
model, gpu = model_key(record.get('modelId')), record.get('gpuType')
|
||||||
|
if model and isinstance(gpu, str) and gpu:
|
||||||
|
models.add((model, gpu))
|
||||||
|
if seen == total:
|
||||||
|
# Do not retain arbitrary response fields, which may echo credentials.
|
||||||
|
return count, 'ok', {m for m in models if all(token.lower() not in part.lower() for part in m)}
|
||||||
|
if not records:
|
||||||
|
break
|
||||||
|
return None, 'incomplete_pagination', set()
|
||||||
129
src/pending_pool.py
Normal file
129
src/pending_pool.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""Read-only pending pool. Name checks cannot establish metadata/uniqueness PASS."""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE = Path('/workspace/modelhub-adapt')
|
||||||
|
GPUS = r'曦望(?: S2)?|壁砺(?:166M)?|天垓150|天垓100|智铠(?: mrv-100)?|Iluvatar_mrv-100|Biren_166m|Sunrise_pt-200-x1'
|
||||||
|
FAMILIES = ('lfm2', 'olmo-2', 'olmo2', 'spark-x2.5', 'spark2_5', 'nemotron-h')
|
||||||
|
|
||||||
|
|
||||||
|
def input_path(env, filename):
|
||||||
|
return Path(os.environ.get(env) or BASE / filename)
|
||||||
|
|
||||||
|
|
||||||
|
def load_do_not_resubmit(path):
|
||||||
|
# Exact ids only; includes collision bullets and overnight tables.
|
||||||
|
return {s for s in re.findall(r'`([^`]+)`', Path(path).read_text())
|
||||||
|
if re.fullmatch(r'[\w.-]+/[\w.\-/]+', s)}
|
||||||
|
|
||||||
|
|
||||||
|
def load_banned_name_tokens(path):
|
||||||
|
text = Path(path).read_text().lower()
|
||||||
|
return {s for s in ('abliterated', 'heretic', 'uncensored', 'unsensored') if s in text}
|
||||||
|
|
||||||
|
|
||||||
|
def experience_order(gpus):
|
||||||
|
"""Stable tier sort of missing chips only; tier-one pending order wins.
|
||||||
|
|
||||||
|
docs/gpu-perf-order.md Section B is experience, never official performance.
|
||||||
|
Unknown names retain their relative order after known tiers; no chips added.
|
||||||
|
"""
|
||||||
|
def tier(gpu):
|
||||||
|
if any(name in gpu for name in ('壁砺', '曦望', 'Biren_166m', 'Sunrise_pt-200-x1')):
|
||||||
|
return 1
|
||||||
|
if '天垓150' in gpu:
|
||||||
|
return 2
|
||||||
|
if '天垓100' in gpu:
|
||||||
|
return 3
|
||||||
|
if '智铠' in gpu or 'mrv-100' in gpu:
|
||||||
|
return 4
|
||||||
|
return 5
|
||||||
|
return sorted(dict.fromkeys(gpus), key=tier)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_foothold(text):
|
||||||
|
"""Classify literal pending evidence; mixed markers prefer verified."""
|
||||||
|
for label, markers in (
|
||||||
|
('verified_reuse', ('已验证成功', '验证通过')),
|
||||||
|
('in_flight', ('正在验证', '验证排队中', '验证进行中')),
|
||||||
|
('failed_foothold', ('验证失败',)),
|
||||||
|
):
|
||||||
|
if any(marker in text for marker in markers):
|
||||||
|
return label
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
def has_no_download_marker(text):
|
||||||
|
"""True only for explicit 免下载/已下载; never inferred from verified alone."""
|
||||||
|
return any(marker in text for marker in ('免下载', '已下载'))
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate(model, section, gpu_text, notes, foothold_text=''):
|
||||||
|
evidence_text = foothold_text if section == 'A' else ''
|
||||||
|
foothold_class = classify_foothold(evidence_text) if section == 'A' else 'unknown'
|
||||||
|
no_download = has_no_download_marker(evidence_text + ' ' + notes)
|
||||||
|
evidence = 'pending-foothold' if (foothold_class != 'unknown' or no_download) else 'GAP'
|
||||||
|
return dict(model_address=model, section=section,
|
||||||
|
foothold_text=foothold_text, foothold_class=foothold_class,
|
||||||
|
evidence_source=evidence, no_download=no_download,
|
||||||
|
try_next_gpus=experience_order(re.findall(GPUS, gpu_text)),
|
||||||
|
perf_order="unset", order_source="experience",
|
||||||
|
notes=notes, comment=notes, skip_reason=None,
|
||||||
|
needs_human=False, status='candidate')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pending_md(path):
|
||||||
|
rows, section = [], None
|
||||||
|
for line in Path(path).read_text().splitlines():
|
||||||
|
heading = re.match(r'##\s+Section\s+([AB])\b', line)
|
||||||
|
if heading:
|
||||||
|
section = heading[1]
|
||||||
|
elif line.startswith('## '):
|
||||||
|
section = None
|
||||||
|
if section and re.match(r'\|\s*\d+\s*\|', line):
|
||||||
|
cols = [s.strip() for s in line.strip('|').split('|')]
|
||||||
|
model = re.search(r'`([^`]+)`', cols[1])
|
||||||
|
if model:
|
||||||
|
rows.append(_candidate(model[1], section, cols[3] if section == 'A' else cols[-1], cols[-1], cols[2] if section == 'A' else ''))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def parse_pending_txt(path):
|
||||||
|
rows = []
|
||||||
|
for line in Path(path).read_text().splitlines():
|
||||||
|
if not line.strip() or line.startswith('#'):
|
||||||
|
continue
|
||||||
|
parts = re.split(r'\t|\\t', line, maxsplit=1)
|
||||||
|
comment = parts[1] if len(parts) == 2 else ''
|
||||||
|
match = re.match(r'#([AB])', comment)
|
||||||
|
section = match[1] if match else 'B'
|
||||||
|
gpu_text = re.split(r'(?:still\s+)?try\s*:', comment, maxsplit=1)[-1] if 'try:' in comment else (comment if section == 'B' else '')
|
||||||
|
row = _candidate(parts[0].strip(), section, gpu_text, comment, comment if section == 'A' else '')
|
||||||
|
if not match:
|
||||||
|
row.update(needs_human=True, status='needs_human')
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def build_ordered_candidates(md_path=None, txt_path=None, dnr_path=None, banned_path=None):
|
||||||
|
md = Path(md_path) if md_path is not None else input_path('PENDING_SUBMIT_MD', 'pending-submit.md')
|
||||||
|
txt = Path(txt_path) if txt_path is not None else input_path('PENDING_SUBMIT_TXT', 'pending-submit.txt')
|
||||||
|
rows = parse_pending_md(md) if md.is_file() else parse_pending_txt(txt) if txt.is_file() else []
|
||||||
|
dnr = Path(dnr_path) if dnr_path is not None else input_path('DO_NOT_RESUBMIT_MD', 'do-not-resubmit.md')
|
||||||
|
banned = Path(banned_path) if banned_path is not None else input_path('BANNED_PATTERNS_MD', 'banned-patterns.md')
|
||||||
|
excluded = load_do_not_resubmit(dnr) if dnr.is_file() else set()
|
||||||
|
tokens = load_banned_name_tokens(banned) if banned.is_file() else set()
|
||||||
|
section = os.environ.get('PENDING_SECTION', '').strip().upper()
|
||||||
|
if section:
|
||||||
|
rows = [row for row in rows if row['section'] == section]
|
||||||
|
for row in rows:
|
||||||
|
name = row['model_address']
|
||||||
|
reason = ('do-not-resubmit' if name in excluded else
|
||||||
|
'hard-gate: banned name token' if any(t in name.lower() for t in tokens) else
|
||||||
|
'hard-gate: banned family' if any(t in name.lower() for t in FAMILIES) else None)
|
||||||
|
row['skip_reason'] = reason
|
||||||
|
# Metadata gates (weights, context, riskTag, uniqueness) remain unverified.
|
||||||
|
row['needs_human'] = not bool(reason)
|
||||||
|
row['status'] = 'skipped' if reason else 'needs_human'
|
||||||
|
return sorted(rows, key=lambda r: (r['section'] != 'A', not (r['foothold_class'] == 'verified_reuse' or r.get('no_download'))))
|
||||||
22
src/queue_depth.py
Normal file
22
src/queue_depth.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""Read queue hints without contacting the platform; unknown is conservative."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def queue_depth():
|
||||||
|
value = os.environ.get('ASYNC_QUEUE_DEPTH')
|
||||||
|
if value is None and os.environ.get('QUEUE_SNAPSHOT_JSON'):
|
||||||
|
try:
|
||||||
|
data = json.loads(Path(os.environ['QUEUE_SNAPSHOT_JSON']).read_text())
|
||||||
|
if isinstance(data, dict):
|
||||||
|
value = next((data[k] for k in ('depth', 'queue_depth', 'async_queue_depth', 'ASYNC_QUEUE_DEPTH') if k in data), None)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return None
|
||||||
|
depth = int(str(value))
|
||||||
|
return depth if depth >= 0 else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
44
src/readonly_probe.py
Normal file
44
src/readonly_probe.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Explicit read-only auth probe. Import performs no I/O; never calls task/add."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from src.task_add_client import resolve_token
|
||||||
|
|
||||||
|
|
||||||
|
def probe_readonly():
|
||||||
|
try:
|
||||||
|
token = resolve_token()
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return dict(http_status=None, platform_code=None, auth_ok=False,
|
||||||
|
record_summary={'count': 0}, error_attribution='missing/invalid credential')
|
||||||
|
request = urllib.request.Request(
|
||||||
|
os.environ.get('MODELHUB_API_BASE', 'https://modelhub.org.cn').rstrip('/')
|
||||||
|
+ '/api/adapt/task/page?onlyMine=true&pageSize=5¤t=1',
|
||||||
|
headers={'Xc-Token': token, 'Accept': 'application/json'}, method='GET')
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as response:
|
||||||
|
status, raw = response.status, response.read()
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
status, raw = error.code, error.read()
|
||||||
|
except (OSError, urllib.error.URLError, ValueError):
|
||||||
|
return dict(http_status=None, platform_code=None, auth_ok=False,
|
||||||
|
record_summary={'count': 0}, error_attribution='transport/timeout')
|
||||||
|
# Never expose response strings: servers can echo credentials in any field.
|
||||||
|
try:
|
||||||
|
body = json.loads(raw)
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
body = {}
|
||||||
|
body = body if isinstance(body, dict) else {}
|
||||||
|
code = body.get('code')
|
||||||
|
code = code if type(code) is int else int(code) if isinstance(code, str) and code in ('0', '40100', '40101') else None
|
||||||
|
ok = 200 <= status < 300 and code == 0
|
||||||
|
data = body.get('data')
|
||||||
|
records = data.get('records') if isinstance(data, dict) else None
|
||||||
|
summary = {'count': len(records) if isinstance(records, list) else 0}
|
||||||
|
if isinstance(data, dict) and type(data.get('total')) is int:
|
||||||
|
summary['total'] = data['total']
|
||||||
|
attribution = {40100: '40100 NOT_LOGIN_ERROR', 40101: '40101 NO_AUTH_ERROR'}.get(
|
||||||
|
code, 'success' if ok else 'other HTTP/platform/invalid response')
|
||||||
|
return dict(http_status=status, platform_code=code, auth_ok=ok,
|
||||||
|
record_summary=summary, error_attribution=attribution)
|
||||||
109
src/server.py
Normal file
109
src/server.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""Minimal HTTP server for the adaptation agent (stdlib only).
|
||||||
|
|
||||||
|
Listens on PORT (default 8080). Handles SIGTERM and exits the main loop
|
||||||
|
within ~1s (platform window documented as ≤30s).
|
||||||
|
|
||||||
|
Startup: require STRATEGY_ID (Phase1 product plan — fail if missing).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .health import handle_get
|
||||||
|
from .strategy import require_strategy_id
|
||||||
|
from .gate import live_enabled
|
||||||
|
from .live_cycle import run_cycle
|
||||||
|
|
||||||
|
_shutdown = threading.Event()
|
||||||
|
_httpd: Optional[ThreadingHTTPServer] = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentHandler(BaseHTTPRequestHandler):
|
||||||
|
protocol_version = "HTTP/1.1"
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args) -> None: # noqa: A003
|
||||||
|
# Avoid noisy default logging; never log env secrets.
|
||||||
|
sys.stderr.write("[http] request handled (details omitted)\n")
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
code, headers, body = handle_get(self.path)
|
||||||
|
self.send_response(code)
|
||||||
|
for k, v in headers.items():
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
body = b'{"error":"method_not_allowed"}'
|
||||||
|
self.send_response(405)
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
def _on_sigterm(signum: int, frame) -> None: # noqa: ARG001
|
||||||
|
sys.stderr.write(f"Received signal {signum}; shutting down...\n")
|
||||||
|
_shutdown.set()
|
||||||
|
httpd = _httpd
|
||||||
|
if httpd is not None:
|
||||||
|
# wake serve_forever
|
||||||
|
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _live_worker():
|
||||||
|
from pathlib import Path
|
||||||
|
from .ledger import rebuild
|
||||||
|
try:
|
||||||
|
_, _, _, sizes = rebuild(Path(os.environ.get('LIVE_STATE_FILE', '/tmp/live-submitted.json')))
|
||||||
|
sys.stderr.write('[live] ledger rebuilt remote=%d baked=%d persisted=%d\n' % sizes)
|
||||||
|
except Exception:
|
||||||
|
sys.stderr.write('[live] hold reason=ledger_rebuild_failed\n')
|
||||||
|
try:
|
||||||
|
interval = max(60, int(os.environ.get('LIVE_CYCLE_SECONDS', '600')))
|
||||||
|
except ValueError:
|
||||||
|
interval = 600
|
||||||
|
while not _shutdown.is_set():
|
||||||
|
try:
|
||||||
|
run_cycle()
|
||||||
|
except Exception:
|
||||||
|
sys.stderr.write('[live] hold reason=cycle_error\n')
|
||||||
|
if _shutdown.wait(interval):
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
global _httpd
|
||||||
|
|
||||||
|
require_strategy_id() # exit 1 if missing
|
||||||
|
|
||||||
|
port = int(os.environ.get("PORT", "8080"))
|
||||||
|
host = os.environ.get("HOST", "0.0.0.0")
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, _on_sigterm)
|
||||||
|
signal.signal(signal.SIGINT, _on_sigterm)
|
||||||
|
|
||||||
|
httpd = ThreadingHTTPServer((host, port), AgentHandler)
|
||||||
|
httpd.daemon_threads = True
|
||||||
|
_httpd = httpd
|
||||||
|
|
||||||
|
if live_enabled():
|
||||||
|
threading.Thread(target=_live_worker, daemon=True).start()
|
||||||
|
|
||||||
|
sys.stderr.write(f"Listening on {host}:{port} (GET /health, GET /status)\n")
|
||||||
|
try:
|
||||||
|
httpd.serve_forever(poll_interval=0.5)
|
||||||
|
finally:
|
||||||
|
httpd.server_close()
|
||||||
|
sys.stderr.write("Server stopped.\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
70
src/strategy.py
Normal file
70
src/strategy.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""STRATEGY_ID and external-token presence helpers.
|
||||||
|
|
||||||
|
Phase1 (product plan): missing STRATEGY_ID → process must fail to start
|
||||||
|
(exit non-zero). Values are never logged or returned in responses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Community alias order (level C); we only check presence, never echo values.
|
||||||
|
_TOKEN_ENV_KEYS = (
|
||||||
|
"EXTERNAL_SERVICE_TOKEN",
|
||||||
|
"XC_TOKEN_FILE",
|
||||||
|
"XC_TOKEN",
|
||||||
|
"MODELHUB_XC_TOKEN",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_strategy_id() -> Optional[str]:
|
||||||
|
"""Return STRATEGY_ID if set and non-empty after strip; else None."""
|
||||||
|
raw = os.environ.get("STRATEGY_ID")
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
value = raw.strip()
|
||||||
|
return value if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def require_strategy_id() -> str:
|
||||||
|
"""Return STRATEGY_ID or exit the process with a non-zero status.
|
||||||
|
|
||||||
|
Does not print the value. Safe for startup path.
|
||||||
|
"""
|
||||||
|
sid = get_strategy_id()
|
||||||
|
if sid is None:
|
||||||
|
print(
|
||||||
|
"ERROR: STRATEGY_ID is required and must be non-empty. "
|
||||||
|
"Refusing to start (Phase1 contract).",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
def strategy_id_present() -> bool:
|
||||||
|
return get_strategy_id() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _token_from_file(path: str) -> bool:
|
||||||
|
"""True if path exists and has non-empty content (value never read into logs)."""
|
||||||
|
try:
|
||||||
|
if not path or not os.path.isfile(path):
|
||||||
|
return False
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
content = fh.read().strip()
|
||||||
|
return bool(content)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def external_service_token_present() -> bool:
|
||||||
|
"""Whether any known token env / file alias is present (boolean only)."""
|
||||||
|
from .task_add_client import resolve_token_with_source
|
||||||
|
try:
|
||||||
|
resolve_token_with_source()
|
||||||
|
return True
|
||||||
|
except (ValueError, OSError, UnicodeError):
|
||||||
|
return False
|
||||||
67
src/task_add_client.py
Normal file
67
src/task_add_client.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Explicit task/add client; import never sends requests. No retries.
|
||||||
|
|
||||||
|
Community Xc-Token authentication is C; platform code 60014 is D (unverified).
|
||||||
|
GPU enums/framework/configParams require human verification. Draft placeholders
|
||||||
|
must be replaced before a real call. Use through the dual gate with injection.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def build_payload(model_address, target_gpu='<TARGET_GPU>', framework='<FRAMEWORK>',
|
||||||
|
strategy_id=None, config_params='<CONFIG_PARAMS>'):
|
||||||
|
return dict(modelAddress=model_address, taskType='text-generation', targetGpu=target_gpu,
|
||||||
|
framework=framework, strategyId=strategy_id or os.environ.get('STRATEGY_ID') or '<STRATEGY_ID>',
|
||||||
|
configParams=config_params)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_token_with_source():
|
||||||
|
for name in ('EXTERNAL_SERVICE_TOKEN', 'XC_TOKEN_FILE', 'XC_TOKEN', 'MODELHUB_XC_TOKEN'):
|
||||||
|
raw = os.environ.get(name, '').strip()
|
||||||
|
if name == 'XC_TOKEN_FILE' and raw:
|
||||||
|
try:
|
||||||
|
raw = Path(raw).read_text().strip()
|
||||||
|
except (OSError, UnicodeError):
|
||||||
|
continue
|
||||||
|
if raw and '<' not in raw and '>' not in raw and not re.search(
|
||||||
|
r'(?i)(?:^|[^a-z0-9])(placeholder|changeme|tmp|xxx)(?:$|[^a-z0-9])', raw):
|
||||||
|
return raw, name
|
||||||
|
raise ValueError('Missing or invalid service credential')
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_token():
|
||||||
|
return resolve_token_with_source()[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TaskAddClient:
|
||||||
|
def submit(self, payload):
|
||||||
|
if not os.environ.get('STRATEGY_ID', '').strip():
|
||||||
|
raise ValueError('STRATEGY_ID required')
|
||||||
|
required = ('modelAddress', 'taskType', 'targetGpu', 'framework', 'strategyId', 'configParams')
|
||||||
|
if any(k not in payload or not payload[k] or '<' in str(payload[k]) for k in required):
|
||||||
|
raise ValueError('Replace all required payload placeholders')
|
||||||
|
if payload['strategyId'] != os.environ['STRATEGY_ID'].strip():
|
||||||
|
raise ValueError('strategyId must match STRATEGY_ID')
|
||||||
|
token = resolve_token()
|
||||||
|
request = urllib.request.Request(os.environ.get('MODELHUB_API_BASE', 'https://www.modelhub.org.cn').rstrip('/') + '/api/adapt/task/add',
|
||||||
|
data=json.dumps(payload).encode(), headers={'Xc-Token': token, 'Content-Type': 'application/json'}, method='POST')
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as response:
|
||||||
|
status, raw = response.status, response.read()
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
status, raw = error.code, error.read()
|
||||||
|
except (OSError, urllib.error.URLError):
|
||||||
|
return dict(http_status=None, body=None, platform_code=None, error_attribution='transport/timeout')
|
||||||
|
# A server may echo credentials; redact before parsing or returning anything.
|
||||||
|
text = raw.decode('utf-8', errors='replace').replace(token, '[REDACTED]')
|
||||||
|
try:
|
||||||
|
body = json.loads(text)
|
||||||
|
except ValueError:
|
||||||
|
body = text
|
||||||
|
code = body.get('code') if isinstance(body, dict) else None
|
||||||
|
attribution = '60014: D (unverified)' if str(code) == '60014' else 'HTTP error' if status >= 400 else 'platform response (unverified)'
|
||||||
|
return dict(http_status=status, body=body, platform_code=code, error_attribution=attribution)
|
||||||
44
templates/config_params.sample.yaml
Normal file
44
templates/config_params.sample.yaml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
framework: vllm
|
||||||
|
api: completion
|
||||||
|
lang: en
|
||||||
|
max_model_len: 4096
|
||||||
|
max_tokens: 1024
|
||||||
|
temperature: 0.7
|
||||||
|
repetition_penalty: 1.1
|
||||||
|
top_p: 0.9
|
||||||
|
sut_config:
|
||||||
|
gpu_num: 1
|
||||||
|
values:
|
||||||
|
command:
|
||||||
|
- /opt/conda/bin/vllm
|
||||||
|
- serve
|
||||||
|
- /model
|
||||||
|
- --port
|
||||||
|
- '20644'
|
||||||
|
- --served-model-name
|
||||||
|
- llm
|
||||||
|
- --max-model-len
|
||||||
|
- '4096'
|
||||||
|
- --gpu-memory-utilization
|
||||||
|
- '0.9'
|
||||||
|
- -tp
|
||||||
|
- '1'
|
||||||
|
- --enforce-eager
|
||||||
|
- --trust-remote-code
|
||||||
|
ref_config:
|
||||||
|
gpu_num: 1
|
||||||
|
values:
|
||||||
|
command:
|
||||||
|
- vllm
|
||||||
|
- serve
|
||||||
|
- /model
|
||||||
|
- --port
|
||||||
|
- '80'
|
||||||
|
- --served-model-name
|
||||||
|
- llm
|
||||||
|
- --max-model-len
|
||||||
|
- '4096'
|
||||||
|
- -tp
|
||||||
|
- '1'
|
||||||
|
- --enforce-eager
|
||||||
|
- --trust-remote-code
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
73
tests/test_build_config_client.py
Normal file
73
tests/test_build_config_client.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import runpy
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
from src.build_config_client import build_config, BuildConfigError
|
||||||
|
from scripts.build_config_dry import main
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildConfigClient(unittest.TestCase):
|
||||||
|
def response(self, body, status=200):
|
||||||
|
response = MagicMock()
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
response.status = status
|
||||||
|
response.read.return_value = json.dumps(body).encode()
|
||||||
|
return response
|
||||||
|
|
||||||
|
def test_success_request_and_auth(self):
|
||||||
|
for authorization in ('0', '1'):
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': 'test-secret', 'BUILD_CONFIG_USE_AUTHORIZATION': authorization}, clear=True), patch('urllib.request.urlopen', return_value=self.response({'code': 0, 'data': 'framework: vllm\n'})) as call:
|
||||||
|
self.assertEqual(build_config('Sunrise_pt-200-x1'), 'framework: vllm\n')
|
||||||
|
request = call.call_args.args[0]
|
||||||
|
self.assertEqual(request.get_method(), 'POST')
|
||||||
|
self.assertIsNone(request.data)
|
||||||
|
parsed = urlparse(request.full_url)
|
||||||
|
self.assertEqual(parsed.path, '/api/adapt/task/build-config')
|
||||||
|
self.assertNotIn('/task/add', request.full_url)
|
||||||
|
self.assertEqual(parse_qs(parsed.query), {'gpuType': ['Sunrise_pt-200-x1'], 'framework': ['vllm'], 'taskType': ['text-generation']})
|
||||||
|
self.assertEqual(request.get_header('Authorization' if authorization == '1' else 'Xc-token'), 'test-secret')
|
||||||
|
self.assertIsNone(request.get_header('Xc-token' if authorization == '1' else 'Authorization'))
|
||||||
|
|
||||||
|
def test_missing_invalid_no_network(self):
|
||||||
|
for env in ({}, {'XC_TOKEN': 'placeholder'}, {'XC_TOKEN_FILE': '/nonexistent/token'}):
|
||||||
|
with patch.dict(os.environ, env, clear=True), patch('urllib.request.urlopen') as call:
|
||||||
|
with self.assertRaisesRegex(BuildConfigError, 'missing/invalid credential'):
|
||||||
|
build_config('gpu')
|
||||||
|
with patch('sys.argv', ['build_config_dry']), patch('sys.stderr', io.StringIO()):
|
||||||
|
self.assertEqual(main(), 1)
|
||||||
|
call.assert_not_called()
|
||||||
|
|
||||||
|
def test_platform_and_data_errors(self):
|
||||||
|
for body, reason in [({'code': 40100}, '40100'), ({'code': 40101}, '40101'), ({'code': 9}, 'other HTTP/platform'), ({'code': 0, 'data': ''}, 'empty/non-str'), ({'code': 0, 'data': []}, 'empty/non-str'), ({'code': 0, 'data': 'test-secret'}, 'credential echoed')]:
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': 'test-secret'}, clear=True), patch('urllib.request.urlopen', return_value=self.response(body)):
|
||||||
|
with self.assertRaisesRegex(BuildConfigError, reason):
|
||||||
|
build_config('gpu')
|
||||||
|
|
||||||
|
def test_http_transport_invalid_response(self):
|
||||||
|
for code in (40100, 40101):
|
||||||
|
error = urllib.error.HTTPError('https://example.test', 403, '', {}, io.BytesIO(json.dumps({'code': code}).encode()))
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': 'test-secret'}, clear=True), patch('urllib.request.urlopen', side_effect=error):
|
||||||
|
with self.assertRaisesRegex(BuildConfigError, str(code)):
|
||||||
|
build_config('gpu')
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': 'test-secret'}, clear=True):
|
||||||
|
for error in (TimeoutError('secret'), urllib.error.URLError('secret')):
|
||||||
|
with patch('urllib.request.urlopen', side_effect=error), self.assertRaisesRegex(BuildConfigError, 'transport/timeout'):
|
||||||
|
build_config('gpu')
|
||||||
|
for response in (self.response([], 200), self.response({'code': 0, 'data': 'yaml'}, 500)):
|
||||||
|
with patch('urllib.request.urlopen', return_value=response), self.assertRaises(BuildConfigError):
|
||||||
|
build_config('gpu')
|
||||||
|
response = self.response({})
|
||||||
|
response.read.return_value = b'not-json'
|
||||||
|
with patch('urllib.request.urlopen', return_value=response), self.assertRaisesRegex(BuildConfigError, 'invalid JSON'):
|
||||||
|
build_config('gpu')
|
||||||
|
|
||||||
|
def test_import_no_io(self):
|
||||||
|
import src.build_config_client
|
||||||
|
with patch('urllib.request.urlopen') as call, patch('pathlib.Path.read_text') as read:
|
||||||
|
runpy.run_path('src/build_config_client.py')
|
||||||
|
call.assert_not_called()
|
||||||
|
read.assert_not_called()
|
||||||
83
tests/test_config_params.py
Normal file
83
tests/test_config_params.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
from pathlib import Path
|
||||||
|
from src.config_params import build_config_params, config_params_source, build_config_params_with_meta, GAP
|
||||||
|
from src.build_config_client import BuildConfigError
|
||||||
|
from scripts.dry_run import _render
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigParams(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
env = patch.dict(os.environ, {}, clear=True)
|
||||||
|
env.start()
|
||||||
|
self.addCleanup(env.stop)
|
||||||
|
|
||||||
|
def test_source_selection_and_failure(self):
|
||||||
|
# No token → sample, no network
|
||||||
|
with patch('src.config_params.resolve_token', side_effect=ValueError), patch('src.config_params.build_config') as client:
|
||||||
|
self.assertEqual(config_params_source(), 'sample')
|
||||||
|
yaml, meta = build_config_params_with_meta('壁砺')
|
||||||
|
self.assertEqual(meta['auth'], 'missing')
|
||||||
|
self.assertEqual(meta['configParams_source'], 'sample')
|
||||||
|
client.assert_not_called()
|
||||||
|
# Token alone → prefer build-config (kickoff default)
|
||||||
|
for gpu, enum in [('壁砺', 'Biren_166m'), ('壁砺166M', 'Biren_166m'),
|
||||||
|
('Biren_166m', 'Biren_166m'), ('曦望', 'Sunrise_pt-200-x1'),
|
||||||
|
('曦望 S2', 'Sunrise_pt-200-x1'), ('Sunrise_pt-200-x1', 'Sunrise_pt-200-x1')]:
|
||||||
|
with self.subTest(gpu=gpu), patch('src.config_params.resolve_token', return_value='synthetic'), patch('src.config_params.build_config', return_value='api: yaml') as client:
|
||||||
|
self.assertEqual(config_params_source(), 'build-config')
|
||||||
|
yaml, meta = build_config_params_with_meta(gpu)
|
||||||
|
self.assertEqual(yaml, 'api: yaml')
|
||||||
|
self.assertEqual(meta['configParams_source'], 'build-config')
|
||||||
|
self.assertEqual(meta['auth'], 'ok')
|
||||||
|
client.assert_called_once_with(enum, 'vllm', 'text-generation')
|
||||||
|
# Failure → sample + GAP reason
|
||||||
|
with patch('src.config_params.resolve_token', return_value='synthetic'), patch('src.config_params.build_config', side_effect=BuildConfigError('transport/timeout')):
|
||||||
|
yaml, meta = build_config_params_with_meta('壁砺')
|
||||||
|
self.assertEqual(meta['configParams_source'], 'sample')
|
||||||
|
self.assertEqual(meta['configParams_fallback_reason'], 'GAP: transport/timeout')
|
||||||
|
self.assertIn('max_model_len: 4096', yaml)
|
||||||
|
# Forced build-config also soft-falls back in with_meta
|
||||||
|
with patch.dict(os.environ, {'CONFIG_PARAMS_SOURCE': 'build-config'}), patch('src.config_params.resolve_token', return_value='synthetic'), patch('src.config_params.build_config', side_effect=BuildConfigError('transport/timeout')):
|
||||||
|
yaml, meta = build_config_params_with_meta('壁砺')
|
||||||
|
self.assertEqual(meta['configParams_fallback_reason'], 'GAP: transport/timeout')
|
||||||
|
# Unmapped GPU → no API call
|
||||||
|
for gpu in ('MetaX', 'MetaX_c-500', '<GPU>', None):
|
||||||
|
with patch('src.config_params.resolve_token', return_value='synthetic'), patch('src.config_params.build_config') as client:
|
||||||
|
_, meta = build_config_params_with_meta(gpu)
|
||||||
|
self.assertIn('GAP: gpu enum unmapped', meta['configParams_fallback_reason'])
|
||||||
|
client.assert_not_called()
|
||||||
|
# Force sample even with token
|
||||||
|
with patch.dict(os.environ, {'CONFIG_PARAMS_SOURCE': 'sample'}), patch('src.config_params.resolve_token', return_value='synthetic'), patch('src.config_params.build_config') as client:
|
||||||
|
self.assertEqual(config_params_source(), 'sample')
|
||||||
|
build_config_params('壁砺')
|
||||||
|
client.assert_not_called()
|
||||||
|
|
||||||
|
def test_report_api_failure_metadata(self):
|
||||||
|
with patch('src.config_params.resolve_token', return_value='synthetic'), patch('src.config_params.build_config', side_effect=BuildConfigError('transport/timeout')):
|
||||||
|
report = _render([{'model_address': 'org/a', 'try_next_gpus': ['壁砺'],
|
||||||
|
'foothold_class': 'verified_reuse', 'evidence_source': 'pending-foothold',
|
||||||
|
'no_download': False}], 'test', 'fixture')
|
||||||
|
for value in ('foothold_class=verified_reuse', 'evidence_source=pending-foothold', 'no_download=False',
|
||||||
|
'configParams_source=sample', 'configParams_fallback_reason=GAP: transport/timeout'):
|
||||||
|
self.assertIn(value, report)
|
||||||
|
|
||||||
|
def test_official_sample_exact(self):
|
||||||
|
scrape = Path('/workspace/modelhub-adapt/logs/api-docs-scrape-2026-10-16.md')
|
||||||
|
if scrape.is_file():
|
||||||
|
body = json.loads(re.search(r'```json\n(.*?)\n```', scrape.read_text(), re.S)[1])
|
||||||
|
with patch('src.config_params.resolve_token', side_effect=ValueError):
|
||||||
|
self.assertEqual(build_config_params(), body['configParams'])
|
||||||
|
with patch('src.config_params.resolve_token', side_effect=ValueError):
|
||||||
|
self.assertIn('max_model_len: 4096', build_config_params())
|
||||||
|
self.assertIn('framework: vllm', build_config_params())
|
||||||
|
self.assertIn('NOT production-ready', GAP)
|
||||||
|
|
||||||
|
def test_report_draft_and_gap(self):
|
||||||
|
with patch('src.config_params.resolve_token', side_effect=ValueError):
|
||||||
|
report = _render([{'model_address': 'org/a', 'try_next_gpus': ['壁砺']}], 'test', 'fixture')
|
||||||
|
for value in ('代提候选 / 需 Victor 确认', 'configParams source', 'configParams_source=sample', 'foothold_class=unknown', 'evidence_source=GAP', 'no_download=False', 'GAP:', 'truncated', 'max_model_len: 4096', 'perf_order=unset', 'order_source=experience'):
|
||||||
|
self.assertIn(value, report)
|
||||||
44
tests/test_gate.py
Normal file
44
tests/test_gate.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Default live approval, kill switch, strategy and conservative hint gates."""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
from src.gate import evaluate_gate, attempt_live_submit, LIVE_CONFIRM_VALUE
|
||||||
|
|
||||||
|
|
||||||
|
class TestGate(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
env = patch.dict(os.environ, {'STRATEGY_ID': 'sid'}, clear=True)
|
||||||
|
env.start()
|
||||||
|
self.addCleanup(env.stop)
|
||||||
|
|
||||||
|
def test_default_live_and_optional_confirmation(self):
|
||||||
|
self.assertTrue(evaluate_gate().allowed)
|
||||||
|
for value in ('', 'YES', LIVE_CONFIRM_VALUE):
|
||||||
|
with patch.dict(os.environ, {'LIVE_CONFIRM': value}):
|
||||||
|
self.assertTrue(evaluate_gate().allowed)
|
||||||
|
|
||||||
|
def test_kill_switch_and_missing_strategy(self):
|
||||||
|
for env in ({'ALLOW_LIVE_SUBMIT': '0'}, {'ALLOW_LIVE_SUBMIT': 'true'}, {'STRATEGY_ID': ' '}):
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
self.assertFalse(evaluate_gate().allowed)
|
||||||
|
|
||||||
|
def test_hints_and_threshold(self):
|
||||||
|
for value in ('95', 'invalid', '-1'):
|
||||||
|
with patch.dict(os.environ, {'ASYNC_QUEUE_DEPTH': value}):
|
||||||
|
self.assertFalse(evaluate_gate().allowed)
|
||||||
|
with patch.dict(os.environ, {'ASYNC_QUEUE_DEPTH': '94'}):
|
||||||
|
self.assertTrue(evaluate_gate().allowed)
|
||||||
|
with patch.dict(os.environ, {'LIVE_QUEUE_THRESHOLD': 'bad'}):
|
||||||
|
self.assertFalse(evaluate_gate().allowed)
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
p = Path(d) / 'hint.json'
|
||||||
|
p.write_text('{"depth":95}')
|
||||||
|
with patch.dict(os.environ, {'QUEUE_SNAPSHOT_JSON': str(p), 'ASYNC_QUEUE_DEPTH': '1'}):
|
||||||
|
self.assertFalse(evaluate_gate().allowed)
|
||||||
|
|
||||||
|
def test_no_client_no_network(self):
|
||||||
|
with patch('urllib.request.urlopen') as network:
|
||||||
|
self.assertFalse(attempt_live_submit({})['submitted'])
|
||||||
|
network.assert_not_called()
|
||||||
60
tests/test_health.py
Normal file
60
tests/test_health.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Tests for /health and /status handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from src.health import handle_get, health_payload, status_payload
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealth(unittest.TestCase):
|
||||||
|
def test_health_payload(self) -> None:
|
||||||
|
self.assertEqual(health_payload(), {"status": "ok"})
|
||||||
|
|
||||||
|
def test_handle_get_health(self) -> None:
|
||||||
|
code, headers, body = handle_get("/health")
|
||||||
|
self.assertEqual(code, 200)
|
||||||
|
self.assertIn("application/json", headers["Content-Type"])
|
||||||
|
data = json.loads(body.decode("utf-8"))
|
||||||
|
self.assertEqual(data, {"status": "ok"})
|
||||||
|
|
||||||
|
def test_handle_get_unknown(self) -> None:
|
||||||
|
code, _, body = handle_get("/nope")
|
||||||
|
self.assertEqual(code, 404)
|
||||||
|
self.assertIn("not_found", body.decode("utf-8"))
|
||||||
|
|
||||||
|
def test_status_booleans_only_no_secret_values(self) -> None:
|
||||||
|
with mock.patch.dict(
|
||||||
|
os.environ,
|
||||||
|
{
|
||||||
|
"STRATEGY_ID": "secret-strategy-id-xyz",
|
||||||
|
"EXTERNAL_SERVICE_TOKEN": "secret-token-abc",
|
||||||
|
},
|
||||||
|
clear=False,
|
||||||
|
):
|
||||||
|
payload = status_payload()
|
||||||
|
self.assertTrue(payload["strategy_id_present"])
|
||||||
|
self.assertTrue(payload["external_service_token_present"])
|
||||||
|
blob = json.dumps(payload)
|
||||||
|
self.assertNotIn("secret-strategy-id-xyz", blob)
|
||||||
|
self.assertNotIn("secret-token-abc", blob)
|
||||||
|
|
||||||
|
def test_status_absent(self) -> None:
|
||||||
|
env = {k: v for k, v in os.environ.items() if k not in (
|
||||||
|
"STRATEGY_ID",
|
||||||
|
"EXTERNAL_SERVICE_TOKEN",
|
||||||
|
"XC_TOKEN",
|
||||||
|
"XC_TOKEN_FILE",
|
||||||
|
"MODELHUB_XC_TOKEN",
|
||||||
|
)}
|
||||||
|
with mock.patch.dict(os.environ, env, clear=True):
|
||||||
|
payload = status_payload()
|
||||||
|
self.assertFalse(payload["strategy_id_present"])
|
||||||
|
self.assertFalse(payload["external_service_token_present"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
110
tests/test_live_cycle.py
Normal file
110
tests/test_live_cycle.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
from src.live_cycle import run_cycle, _submitted
|
||||||
|
|
||||||
|
|
||||||
|
def candidate(name, section='B', foothold='unknown'):
|
||||||
|
return dict(model_address='org/' + name, section=section, foothold_class=foothold, try_next_gpus=['壁砺'])
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveCycle(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(directory.cleanup)
|
||||||
|
self.env = patch.dict(os.environ, {'STRATEGY_ID': 'sid', 'LIVE_STATE_FILE': directory.name + '/state.json', 'CONFIG_PARAMS_SOURCE': 'sample', 'XC_TOKEN': 'synthetic-secret'}, clear=True)
|
||||||
|
self.env.start()
|
||||||
|
self.addCleanup(self.env.stop)
|
||||||
|
_submitted.clear()
|
||||||
|
self.client = Mock()
|
||||||
|
self.client.submit.return_value = dict(http_status=200, platform_code=0)
|
||||||
|
network = patch('urllib.request.urlopen', side_effect=AssertionError('network forbidden'))
|
||||||
|
network.start()
|
||||||
|
self.addCleanup(network.stop)
|
||||||
|
logs = patch('sys.stderr', new_callable=io.StringIO)
|
||||||
|
self.logs = logs.start()
|
||||||
|
self.addCleanup(logs.stop)
|
||||||
|
|
||||||
|
def cycle(self, rows=None, counts=(94,)):
|
||||||
|
reader = Mock(side_effect=[(n, 'platform_error' if n is None else 'ok', set()) for n in counts])
|
||||||
|
return run_cycle(rows or [candidate('a')], self.client, reader)
|
||||||
|
|
||||||
|
def test_high_and_failed_queue(self):
|
||||||
|
for n in (95, None):
|
||||||
|
decisions = self.cycle(counts=(n,))
|
||||||
|
self.assertEqual(decisions[-1]['action'], 'hold')
|
||||||
|
self.client.submit.assert_not_called()
|
||||||
|
self.assertIn('[live] hold', self.logs.getvalue())
|
||||||
|
|
||||||
|
def test_order_and_cap(self):
|
||||||
|
rows = [candidate('b'), candidate('a', 'A'), candidate('verified', 'A', 'verified_reuse'), candidate('c'), candidate('d')]
|
||||||
|
self.cycle(rows, (90, 90, 90))
|
||||||
|
self.assertEqual([c.args[0]['modelAddress'] for c in self.client.submit.call_args_list], ['org/verified', 'org/a', 'org/b'])
|
||||||
|
self.assertIn('cycle_cap', self.logs.getvalue())
|
||||||
|
|
||||||
|
def test_midcycle(self):
|
||||||
|
self.cycle([candidate(str(i)) for i in range(5)], (93, 94, 95))
|
||||||
|
self.assertEqual(self.client.submit.call_count, 2)
|
||||||
|
|
||||||
|
def test_kill_and_threshold(self):
|
||||||
|
for env in ({'ALLOW_LIVE_SUBMIT': '0'}, {'LIVE_QUEUE_THRESHOLD': 'bad'}, {'LIVE_QUEUE_THRESHOLD': '20'}):
|
||||||
|
with patch.dict(os.environ, env):
|
||||||
|
self.cycle(counts=(20,))
|
||||||
|
self.client.submit.assert_not_called()
|
||||||
|
with patch.dict(os.environ, {'LIVE_QUEUE_THRESHOLD': '100'}):
|
||||||
|
self.cycle(counts=(99,))
|
||||||
|
self.client.submit.assert_called_once()
|
||||||
|
|
||||||
|
def test_banned_duplicate_persistence(self):
|
||||||
|
rows = [candidate('GPT-2'), candidate('ok'), candidate('ok')]
|
||||||
|
self.cycle(rows)
|
||||||
|
_submitted.clear()
|
||||||
|
self.cycle([candidate('ok')], ())
|
||||||
|
self.client.submit.assert_called_once()
|
||||||
|
self.assertIn('banned:', self.logs.getvalue())
|
||||||
|
self.assertIn('all_gpus_done_or_unmapped', self.logs.getvalue())
|
||||||
|
|
||||||
|
def test_remote_duplicate_and_transport_reservation(self):
|
||||||
|
run_cycle([candidate('remote')], self.client, lambda: (1, 'ok', {('org/remote', 'Biren_166m')}))
|
||||||
|
self.client.submit.assert_not_called()
|
||||||
|
self.client.submit.side_effect = OSError('do not log')
|
||||||
|
self.cycle()
|
||||||
|
self.cycle(counts=())
|
||||||
|
self.client.submit.assert_called_once()
|
||||||
|
self.assertNotIn('do not log', self.logs.getvalue())
|
||||||
|
|
||||||
|
def test_missing_token_real_reader(self):
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': ''}):
|
||||||
|
run_cycle([candidate('a')], self.client)
|
||||||
|
self.client.submit.assert_not_called()
|
||||||
|
self.assertIn('missing/invalid_token', self.logs.getvalue())
|
||||||
|
|
||||||
|
def test_worker_waits_and_stops(self):
|
||||||
|
from src.server import _live_worker
|
||||||
|
with patch('src.server._shutdown') as shutdown, patch('src.server.run_cycle') as cycle:
|
||||||
|
shutdown.is_set.return_value = False
|
||||||
|
shutdown.wait.return_value = True
|
||||||
|
with patch.dict(os.environ, {'LIVE_CYCLE_SECONDS': '1'}):
|
||||||
|
_live_worker()
|
||||||
|
cycle.assert_called_once()
|
||||||
|
shutdown.wait.assert_called_once_with(60)
|
||||||
|
|
||||||
|
def test_unauthenticated_platform_zero_submits(self):
|
||||||
|
import json
|
||||||
|
response = Mock()
|
||||||
|
response.__enter__ = Mock(return_value=response)
|
||||||
|
response.__exit__ = Mock(return_value=False)
|
||||||
|
response.status = 200
|
||||||
|
response.read.return_value = json.dumps({'code': 40100}).encode()
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': 'synthetic-secret'}), patch('urllib.request.urlopen', return_value=response):
|
||||||
|
run_cycle([candidate('a')], self.client)
|
||||||
|
self.client.submit.assert_not_called()
|
||||||
|
|
||||||
|
def test_unmapped_and_placeholder(self):
|
||||||
|
rows = [dict(model_address='org/unmapped', targetGpu='unknown'), candidate('<MODEL>')]
|
||||||
|
self.cycle(rows, ())
|
||||||
|
self.client.submit.assert_not_called()
|
||||||
|
self.assertIn('all_gpus_done_or_unmapped', self.logs.getvalue())
|
||||||
|
self.assertIn('placeholder', self.logs.getvalue())
|
||||||
71
tests/test_live_ledger.py
Normal file
71
tests/test_live_ledger.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
from src.live_cycle import run_cycle, _submitted
|
||||||
|
from src.ledger import load_baked, rebuild
|
||||||
|
from src.banned import banned_rule
|
||||||
|
from src.task_add_client import resolve_token_with_source
|
||||||
|
|
||||||
|
|
||||||
|
class TestLedger(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(temp.cleanup)
|
||||||
|
self.state = Path(temp.name) / 'state.json'
|
||||||
|
env = patch.dict(os.environ, dict(STRATEGY_ID='sid', XC_TOKEN='synthetic-credential', CONFIG_PARAMS_SOURCE='sample', LIVE_STATE_FILE=str(self.state)), clear=True)
|
||||||
|
env.start()
|
||||||
|
self.addCleanup(env.stop)
|
||||||
|
_submitted.clear()
|
||||||
|
self.addCleanup(_submitted.clear)
|
||||||
|
net = patch('urllib.request.urlopen', side_effect=AssertionError('network forbidden'))
|
||||||
|
net.start()
|
||||||
|
self.addCleanup(net.stop)
|
||||||
|
self.client = Mock()
|
||||||
|
self.client.submit.return_value = dict(http_status=200, platform_code=0)
|
||||||
|
|
||||||
|
def cycle(self, remote=(), model='unit/model', gpus=None):
|
||||||
|
return run_cycle([dict(model_address=model, try_next_gpus=gpus or ['壁砺', '天垓100'])], self.client, lambda: (0, 'ok', set(remote)))
|
||||||
|
|
||||||
|
def test_pair_and_restart(self):
|
||||||
|
self.cycle([('unit/model', 'Biren_166m')])
|
||||||
|
self.assertEqual(self.client.submit.call_args.args[0]['targetGpu'], 'Iluvatar_bi-100')
|
||||||
|
self.assertEqual(json.loads(self.state.read_text()), ['unit/model|Iluvatar_bi-100'])
|
||||||
|
_submitted.clear()
|
||||||
|
self.cycle([('unit/model', 'Biren_166m')])
|
||||||
|
self.client.submit.assert_called_once()
|
||||||
|
|
||||||
|
def test_baked_and_blocked(self):
|
||||||
|
pairs, blocked = load_baked()
|
||||||
|
model, gpu = next(iter(pairs))
|
||||||
|
self.cycle(model=model, gpus=[gpu])
|
||||||
|
self.cycle(model=next(iter(blocked)))
|
||||||
|
self.client.submit.assert_not_called()
|
||||||
|
|
||||||
|
def test_failure_and_missing_token(self):
|
||||||
|
result = run_cycle([dict(model_address='unit/model', try_next_gpus=['壁砺'])], self.client, lambda: (None, 'failed', set()))
|
||||||
|
self.assertEqual(result[-1]['reason'], 'ledger_rebuild_failed')
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': ''}):
|
||||||
|
self.cycle()
|
||||||
|
self.client.submit.assert_not_called()
|
||||||
|
|
||||||
|
def test_source_and_no_value_logs(self):
|
||||||
|
import io
|
||||||
|
with patch.dict(os.environ, {'EXTERNAL_SERVICE_TOKEN': 'platform-credential', 'XC_TOKEN_FILE': '/nonexistent'}), patch('sys.stderr', new_callable=io.StringIO) as log:
|
||||||
|
self.assertEqual(resolve_token_with_source(), ('platform-credential', 'EXTERNAL_SERVICE_TOKEN'))
|
||||||
|
self.cycle()
|
||||||
|
self.assertEqual(log.getvalue().count('token source=EXTERNAL_SERVICE_TOKEN'), 1)
|
||||||
|
self.assertNotIn('platform-credential', log.getvalue())
|
||||||
|
with patch.dict(os.environ, {'EXTERNAL_SERVICE_TOKEN': '<TOKEN>', 'XC_TOKEN': 'placeholder', 'MODELHUB_XC_TOKEN': 'alias-credential'}):
|
||||||
|
self.assertEqual(resolve_token_with_source()[1], 'MODELHUB_XC_TOKEN')
|
||||||
|
|
||||||
|
def test_candidates_and_rebuild(self):
|
||||||
|
rows = json.loads(Path('src/data/candidates.json').read_text())
|
||||||
|
self.assertGreater(len(rows), 0)
|
||||||
|
self.assertEqual([r['section'] for r in rows], sorted(r['section'] for r in rows))
|
||||||
|
self.assertFalse(any(banned_rule(r['model_address']) for r in rows))
|
||||||
|
count, pairs, blocked, sizes = rebuild(self.state, lambda: (0, 'ok', {('unit/model', 'Biren_166m')}))
|
||||||
|
self.assertIn(('unit/model', 'Biren_166m'), pairs)
|
||||||
|
self.assertEqual(sizes[0], 1)
|
||||||
55
tests/test_live_queue.py
Normal file
55
tests/test_live_queue.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from src.live_queue import read_queue
|
||||||
|
|
||||||
|
|
||||||
|
class TestLiveQueue(unittest.TestCase):
|
||||||
|
def response(self, body):
|
||||||
|
r = MagicMock()
|
||||||
|
r.__enter__.return_value = r
|
||||||
|
r.status = 200
|
||||||
|
r.read.return_value = json.dumps(body).encode()
|
||||||
|
return r
|
||||||
|
|
||||||
|
def test_pagination_and_status(self):
|
||||||
|
pages = [self.response({'code': 0, 'data': {'total': '3', 'records': [
|
||||||
|
{'status': 'waiting', 'modelId': 'org/a', 'gpuType': 'Biren_166m'}, {'status': 'success', 'verifyResult': -1}]}}),
|
||||||
|
self.response({'code': 0, 'data': {'total': '3', 'records': [{'status': '等待中'}]}})]
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': 'synthetic-secret'}, clear=True), patch('urllib.request.urlopen', side_effect=pages) as call:
|
||||||
|
count, why, models = read_queue()
|
||||||
|
self.assertEqual(count, 2)
|
||||||
|
self.assertIn(('org/a', 'Biren_166m'), models)
|
||||||
|
self.assertIn('current=2', call.call_args.args[0].full_url)
|
||||||
|
|
||||||
|
def test_fail_closed(self):
|
||||||
|
for body in ({'code': 0, 'data': {'total': 'bad', 'records': []}}, {'code': 40100}, {'code': 40101}, {'code': 0, 'data': {'total': 1, 'records': []}}, []):
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': 'synthetic-secret'}, clear=True), patch('urllib.request.urlopen', return_value=self.response(body)):
|
||||||
|
self.assertIsNone(read_queue()[0])
|
||||||
|
with patch.dict(os.environ, {}, clear=True), patch('urllib.request.urlopen') as call:
|
||||||
|
self.assertIsNone(read_queue()[0])
|
||||||
|
call.assert_not_called()
|
||||||
|
|
||||||
|
def test_page_limit_and_transport(self):
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': 'synthetic-secret'}, clear=True):
|
||||||
|
with patch('urllib.request.urlopen', return_value=self.response({'code': 0, 'data': {'total': 21, 'records': [{'status': 'waiting'}]}})) as call:
|
||||||
|
self.assertIsNone(read_queue()[0])
|
||||||
|
self.assertEqual(call.call_count, 20)
|
||||||
|
with patch('urllib.request.urlopen', side_effect=OSError('synthetic-secret')):
|
||||||
|
self.assertNotIn('synthetic-secret', str(read_queue()))
|
||||||
|
|
||||||
|
def test_invalid_json_http_and_auth_header(self):
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': 'synthetic-secret'}, clear=True):
|
||||||
|
r = self.response({})
|
||||||
|
r.read.return_value = b'invalid synthetic-secret'
|
||||||
|
with patch('urllib.request.urlopen', return_value=r):
|
||||||
|
self.assertEqual(read_queue()[1], 'malformed_json')
|
||||||
|
r.status = 503
|
||||||
|
with patch('urllib.request.urlopen', return_value=r):
|
||||||
|
self.assertIsNone(read_queue()[0])
|
||||||
|
with patch('urllib.request.urlopen', return_value=self.response({'code': 0, 'data': {'total': 0, 'records': []}})) as call:
|
||||||
|
self.assertEqual(read_queue()[0], 0)
|
||||||
|
req = call.call_args.args[0]
|
||||||
|
self.assertEqual(req.get_header('Xc-token'), 'synthetic-secret')
|
||||||
|
self.assertEqual(req.method, 'GET')
|
||||||
86
tests/test_pending_pool.py
Normal file
86
tests/test_pending_pool.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from src.pending_pool import build_ordered_candidates, parse_pending_txt
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingPool(unittest.TestCase):
|
||||||
|
def test_order_filters_and_first_gpu(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
md, txt, dnr, banned = [root / n for n in ('pending.md', 'pending.txt', 'dnr.md', 'banned.md')]
|
||||||
|
md.write_text('''## Section B
|
||||||
|
| 1 | `org/cold` | 50 | | |
|
||||||
|
## Section A
|
||||||
|
| 1 | `org/good` | 曦望 | 壁砺, 天垓150 | — |
|
||||||
|
| 2 | `org/exact` | 曦望 | 壁砺 | — |
|
||||||
|
| 3 | `org/Heretic-model` | 曦望 | 壁砺 | — |
|
||||||
|
| 4 | `org/OLMo-2-model` | 曦望 | 壁砺 | — |
|
||||||
|
''')
|
||||||
|
dnr.write_text('| 1 | `org/exact` | fail |\n## Uniqueness collisions\n- `org/collision`\n## Overnight\n- `org/overnight`')
|
||||||
|
banned.write_text('`abliterated` `heretic` `uncensored` `unsensored`')
|
||||||
|
rows = build_ordered_candidates(md, txt, dnr, banned)
|
||||||
|
self.assertEqual([r['section'] for r in rows], ['A'] * 4 + ['B'])
|
||||||
|
self.assertEqual(rows[0]['try_next_gpus'][0], '壁砺')
|
||||||
|
self.assertEqual(rows[1]['skip_reason'], 'do-not-resubmit')
|
||||||
|
self.assertEqual(rows[2]['status'], 'skipped')
|
||||||
|
self.assertIn('family', rows[3]['skip_reason'])
|
||||||
|
self.assertTrue(rows[-1]['needs_human'])
|
||||||
|
txt.write_text('org/b\t#B cold\norg/a\t#A done:曦望; still try:壁砺+天垓150\norg/c\\t#A try:天垓150\n')
|
||||||
|
self.assertEqual(parse_pending_txt(txt)[1]['try_next_gpus'], ['壁砺', '天垓150'])
|
||||||
|
fallback = build_ordered_candidates(root/'absent', txt, dnr, banned)
|
||||||
|
self.assertEqual([r['model_address'] for r in fallback], ['org/a', 'org/c', 'org/b'])
|
||||||
|
self.assertEqual(len(build_ordered_candidates(md, txt, dnr, banned)), 5)
|
||||||
|
|
||||||
|
def test_experience_order_missing_only_and_ties(self):
|
||||||
|
from src.pending_pool import experience_order, parse_pending_md
|
||||||
|
self.assertEqual(experience_order(['智铠 mrv-100', '天垓100', '曦望', '天垓150', '壁砺']),
|
||||||
|
['曦望', '壁砺', '天垓150', '天垓100', '智铠 mrv-100'])
|
||||||
|
self.assertEqual(experience_order(['天垓150']), ['天垓150'])
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / 'pending.md'
|
||||||
|
path.write_text('## Section A\n| 1 | `org/a` | 壁砺:验证失败; 曦望:正在验证 | 天垓150 | no 壁砺 retry |\n## Section B\n| 1 | `org/b` | 0 | 壁砺 |\n')
|
||||||
|
from unittest.mock import patch
|
||||||
|
import os
|
||||||
|
with patch.dict(os.environ, {'PENDING_SECTION': 'A'}):
|
||||||
|
rows = build_ordered_candidates(path, dnr_path=Path(directory)/'none', banned_path=Path(directory)/'none')
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0]['try_next_gpus'], ['天垓150'])
|
||||||
|
self.assertEqual(rows[0]['perf_order'], 'unset')
|
||||||
|
|
||||||
|
def test_foothold_evidence_and_stable_verified_priority(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
md = root / 'pending.md'
|
||||||
|
md.write_text('## Section B\n| 1 | `org/b` | 0 | 壁砺 |\n## Section A\n| 1 | `org/unknown` | 曦望 | 壁砺 | 已验证成功 |\n| 2 | `org/flight` | 验证失败; 正在验证 | 壁砺 | — |\n| 3 | `org/verified` | 他人:已验证成功; 正在验证 | 壁砺 | — |\n| 4 | `org/fail` | 验证失败 | 曦望 | — |\n| 5 | `org/pass` | 既有:验证通过 | 曦望 | — |\n| 6 | `org/queue` | 验证排队中 | 壁砺 | — |\n| 7 | `org/progress` | 验证进行中 | 壁砺 | — |\n')
|
||||||
|
rows = build_ordered_candidates(md, dnr_path=root/'none', banned_path=root/'none')
|
||||||
|
self.assertEqual([r['model_address'] for r in rows],
|
||||||
|
['org/verified', 'org/pass', 'org/unknown', 'org/flight',
|
||||||
|
'org/fail', 'org/queue', 'org/progress', 'org/b'])
|
||||||
|
self.assertEqual([r['foothold_class'] for r in rows],
|
||||||
|
['verified_reuse', 'verified_reuse', 'unknown', 'in_flight',
|
||||||
|
'failed_foothold', 'in_flight', 'in_flight', 'unknown'])
|
||||||
|
for row in rows:
|
||||||
|
self.assertEqual(row['evidence_source'], 'GAP' if row['foothold_class'] == 'unknown' else 'pending-foothold')
|
||||||
|
self.assertEqual(rows[0]['foothold_text'], '他人:已验证成功; 正在验证')
|
||||||
|
txt = root / 'pending.txt'
|
||||||
|
txt.write_text('org/a\t#A done:曦望; try:壁砺\norg/v\t#A 验证通过; try:壁砺\n')
|
||||||
|
parsed = parse_pending_txt(txt)
|
||||||
|
self.assertEqual(parsed[0]['foothold_class'], 'unknown')
|
||||||
|
self.assertEqual(parsed[0]['evidence_source'], 'GAP')
|
||||||
|
self.assertEqual(parsed[1]['foothold_class'], 'verified_reuse')
|
||||||
|
|
||||||
|
def test_no_download_marker_without_inventing(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
md = root / 'pending.md'
|
||||||
|
md.write_text('## Section A\n| 1 | `org/plain` | 曦望:验证排队中 | 壁砺 | — |\n| 2 | `org/cached` | 壁砺:已下载; 曦望:免下载 | 天垓150 | — |\n| 3 | `org/verified` | 壁砺:已验证成功 | 天垓150 | — |\n')
|
||||||
|
rows = build_ordered_candidates(md, dnr_path=root/'none', banned_path=root/'none')
|
||||||
|
by = {r['model_address']: r for r in rows}
|
||||||
|
self.assertFalse(by['org/plain']['no_download'])
|
||||||
|
self.assertEqual(by['org/plain']['evidence_source'], 'pending-foothold')
|
||||||
|
self.assertTrue(by['org/cached']['no_download'])
|
||||||
|
self.assertEqual(by['org/cached']['evidence_source'], 'pending-foothold')
|
||||||
|
self.assertFalse(by['org/verified']['no_download']) # verified alone does not invent no_download
|
||||||
|
self.assertEqual([r['model_address'] for r in rows],
|
||||||
|
['org/cached', 'org/verified', 'org/plain'])
|
||||||
61
tests/test_readonly_probe.py
Normal file
61
tests/test_readonly_probe.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from src.readonly_probe import probe_readonly
|
||||||
|
from scripts.probe_readonly import main
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadonlyProbe(unittest.TestCase):
|
||||||
|
def test_missing_token_no_network(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=True), patch('urllib.request.urlopen') as call:
|
||||||
|
self.assertFalse(probe_readonly()['auth_ok'])
|
||||||
|
with patch('sys.stdout', io.StringIO()):
|
||||||
|
self.assertEqual(main(), 1)
|
||||||
|
call.assert_not_called()
|
||||||
|
|
||||||
|
def test_success_and_exact_request(self):
|
||||||
|
response = MagicMock()
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
response.status = 200
|
||||||
|
response.read.return_value = json.dumps({'code': 0, 'data': {
|
||||||
|
'records': [{'modelId': 'secret-value'}], 'total': 10}}).encode()
|
||||||
|
with patch.dict(os.environ, {'EXTERNAL_SERVICE_TOKEN': 'secret-value'}, clear=True), patch('urllib.request.urlopen', return_value=response) as call:
|
||||||
|
result = probe_readonly()
|
||||||
|
request = call.call_args.args[0]
|
||||||
|
self.assertEqual(request.get_method(), 'GET')
|
||||||
|
self.assertEqual(request.full_url, 'https://modelhub.org.cn/api/adapt/task/page?onlyMine=true&pageSize=5¤t=1')
|
||||||
|
self.assertEqual(request.get_header('Xc-token'), 'secret-value')
|
||||||
|
self.assertTrue(result['auth_ok'])
|
||||||
|
self.assertEqual(result['record_summary'], {'count': 1, 'total': 10})
|
||||||
|
self.assertNotIn('secret-value', json.dumps(result))
|
||||||
|
|
||||||
|
def test_errors_no_fallback_or_echo(self):
|
||||||
|
for code in (40100, 40101, 50000):
|
||||||
|
error = urllib.error.HTTPError('https://example.test', 403, '', {}, io.BytesIO(json.dumps({'code': code, 'message': 'secret-value'}).encode()))
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': 'secret-value'}, clear=True), patch('urllib.request.urlopen', side_effect=error) as call:
|
||||||
|
result = probe_readonly()
|
||||||
|
self.assertEqual(call.call_count, 1)
|
||||||
|
self.assertFalse(result['auth_ok'])
|
||||||
|
self.assertEqual(result['platform_code'], code)
|
||||||
|
self.assertIn(str(code) if code != 50000 else 'other', result['error_attribution'])
|
||||||
|
self.assertNotIn('secret-value', json.dumps(result))
|
||||||
|
|
||||||
|
def test_transport_and_invalid_json(self):
|
||||||
|
for error in (OSError('secret-value'), urllib.error.URLError('secret-value')):
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': 'secret-value'}, clear=True), patch('urllib.request.urlopen', side_effect=error):
|
||||||
|
self.assertEqual(probe_readonly()['error_attribution'], 'transport/timeout')
|
||||||
|
response = MagicMock()
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
response.status, response.read.return_value = 200, b'invalid secret-value'
|
||||||
|
with patch.dict(os.environ, {'XC_TOKEN': 'secret-value'}, clear=True), patch('urllib.request.urlopen', return_value=response):
|
||||||
|
self.assertFalse(probe_readonly()['auth_ok'])
|
||||||
|
|
||||||
|
def test_import_no_network(self):
|
||||||
|
import importlib
|
||||||
|
import src.readonly_probe
|
||||||
|
with patch('urllib.request.urlopen') as call:
|
||||||
|
importlib.reload(src.readonly_probe)
|
||||||
|
call.assert_not_called()
|
||||||
46
tests/test_report_safety.py
Normal file
46
tests/test_report_safety.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"""Local report and HTTP log regression checks; no network requests."""
|
||||||
|
import contextlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from scripts.dry_run import _render
|
||||||
|
from src.server import AgentHandler
|
||||||
|
|
||||||
|
|
||||||
|
class TestReportSafety(unittest.TestCase):
|
||||||
|
def test_report_marks_pending_and_does_not_echo_gate_values(self):
|
||||||
|
sentinel = "synthetic-sensitive-value"
|
||||||
|
with mock.patch.dict(os.environ, {
|
||||||
|
"ALLOW_LIVE_SUBMIT": sentinel,
|
||||||
|
"LIVE_CONFIRM": sentinel,
|
||||||
|
"STRATEGY_ID": sentinel,
|
||||||
|
"EXTERNAL_SERVICE_TOKEN": sentinel,
|
||||||
|
}, clear=True):
|
||||||
|
report = _render([], "test-time", Path("example.json"))
|
||||||
|
self.assertNotIn(sentinel, report)
|
||||||
|
for required in ("A→B", "需 Victor 人工提交", "积分:待平台核实", "≥95"):
|
||||||
|
self.assertIn(required, report)
|
||||||
|
|
||||||
|
def test_pending_draft_queue_banner_and_skips(self):
|
||||||
|
rows = [dict(model_address='org/a', section='A', try_next_gpus=['壁砺', '天垓150'],
|
||||||
|
status='skipped', skip_reason='do-not-resubmit', needs_human=False),
|
||||||
|
dict(model_address='org/b', section='B', try_next_gpus=[],
|
||||||
|
status='needs_human', needs_human=True)]
|
||||||
|
with mock.patch.dict(os.environ, {'ASYNC_QUEUE_DEPTH': '95'}, clear=True):
|
||||||
|
report = _render(rows, 'test', Path('pending.md'))
|
||||||
|
self.assertIn('禁 live / 队列≥95', report)
|
||||||
|
self.assertIn('do-not-resubmit', report)
|
||||||
|
self.assertIn('needs_human', report)
|
||||||
|
self.assertIn('"targetGpu": "壁砺"', report)
|
||||||
|
self.assertNotIn('"targetGpu": "天垓150"', report)
|
||||||
|
self.assertLess(report.index('Section A'), report.index('Section B'))
|
||||||
|
|
||||||
|
def test_http_log_omits_request_details(self):
|
||||||
|
output = io.StringIO()
|
||||||
|
with contextlib.redirect_stderr(output):
|
||||||
|
AgentHandler.log_message(None, '"%s" %s', '/health?token=synthetic-sensitive-value', 200)
|
||||||
|
self.assertNotIn("synthetic-sensitive-value", output.getvalue())
|
||||||
|
self.assertIn("request handled", output.getvalue())
|
||||||
69
tests/test_sigterm.py
Normal file
69
tests/test_sigterm.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""SIGTERM should stop the server subprocess promptly (<< 30s platform window)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSigterm(unittest.TestCase):
|
||||||
|
def test_sigterm_exits_quickly(self) -> None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["STRATEGY_ID"] = "sigterm-test"
|
||||||
|
env["ALLOW_LIVE_SUBMIT"] = "0"
|
||||||
|
env["HOST"] = "127.0.0.1"
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, '-c', """
|
||||||
|
from unittest.mock import patch
|
||||||
|
import src.server as server
|
||||||
|
class OfflineServer:
|
||||||
|
def __init__(self, *args): pass
|
||||||
|
def serve_forever(self, **kwargs): server._shutdown.wait(10)
|
||||||
|
def shutdown(self): pass
|
||||||
|
def server_close(self): pass
|
||||||
|
with patch.object(server, 'ThreadingHTTPServer', OfflineServer):
|
||||||
|
server.main()
|
||||||
|
"""],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
# Read readiness from stderr; no sockets or network calls.
|
||||||
|
import selectors
|
||||||
|
selector = selectors.DefaultSelector()
|
||||||
|
self.addCleanup(selector.close)
|
||||||
|
selector.register(proc.stderr, selectors.EVENT_READ)
|
||||||
|
self.assertTrue(selector.select(timeout=5), 'server readiness timeout')
|
||||||
|
self.assertIn(b'Listening on', proc.stderr.readline())
|
||||||
|
|
||||||
|
t0 = time.time()
|
||||||
|
proc.send_signal(signal.SIGTERM)
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
self.fail("server did not exit within 5s after SIGTERM (platform allows ≤30s)")
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
self.assertLess(elapsed, 5.0, f"exit took {elapsed:.2f}s")
|
||||||
|
self.assertEqual(proc.returncode, 0, "SIGTERM must exit cleanly, not kill the process")
|
||||||
|
finally:
|
||||||
|
if proc.poll() is None:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait(timeout=2)
|
||||||
|
if proc.stderr:
|
||||||
|
proc.stderr.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
57
tests/test_strategy_startup.py
Normal file
57
tests/test_strategy_startup.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Missing STRATEGY_ID must cause startup failure (exit non-zero)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
class TestStrategyStartup(unittest.TestCase):
|
||||||
|
def test_missing_strategy_id_exits_nonzero(self) -> None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["STRATEGY_ID"] = ""
|
||||||
|
env["PORT"] = "19081"
|
||||||
|
env["HOST"] = "127.0.0.1"
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, "-m", "src.server"],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(proc.returncode, 0)
|
||||||
|
self.assertIn("STRATEGY_ID", proc.stderr)
|
||||||
|
|
||||||
|
def test_whitespace_strategy_id_exits_nonzero(self) -> None:
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["STRATEGY_ID"] = " "
|
||||||
|
env["PORT"] = "19082"
|
||||||
|
env["HOST"] = "127.0.0.1"
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, "-m", "src.server"],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
self.assertNotEqual(proc.returncode, 0)
|
||||||
|
|
||||||
|
def test_require_strategy_id_helper(self) -> None:
|
||||||
|
from src.strategy import require_strategy_id
|
||||||
|
|
||||||
|
with self.assertRaises(SystemExit) as ctx:
|
||||||
|
with mock.patch.dict(os.environ, {"STRATEGY_ID": ""}, clear=False):
|
||||||
|
require_strategy_id()
|
||||||
|
self.assertNotEqual(ctx.exception.code, 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
69
tests/test_task_add_client.py
Normal file
69
tests/test_task_add_client.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import importlib
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import urllib.error
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
from src import task_add_client as module
|
||||||
|
|
||||||
|
|
||||||
|
class TestTaskAddClient(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
patch = mock.patch.dict(os.environ, {'STRATEGY_ID': 'sid', 'XC_TOKEN': 'test-secret-credential'}, clear=True)
|
||||||
|
patch.start()
|
||||||
|
self.addCleanup(patch.stop)
|
||||||
|
self.payload = module.build_payload('org/model', '壁砺', 'vllm', config_params={ 'max_model_len': 4096 })
|
||||||
|
|
||||||
|
def test_import_no_network(self):
|
||||||
|
with mock.patch('urllib.request.urlopen') as urlopen:
|
||||||
|
importlib.reload(module)
|
||||||
|
urlopen.assert_not_called()
|
||||||
|
|
||||||
|
def test_success(self):
|
||||||
|
response = mock.MagicMock()
|
||||||
|
response.__enter__.return_value = response
|
||||||
|
response.status = 200
|
||||||
|
response.read.return_value = b'{"code":0,"message":"test-secret-credential"}'
|
||||||
|
with mock.patch('urllib.request.urlopen', return_value=response) as urlopen:
|
||||||
|
result = module.TaskAddClient().submit(self.payload)
|
||||||
|
self.assertEqual(result['http_status'], 200)
|
||||||
|
self.assertNotIn(os.environ['XC_TOKEN'], str(result))
|
||||||
|
request = urlopen.call_args.args[0]
|
||||||
|
self.assertEqual(request.get_header('Xc-token'), os.environ['XC_TOKEN'])
|
||||||
|
self.assertEqual(request.method, 'POST')
|
||||||
|
urlopen.assert_called_once()
|
||||||
|
|
||||||
|
def test_http_4xx_60014(self):
|
||||||
|
error = urllib.error.HTTPError('https://example.invalid', 403, 'forbidden', {}, io.BytesIO(b'{"code":60014}'))
|
||||||
|
with mock.patch('urllib.request.urlopen', side_effect=error) as urlopen:
|
||||||
|
result = module.TaskAddClient().submit(self.payload)
|
||||||
|
self.assertEqual(result['http_status'], 403)
|
||||||
|
self.assertEqual(result['platform_code'], 60014)
|
||||||
|
self.assertIn('D (unverified)', result['error_attribution'])
|
||||||
|
urlopen.assert_called_once()
|
||||||
|
|
||||||
|
def test_transport_no_retry(self):
|
||||||
|
with mock.patch('urllib.request.urlopen', side_effect=TimeoutError('test-secret-credential')) as urlopen:
|
||||||
|
result = module.TaskAddClient().submit(self.payload)
|
||||||
|
self.assertNotIn(os.environ['XC_TOKEN'], str(result))
|
||||||
|
self.assertEqual(result['error_attribution'], 'transport/timeout')
|
||||||
|
urlopen.assert_called_once()
|
||||||
|
|
||||||
|
def test_token_precedence_and_rejections(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory)/'credential'
|
||||||
|
path.write_text(' file-credential \n')
|
||||||
|
with mock.patch.dict(os.environ, {'XC_TOKEN_FILE': str(path)}):
|
||||||
|
self.assertEqual(module.resolve_token(), 'file-credential')
|
||||||
|
for token in ('', 'tmp', 'PLACEHOLDER', 'changeme', 'tmp-token'):
|
||||||
|
with self.subTest(token=token), mock.patch.dict(os.environ, {'XC_TOKEN': token}):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
module.resolve_token()
|
||||||
|
|
||||||
|
def test_placeholder_never_requests(self):
|
||||||
|
with mock.patch('urllib.request.urlopen') as urlopen:
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
module.TaskAddClient().submit(module.build_payload('org/model'))
|
||||||
|
urlopen.assert_not_called()
|
||||||
Reference in New Issue
Block a user