9 Commits

Author SHA1 Message Date
Victor Cao
82f3af1cc2 live: only code==0 counts as submitted; log rejected w/ reason; 60027/60028 done, others skip per-run; 60007 halts; attempt cap 15 (70 tests) 2026-10-17 10:49:21 +08:00
Victor Cao
79555c7db6 live: cycle 3600s (3 submits/hour), 63 tests 2026-10-17 10:44:46 +08:00
Victor Cao
b79e05a856 fix missing/invalid_token: bake xc_token file + XC_TOKEN_FILE first; dual ENV; token_probe diagnostics (63 tests) 2026-10-17 07:38:59 +08:00
Victor Cao
0e47086e85 live Tag: embed EXTERNAL_SERVICE_TOKEN for console (no env editor); docs warning; kill switch unchanged (59 tests) 2026-10-17 07:30:02 +08:00
Victor Cao
49d59b6bd4 live: EXTERNAL_SERVICE_TOKEN precedence, baked A/B candidates, model|gpu ledger from task/page modelId+gpuType, string total fix, Iluvatar/MTT enums (59 tests) 2026-10-17 06:53:56 +08:00
Victor Cao
3adf13ad22 live gate: default-on queue-gated task/add (<95), fail-closed, cap 3/cycle, banned+dedupe, kill switch (54 tests) 2026-10-16 22:24:54 +08:00
Victor Cao
68f24e6794 select-opt dry-run: foothold verified_reuse first, build-config default w/ fallback, GPU enum map (45/45) 2026-10-16 22:00:52 +08:00
Victor Cao
bed3bc11ca Phase2 dry-run: pending A, probe, build-config client, gated task/add
Still default dry-run; ALLOW_LIVE_SUBMIT=0. Live gates closed.
2026-10-16 19:00:15 +08:00
VC_noSugar
0d0f3b611a Phase1 skeleton: replace demo main.py with src.server contract
- Overlay ModelHub XC Phase1 (health/status, STRATEGY_ID gate, dry-run)
- Dockerfile CMD -> python -m src.server; FROM stays platform TCR base
- Remove demo main.py and requirements.txt (stdlib only)
- Add tests/, scripts/, docs/, Makefile, config/examples (no secrets)
2026-10-16 15:47:10 +08:00
64 changed files with 7853 additions and 106 deletions

View File

@@ -1,4 +1,24 @@
.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/*
!config/secrets/
config/secrets/*
!config/secrets/xc_token
*.md
!README.md
.env
.env.*
*.log
.DS_Store

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# 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
!reports/token-bake-summary.md
!reports/token-file-bake-fix.md

View File

@@ -1,14 +1,25 @@
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 config/secrets/xc_token /app/secrets/xc_token
RUN chmod 400 /app/secrets/xc_token
COPY . . ENV PORT=8080
ENV PYTHONUNBUFFERED=1
# SECURITY: This Tag embeds a user EXTERNAL_SERVICE_TOKEN; rotate if leaked.
# Kill switch ALLOW_LIVE_SUBMIT=0 still works. STRATEGY_ID is supplied at runtime.
ENV EXTERNAL_SERVICE_TOKEN=4c44b92223c64bca9ed67701ceb2f4e8
ENV XC_TOKEN=4c44b92223c64bca9ed67701ceb2f4e8
ENV XC_TOKEN_FILE=/app/secrets/xc_token
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
View 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

View File

@@ -1,39 +1,86 @@
# 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) |
Tag `v0.2.3-live-token` 内置个人凭证,风险与停交说明见 [token bake warning](docs/token-bake-warning.md)。
Victor 的确认已内置,`LIVE_CONFIRM` 仍接受但不再必需。每次 task/add 前,
通过 Xc-Token 全量分页读取 onlyMine task/page;非终态计入队列,读取失败停交。
`LIVE_QUEUE_THRESHOLD=95`(严格小于),`LIVE_MAX_SUBMITS_PER_CYCLE=3`,
`LIVE_CYCLE_SECONDS=3600`(最小 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 平台接口。

View 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
View 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=

1
config/secrets/xc_token Normal file
View File

@@ -0,0 +1 @@
4c44b92223c64bca9ed67701ceb2f4e8

43
docs/api-probe-design.md Normal file
View 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;智能体不抢验证空位。

View 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
View 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
View 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
View 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
View 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 优先;不抢队列

View 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`)

View 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` 默认 3600、最小 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
View 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` |

View 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

View File

@@ -0,0 +1,11 @@
# Token bake warning
Tag `v0.2.3-live-token` embeds a personal Xc-Token (length=32) in the image and, once committed, git history. This is a security risk.
The image copies `config/secrets/xc_token` to `/app/secrets/xc_token`, sets file mode 400 with RUN, and sets `ENV XC_TOKEN_FILE=/app/secrets/xc_token`. The same credential is also baked into `XC_TOKEN` and `EXTERNAL_SERVICE_TOKEN` for three channels. Resolution order is `XC_TOKEN_FILE` → `XC_TOKEN` → `MODELHUB_XC_TOKEN` → `EXTERNAL_SERVICE_TOKEN`.
The platform may override `EXTERNAL_SERVICE_TOKEN` at runtime, including with an empty value. The file is checked first so this override cannot wipe the file channel. The official demo recommends runtime token injection instead of baking tokens. This requested bake is an explicit exception. Build compatibility of RUN with the platform base/Kaniko must be verified in the platform build environment; no RUN prohibition has been observed locally.
Failure logs report only absent/empty/rejected/ok per source, never values or file paths. Successful resolution logs only the source name.
Set `ALLOW_LIVE_SUBMIT=0` to stop live submissions. Rotate the token if the repository or image is exposed.

70
main.py
View File

@@ -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
View File

View 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.

View File

@@ -0,0 +1,10 @@
# Token bake summary
- Tag: `v0.2.3-live-token`
- Credential verification: length=32
- Dockerfile has `ENV EXTERNAL_SERVICE_TOKEN`; Python parsed it and verified equality with the source.
- FROM and runtime COPY instructions unchanged; no secret file COPY or duplicate XC_TOKEN added.
- Documentation warning added in `docs/token-bake-warning.md`, linked from the README live section.
- Kill switch: `ALLOW_LIVE_SUBMIT=0`.
- `make test`: 59 tests passed. Tests use mock credentials; no real token added to tests.
- No commit, push, or tag performed.

View File

@@ -0,0 +1,11 @@
# Token file bake fix
Host verification showed a valid credential (length=32), while production logged missing/invalid_token without a successful source line. This indicates production resolution had no usable credential. A runtime override of EXTERNAL_SERVICE_TOKEN or dropped token environment settings is the likely cause; the exact platform behavior remains unverified.
The Dockerfile retains its original FROM and EXTERNAL_SERVICE_TOKEN, adds the identical XC_TOKEN value, copies config/secrets/xc_token to /app/secrets/xc_token, applies mode 400 using RUN, and sets XC_TOKEN_FILE=/app/secrets/xc_token. The original staged file remains length=32, no newline, mode 600. A narrow .dockerignore exception makes this file available to COPY while excluding other config files.
Resolution order is XC_TOKEN_FILE → XC_TOKEN → MODELHUB_XC_TOKEN → EXTERNAL_SERVICE_TOKEN. The three baked channels are the file, XC_TOKEN, and EXTERNAL_SERVICE_TOKEN. Empty or rejected sources fall through. Failure diagnostics expose only fixed status labels; successful resolution logs only the source name.
Validation: make test passed all 63 tests. Coverage includes file-first precedence with competing valid aliases, empty EXTERNAL_SERVICE_TOKEN falling through to the file, alias fallback order, missing credentials holding without submission, and probe/log secrecy. Static checks confirmed both baked ENV values match the file without printing values.
Docker is unavailable locally, so image build, RUN support under the platform base/Kaniko, runtime file readability, and production recovery remain unverified. RUN chmod is included as requested; no observed Kaniko rejection justifies removing it. No live calls, commit, push, or tag were performed.

View File

@@ -1 +0,0 @@

View 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
View 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()

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
"""ModelHub XC adaptation agent — Phase1 local skeleton."""
__version__ = "0.1.0"

15
src/banned.py Normal file
View 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)

View 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
View 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

74
src/gate.py Normal file
View 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
View 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
View 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)

206
src/live_cycle.py Normal file
View File

@@ -0,0 +1,206 @@
"""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, token_probe_status
from .ledger import load_baked, load_persisted, persist, rebuild
REJECT_CODES = {
60006: 'CANNOT_CREATE_CONTEST_TASK_BECAUSE_NOT_IN_WHITE_LIST',
60007: 'MODEL_VERIFY_TASK_NUM_LIMIT',
60008: 'CREATE_CONTEST_TASK_REQUEST_EMPTY',
60009: 'CREATE_CONTEST_TASK_CONTRIBUTORS_EMPTY',
60010: 'CREATE_CONTEST_TASK_SUBMISSION_CONFIG_EMPTY',
60011: 'CREATE_CONTEST_TASK_GPU_TYPE_EMPTY',
60012: 'CREATE_CONTEST_TASK_MULTI_GPU_UNSUPPORTED',
60013: 'CREATE_CONTEST_TASK_MODEL_ID_EMPTY',
60014: 'CREATE_CONTEST_TASK_SCRIPT_SUBMIT_DISABLED',
60015: 'CREATE_CONTEST_TASK_TASK_TYPE_EMPTY',
60016: 'CREATE_CONTEST_TASK_FRAMEWORK_EMPTY',
60017: 'CREATE_CONTEST_TASK_DRIVER_IMAGE_NOT_FOUND',
60018: 'CREATE_CONTEST_TASK_CONTRIBUTOR_NOT_FOUND',
60019: 'CREATE_CONTEST_TASK_SUBMISSION_GPU_TYPE_MISMATCH',
60020: 'CREATE_CONTEST_TASK_MODEL_NOT_DOWNLOADED',
60021: 'CREATE_CONTEST_TASK_FRAMEWORK_UNRESOLVED',
60022: 'CREATE_CONTEST_TASK_GPU_CONFIG_UNSUPPORTED',
60023: 'CREATE_CONTEST_TASK_YAML_CONFIG_EMPTY',
60024: 'CREATE_CONTEST_TASK_YAML_CONFIG_INVALID',
60025: 'CREATE_CONTEST_TASK_GPU_TYPE_UNSUPPORTED',
60026: 'CREATE_CONTEST_TASK_MODEL_NOT_FOUND',
60027: 'CREATE_CONTEST_TASK_MODEL_ALREADY_VERIFIED',
60028: 'CREATE_CONTEST_TASK_MODEL_VERIFY_RUNNING',
60029: 'CREATE_CONTEST_TASK_TRANSFORMERS_REQUIRES_FAILED_BASELINE',
60030: 'CREATE_CONTEST_TASK_API_VERSION_UNSUPPORTED',
}
_DONE_CODES = {60027, 60028}
_submitted = set()
_rejected = 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):
statuses = token_probe_status()
print('[live] token_probe ' + ' '.join(
name + '=' + statuses[name] for name in
('EXTERNAL_SERVICE_TOKEN', 'XC_TOKEN_FILE', 'XC_TOKEN', 'MODELHUB_XC_TOKEN')), file=sys.stderr)
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
try:
attempt_cap = int(os.environ.get('LIVE_MAX_ATTEMPTS_PER_CYCLE', '15'))
if attempt_cap < 1:
raise ValueError
except ValueError:
attempt_cap = 15
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 | _rejected
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
counted = 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 counted >= 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'
if code is not None and code != 0:
emit('rejected', REJECT_CODES.get(code, 'unknown'), model=model,
gpu=GPU_ENUMS[gpu], code=code, http=status, queue_before=count)
if code not in _DONE_CODES:
_rejected.add(pair)
persisted.discard(pair)
_submitted.discard(pair)
try:
persist(state, persisted)
except OSError:
emit('hold', 'state_write_failed')
break
if code == 60007:
emit('hold', 'task_num_limit')
break
if attempts >= attempt_cap:
emit('hold', 'attempt_cap')
break
continue
counted += 1
emit('submitted', reason, model=model, gpu=GPU_ENUMS[gpu], queue_before=count,
http=status, code=code, **meta)
if attempts >= attempt_cap:
emit('hold', 'attempt_cap')
break
return decisions

68
src/live_queue.py Normal file
View 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&current={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
View 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
View 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
View 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&current=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
View 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', '3600')))
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
View 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 = (
"XC_TOKEN_FILE",
"XC_TOKEN",
"MODELHUB_XC_TOKEN",
"EXTERNAL_SERVICE_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

90
src/task_add_client.py Normal file
View File

@@ -0,0 +1,90 @@
"""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)
TOKEN_SOURCES = ('XC_TOKEN_FILE', 'XC_TOKEN', 'MODELHUB_XC_TOKEN', 'EXTERNAL_SERVICE_TOKEN')
def _token_source(name):
"""Read one source; status and credential stay internal to resolution."""
value = os.environ.get(name)
if value is None:
return 'absent', ''
raw = value.strip()
if not raw:
return 'empty', ''
if name == 'XC_TOKEN_FILE':
try:
raw = Path(raw).read_text().strip()
except (OSError, UnicodeError, ValueError):
return 'rejected', ''
if not raw:
return 'empty', ''
if '<' in raw or '>' in raw or re.search(
r'(?i)(?:^|[^a-z0-9])(placeholder|changeme|tmp|xxx)(?:$|[^a-z0-9])', raw):
return 'rejected', ''
return 'ok', raw
def token_probe_status():
"""Return fixed per-source status labels only, never credentials or paths."""
return {name: _token_source(name)[0] for name in TOKEN_SOURCES}
def resolve_token_with_source():
for name in TOKEN_SOURCES:
status, token = _token_source(name)
if status == 'ok':
return token, 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)

View 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
View File

View 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()

View 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
View 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
View 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()

196
tests/test_live_cycle.py Normal file
View File

@@ -0,0 +1,196 @@
import io
import json
from pathlib import Path
import os
import tempfile
import unittest
from unittest.mock import Mock, patch
from src.live_cycle import run_cycle, _submitted, _rejected
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()
_rejected.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 state_pairs(self):
return json.loads(Path(os.environ['LIVE_STATE_FILE']).read_text())
def test_done_rejection_keeps_reservation_and_continues(self):
for code in (60027, 60028):
with self.subTest(code=code):
self.client.submit.side_effect = [
dict(http_status=200, platform_code=code),
dict(http_status=200, platform_code=0)]
with patch.dict(os.environ, {'LIVE_MAX_SUBMITS_PER_CYCLE': '1'}):
decisions = self.cycle([candidate(str(code)), candidate('ok' + str(code))], (1, 1))
self.assertEqual([d['action'] for d in decisions], ['rejected', 'submitted'])
self.assertIn('org/' + str(code) + '|Biren_166m', self.state_pairs())
self.assertIn('rejected', self.logs.getvalue())
self.assertIn('CREATE_CONTEST_TASK_MODEL_VERIFY_RUNNING', self.logs.getvalue())
def test_rejection_removed_but_not_retried_in_process(self):
self.client.submit.return_value = dict(http_status=200, platform_code=60020)
self.cycle()
self.assertEqual(self.state_pairs(), [])
self.cycle(counts=())
self.client.submit.assert_called_once()
def test_unknown_rejection(self):
self.client.submit.return_value = dict(http_status=200, platform_code=99999)
decisions = self.cycle()
self.assertEqual(decisions[0]['action'], 'rejected')
self.assertEqual(decisions[0]['reason'], 'unknown')
self.assertEqual(self.state_pairs(), [])
def test_attempt_cap_and_invalid_fallback(self):
for value, expected in ((None, 15), ('bad', 15), ('0', 15), ('-1', 15), ('2', 2)):
with self.subTest(value=value):
_rejected.clear()
self.client.reset_mock()
self.client.submit.return_value = dict(http_status=200, platform_code=60020)
with patch.dict(os.environ, {} if value is None else {'LIVE_MAX_ATTEMPTS_PER_CYCLE': value}):
decisions = self.cycle([candidate(str(i)) for i in range(expected)], (1,) * expected)
self.assertEqual(self.client.submit.call_count, expected)
self.assertEqual(decisions[-1]['reason'], 'attempt_cap')
def test_submit_cap_counts_successes_after_rejections(self):
self.client.submit.side_effect = [dict(http_status=200, platform_code=c) for c in (60020, 0, 60028, 0, 0)]
decisions = self.cycle([candidate(str(i)) for i in range(7)], (1,) * 5)
self.assertEqual(self.client.submit.call_count, 5)
self.assertEqual(sum(d['action'] == 'submitted' for d in decisions), 3)
self.assertEqual(decisions[-1]['reason'], 'cycle_cap')
def test_task_limit_stops_cycle(self):
self.client.submit.return_value = dict(http_status=200, platform_code=60007)
decisions = self.cycle([candidate('a'), candidate('b')])
self.assertEqual([d['action'] for d in decisions], ['rejected', 'hold'])
self.assertEqual(decisions[-1]['reason'], 'task_num_limit')
self.assertEqual(self.state_pairs(), [])
self.client.submit.assert_called_once()
def test_unknown_outcomes_count_and_reserve_before_submit(self):
for result in (dict(http_status=200, platform_code='0'), dict(http_status=None, platform_code=None), OSError()):
with self.subTest(result=result):
_submitted.clear()
Path(os.environ['LIVE_STATE_FILE']).unlink(missing_ok=True)
self.client.reset_mock()
def submit(payload):
self.assertIn('org/a|Biren_166m', self.state_pairs())
if isinstance(result, Exception):
raise result
return result
self.client.submit.side_effect = submit
with patch.dict(os.environ, {'LIVE_MAX_SUBMITS_PER_CYCLE': '1'}):
decisions = self.cycle([candidate('a'), candidate('b')])
self.assertEqual(decisions[0]['action'], 'submitted')
self.assertEqual(decisions[-1]['reason'], 'cycle_cap')
self.client.submit.assert_called_once()
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, {'STRATEGY_ID': 'sid'}, clear=True):
run_cycle([candidate('a')], self.client)
self.client.submit.assert_not_called()
self.assertIn('missing/invalid_token', self.logs.getvalue())
self.assertIn('[live] token_probe EXTERNAL_SERVICE_TOKEN=absent XC_TOKEN_FILE=absent XC_TOKEN=absent MODELHUB_XC_TOKEN=absent', self.logs.getvalue())
self.assertNotIn('[live] token source=', self.logs.getvalue())
def test_rejected_token_probe_does_not_leak(self):
with patch.dict(os.environ, {'XC_TOKEN': '<private-secret-fragment>'}):
self.cycle()
self.client.submit.assert_not_called()
self.assertIn('XC_TOKEN=rejected', self.logs.getvalue())
self.assertNotIn('private-secret', 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())

72
tests/test_live_ledger.py Normal file
View File

@@ -0,0 +1,72 @@
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(), ('synthetic-credential', 'XC_TOKEN'))
self.cycle()
self.assertEqual(log.getvalue().count('token source=XC_TOKEN'), 1)
self.assertNotIn('platform-credential', log.getvalue())
self.assertNotIn('synthetic-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
View 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')

View 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'])

View 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&current=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()

View 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
View 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()

View 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()

View File

@@ -0,0 +1,111 @@
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_file_first_and_empty_external_override(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'credential'
path.write_text('file-secret-credential')
for external in ('external-secret-credential', ''):
with self.subTest(external_empty=not external), mock.patch.dict(os.environ, {
'XC_TOKEN_FILE': str(path), 'XC_TOKEN': 'alias-secret-credential',
'MODELHUB_XC_TOKEN': 'model-secret-credential',
'EXTERNAL_SERVICE_TOKEN': external}, clear=True):
self.assertEqual(module.resolve_token_with_source(),
('file-secret-credential', 'XC_TOKEN_FILE'))
def test_alias_fallback_order(self):
env = {'XC_TOKEN_FILE': '/nonexistent/credential', 'XC_TOKEN': 'alias-secret',
'MODELHUB_XC_TOKEN': 'model-secret', 'EXTERNAL_SERVICE_TOKEN': 'external-secret'}
with mock.patch.dict(os.environ, env, clear=True):
for name in module.TOKEN_SOURCES[1:]:
self.assertEqual(module.resolve_token_with_source()[1], name)
os.environ[name] = ''
with self.assertRaises(ValueError):
module.resolve_token()
def test_probe_statuses_are_labels_only(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'private-path'
for contents, expected in (('file-secret-credential', 'ok'), (' ', 'empty'),
('<private-rejected-secret>', 'rejected')):
path.write_text(contents)
with mock.patch.dict(os.environ, {'XC_TOKEN_FILE': str(path),
'XC_TOKEN': 'alias-secret-credential',
'MODELHUB_XC_TOKEN': '<model-private-secret>',
'EXTERNAL_SERVICE_TOKEN': ' '}, clear=True):
statuses = module.token_probe_status()
self.assertEqual(statuses, dict(XC_TOKEN_FILE=expected, XC_TOKEN='ok',
MODELHUB_XC_TOKEN='rejected', EXTERNAL_SERVICE_TOKEN='empty'))
for secret in ('private', 'credential', 'alias-secret', 'model-private', str(path)):
self.assertNotIn(secret, str(statuses))
with mock.patch.dict(os.environ, {}, clear=True):
self.assertEqual(set(module.token_probe_status().values()), {'absent'})
with mock.patch.dict(os.environ, {'XC_TOKEN_FILE': str(path / 'missing')}, clear=True):
self.assertEqual(module.token_probe_status()['XC_TOKEN_FILE'], 'rejected')
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()