2 Commits

Author SHA1 Message Date
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
25 changed files with 1089 additions and 110 deletions

View File

@@ -2,18 +2,21 @@
# Allowed: test / build / smoke-health / dry-run # Allowed: test / build / smoke-health / dry-run
# FORBIDDEN: register / submit / deploy / push targets # FORBIDDEN: register / submit / deploy / push targets
.PHONY: test build smoke-health dry-run help .PHONY: build-config-dry probe-readonly test build smoke-health dry-run dry-run-pending help
PYTHON ?= python3 PYTHON ?= python3
IMAGE ?= modelhub-agent-contrib:local IMAGE ?= modelhub-agent-contrib:local
PORT ?= 8080 PORT ?= 8080
help: 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 + local SIGTERM integration (needs sockets)" @echo "test stdlib unit tests + local SIGTERM integration (needs sockets)"
@echo "build Docker build using platform base (needs Docker + TCR access)" @echo "build Docker build using platform base (needs Docker + TCR access)"
@echo "smoke-health local HTTP smoke (needs sockets; default test STRATEGY_ID)" @echo "smoke-health local HTTP smoke (needs sockets; default test STRATEGY_ID)"
@echo "dry-run local example report; pending-submit integration outstanding" @echo "dry-run read-only pending report (example fallback)"
@echo "No register/submit/deploy/push — Phase1 hard boundary." @echo "No register/submit/deploy/push — default live off."
test: test:
$(PYTHON) -m unittest discover -s tests -v $(PYTHON) -m unittest discover -s tests -v
@@ -30,3 +33,12 @@ smoke-health:
dry-run: dry-run:
$(PYTHON) scripts/dry_run.py $(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

@@ -72,3 +72,5 @@ tests/ # stdlib unittest
## 许可与边界 ## 许可与边界
本仓库用于 Victor 本地/Gitea 交付骨架;任何 ModelHub 写操作须 Victor 本人在控制台或显式批准后执行。 本仓库用于 Victor 本地/Gitea 交付骨架;任何 ModelHub 写操作须 Victor 本人在控制台或显式批准后执行。
Phase2:`make dry-run-pending` 只读生成 A→B 草稿,名称硬门与禁提过滤已接入;需 Victor 人工提交/确认。task/add 客户端默认不调用,必须显式注入并通过双闸门、队列及单次预算;测试仅 mock HTTP。

View File

@@ -1,7 +1,7 @@
# API 探针设计(只读意图 · 禁止代调写接口) # API 探针设计(只读意图 · 禁止代调写接口)
- 日期:2026-10-16 Asia/Shanghai - 日期:2026-10-16 Asia/Shanghai
- Phase1:**仅设计文档**;本仓不实现真实 HTTP 写客户端 - Phase2:客户端已实现但默认不调用;本轮禁止真实 task/add
## 管理 API `/agent_platform/strategies*` ## 管理 API `/agent_platform/strategies*`
@@ -22,15 +22,20 @@
|------|------|----------| |------|------|----------|
| GET | `/api/adapt/task/page` | 未来只读鉴权探针(对齐 huni-probe,`ALLOW_SUBMIT=0`) | | GET | `/api/adapt/task/page` | 未来只读鉴权探针(对齐 huni-probe,`ALLOW_SUBMIT=0`) |
| POST | `/api/adapt/task/build-config` | 设计可引用;Phase1 不实现 | | POST | `/api/adapt/task/build-config` | 设计可引用;Phase1 不实现 |
| POST | `/api/adapt/task/add` | **禁止代调**;live 仅闸门 + 单测假客户端 | | POST | `/api/adapt/task/add` | 显式注入客户端 + 双闸门;本轮只做 mock 测试 |
| GET | `/api/adapt/task/calculate-credit` | 只读索引;勿写死 KPI | | GET | `/api/adapt/task/calculate-credit` | 只读索引;勿写死 KPI |
容器内社区鉴权(C):`Xc-Token` + body `strategyId` — **待 Victor 账号核实**后再实现探针。 容器内社区鉴权(C):`Xc-Token` + body `strategyId` — **待 Victor 账号核实**;60014 仍为 **D(unverified)**。
## Phase1 落地边界 ## Phase2 落地边界
- `src/gate.py`:闸门逻辑 + `attempt_live_submit(..., client=Fake)` 仅供单测 - `src/gate.py`:闸门逻辑 + `attempt_live_submit(..., client=Fake)` ;真实客户端只能显式注入,测试必须 mock HTTP
- **无**默认 `urllib`/`requests` 客户端指向 `task/add` 或 `strategies` POST - `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 - Makefile **无** register / submit / deploy / push
## 冲突裁决提醒 ## 冲突裁决提醒

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。

51
docs/gpu-perf-order.md Normal file
View File

@@ -0,0 +1,51 @@
# 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 天垓族(字符串待核) | 条件回退;饱和+唯一性碰撞多 |
| 3 | 天垓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。模型地址不进入该请求。
- 抓包观察 Authorization(值未记录);容器默认 Xc-Token 社区实践,可显式切换 Authorization,尚无 token 鉴权实证。
- 默认 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。

View File

@@ -8,9 +8,9 @@
|-------------------|-----|--------------| |-------------------|-----|--------------|
| Phase 0 调研冻结 | M0 | 文档已落 `docs/` | | Phase 0 调研冻结 | M0 | 文档已落 `docs/` |
| Phase 1 本地可部署样例 | M1 | **本轮交付** | | Phase 1 本地可部署样例 | M1 | **本轮交付** |
| Phase 2 Dry-run 闭环 | M2 | 骨架:`scripts/dry_run.py`;**待对接 pending-submit**:A→B / PASS / 硬门尚未自动核验 | | Phase 2 Dry-run 闭环 | M2 | 已实现 pending A→B 解析、名称硬门/禁提过滤与 dry-run;元数据 PASS 仍需人工核实 |
| Phase 3 人工注册 + 只读探针 | M3 | 仅 runbook / api-probe-design;**不代注册** | | Phase 3 人工注册 + 只读探针 | M3 | 仅 runbook / api-probe-design;**不代注册** |
| Phase 4 一次 live 验证 | M4 | 闸门已备;**无真实客户端** | | Phase 4 一次 live 验证 | M4 | 闸门已备;客户端已实现;默认关闭,必须显式注入 |
| Phase 5 规模化 | — | 未开 | | Phase 5 规模化 | — | 未开 |
## P0 Issues ## P0 Issues
@@ -28,9 +28,9 @@
Phase1 构建与运行验收须独立记录;环境阻塞不等于通过。 Phase1 构建与运行验收须独立记录;环境阻塞不等于通过。
## P1(未做) ## P1
- [ ] 对接 `/workspace/modelhub-adapt/pending-submit.md` A→B 只读 - [x] 对接 `/workspace/modelhub-adapt/pending-submit.md` A→B 只读
- [ ] 结构化 JSON 日志(禁密钥) - [ ] 结构化 JSON 日志(禁密钥)
- [ ] evidence-agent 模板填写 - [ ] evidence-agent 模板填写
@@ -43,5 +43,5 @@ Phase1 构建与运行验收须独立记录;环境阻塞不等于通过。
- 无 register / submit / deploy / push Makefile 目标 - 无 register / submit / deploy / push Makefile 目标
- 无 `/agent_platform/strategies*` 写调用 - 无 `/agent_platform/strategies*` 写调用
- 无 `/api/adapt/task/add` 真客户端 - `/api/adapt/task/add` 客户端默认不调用;双闸门、队列≥95 阻断、每进程最多一次;本轮未授权真网
- 适配 W1 优先;不抢队列 - 适配 W1 优先;不抢队列

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

View File

@@ -1,12 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Generate a dry-run adaptation report (no platform side effects). """Read-only Phase2 pending draft report. Token-resolvable build-config first; explicit sample fallback."""
Reads config/candidates.example.json (or DRY_RUN_CANDIDATES path).
Writes reports/dry-run-<Asia/Shanghai-timestamp>.md
"""
from __future__ import annotations
import json import json
import os import os
import sys import sys
@@ -15,93 +8,81 @@ from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
TZ = ZoneInfo("Asia/Shanghai") 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: Path) -> list[dict]: def _load_candidates(path):
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text())
if isinstance(data, list): return data if isinstance(data, list) else list(data['candidates'])
return data
if isinstance(data, dict) and "candidates" in data:
return list(data["candidates"])
raise SystemExit(f"unexpected candidates schema in {path}")
def _render(candidates: list[dict], ts: str, src: Path) -> str: def _render(candidates, ts, src):
lines = [ candidates = [c for c in candidates if not os.environ.get('PENDING_SECTION') or
f"# Dry-run 适配建议报告 — {ts} (Asia/Shanghai)", c.get('section', 'B') == os.environ['PENDING_SECTION'].strip().upper()]
"", depth = queue_depth()
"> **模式:dry-run** — 本报告不产生平台侧真实提交。", lines = [f'# 代提候选 / 需 Victor 确认 — Dry-run — {ts} (Asia/Shanghai)', '',
"> **需 Victor 人工提交**:请登录模盒控制台按草稿确认后手工提交。", '> 模式: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 "未知,需人工确认"}',
f"- ALLOW_LIVE_SUBMIT is 1: {os.environ.get('ALLOW_LIVE_SUBMIT', '0').strip() == '1'}", '队列≥95:禁 live;适配 W1 优先,不抢空位。', '']
f"- LIVE_CONFIRM set: {'yes' if os.environ.get('LIVE_CONFIRM', '').strip() else 'no'}", if depth is not None and depth >= 95:
f"- STRATEGY_ID present: {'yes' if os.environ.get('STRATEGY_ID', '').strip() else 'no'}", lines.extend(['> **禁 live / 队列≥95**', ''])
"",
"## 输入",
"",
f"- 候选文件:`{src}`",
f"- 候选条数:{len(candidates)}",
"",
"## 建议草稿(字段示意,非真实 POST)",
"",
]
for i, c in enumerate(candidates, 1): for i, c in enumerate(candidates, 1):
model = c.get("modelAddress") or c.get("model_id") or "(missing model_id)" model = c.get('model_address') or c.get('modelAddress') or c.get('model_id') or '<MODEL>'
lines.append(f"### {i}. `{model}`") gpus = experience_order(c.get('try_next_gpus', []))
lines.append("") target = gpus[0] if gpus else c.get('targetGpu', '<TARGET_GPU:需人工确认>')
lines.append("```json") status = c.get('status', 'needs_human')
draft = { lines.extend([f'### {i}. Section {c.get("section", "B")} — `{model}`', '',
"modelAddress": model, f'状态:{status};needs_human={c.get("needs_human", True)}',
"taskType": c.get("taskType", "text-generation"), f'过滤:{c.get("skip_reason") or "名称硬门未命中;元数据/唯一性待人工核实"}', ''])
"targetGpu": c.get("targetGpu", "(unset)"), lines.extend([f'未适配卡(仅 pending 缺口):{", ".join(gpus) or "未提供,需确认"}',
"framework": c.get("framework", "(unset)"), 'perf_order=unset;order_source=experience;天垓100 一般不扇出', ''])
"strategyId": "<PLATFORM_INJECTED_STRATEGY_ID>", yaml, meta = build_config_params_with_meta(target)
"configParams": "<from build-config — not fetched in Phase1 dry-run>", lines.extend([f'foothold_class={c.get("foothold_class", "unknown")}; evidence_source={c.get("evidence_source", "GAP")}; no_download={c.get("no_download", False)}',
"notes": c.get("notes", ""), '; '.join(f'{key}={value}' for key, value in meta.items()), ''])
} draft = build_payload(model, target, 'vllm', strategy_id='<STRATEGY_ID>',
lines.append(json.dumps(draft, ensure_ascii=False, indent=2)) config_params=yaml)
lines.append("```") # Display only; this truncated draft must never be submitted.
lines.append("") draft['configParams'] = draft['configParams'][:400] + '\n… [truncated; dry-run only]'
lines.append("- 风险:Phase1 未做唯一性/硬门预检;提交前请对照 do-not-resubmit 与队列空位。") 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), '```',
lines.append("- **需 Victor 人工提交**(或经双重闸门的受控 live;本骨架默认拒绝 live)。") '需 Victor 人工提交/确认;积分:待平台核实', ''])
lines.append("- **积分:待平台核实**") report = '\n'.join(lines)
lines.append("") # Defensive redaction even if an input happens to contain an injected credential.
lines.extend( 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'):
"## 后续对接(Phase2)", try:
"", secrets.append(Path(os.environ['XC_TOKEN_FILE']).read_text().strip())
"- **待对接 pending-submit**:尚未读取 `/workspace/modelhub-adapt/pending-submit.md`,未验证 A→B、PASS、硬门与 do-not-resubmit;本报告仅保留输入顺序。", except OSError:
"- 适配 W1 优先;同一 100 队列,≥95 全体停交;空位先 A 再 B,人工核验后决定,不凭示例报告提交。", pass
"- 禁止 Makefile / 脚本调用 `/api/adapt/task/add` 或 `/agent_platform/strategies*` 写接口。", for secret in secrets:
"", if secret:
] report = report.replace(secret, '[REDACTED]')
) return report
return "\n".join(lines)
def main() -> None: def main():
src = Path( md = input_path('PENDING_SUBMIT_MD', 'pending-submit.md')
os.environ.get( txt = input_path('PENDING_SUBMIT_TXT', 'pending-submit.txt')
"DRY_RUN_CANDIDATES", if md.is_file() or txt.is_file():
str(ROOT / "config" / "candidates.example.json"), src = md if md.is_file() else txt
) candidates = build_ordered_candidates(md, txt)
) else:
if not src.is_file(): src = Path(os.environ.get('DRY_RUN_CANDIDATES', ROOT / 'config/candidates.example.json'))
raise SystemExit(f"candidates file not found: {src}")
candidates = _load_candidates(src) candidates = _load_candidates(src)
now = datetime.now(TZ) now = datetime.now(TZ)
ts = now.strftime("%Y%m%d-%H%M%S") out = ROOT / 'reports' / f'dry-run-{now:%Y%m%d-%H%M%S}.md'
out_dir = ROOT / "reports" out.parent.mkdir(exist_ok=True)
out_dir.mkdir(parents=True, exist_ok=True) out.write_text(_render(candidates, now.strftime('%Y-%m-%d %H:%M:%S'), src), encoding='utf-8')
out_path = out_dir / f"dry-run-{ts}.md" print(f'Wrote {out}')
out_path.write_text(_render(candidates, now.strftime("%Y-%m-%d %H:%M:%S"), src), encoding="utf-8")
print(f"Wrote {out_path}")
if __name__ == "__main__": if __name__ == '__main__':
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())

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

66
src/config_params.py Normal file
View File

@@ -0,0 +1,66 @@
"""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',
}
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]

View File

@@ -5,18 +5,23 @@ Live is allowed only when ALL of:
- LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT - LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT
- STRATEGY_ID present - STRATEGY_ID present
Phase1: no real HTTP client for /api/adapt/task/add. Live path accepts an Phase2 client exists but requires explicit injection. No automatic wiring.
injected fake client for unit tests only. Queue ≥95 blocks; at most one injected submit per process.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import threading
from .queue_depth import queue_depth
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Callable, Dict, Optional, Protocol from typing import Any, Callable, Dict, Optional, Protocol
from .strategy import strategy_id_present from .strategy import strategy_id_present
_submit_count = 0
_submit_lock = threading.Lock()
LIVE_CONFIRM_VALUE = "I_CONFIRM_LIVE_SUBMIT" LIVE_CONFIRM_VALUE = "I_CONFIRM_LIVE_SUBMIT"
@@ -62,6 +67,11 @@ def evaluate_gate() -> GateDecision:
allowed=False, allowed=False,
reason=f"LIVE_CONFIRM must be exactly {LIVE_CONFIRM_VALUE!r}", reason=f"LIVE_CONFIRM must be exactly {LIVE_CONFIRM_VALUE!r}",
) )
depth = queue_depth()
if depth is None and ("ASYNC_QUEUE_DEPTH" in os.environ or "QUEUE_SNAPSHOT_JSON" in os.environ):
return GateDecision("blocked", False, "queue depth invalid/unknown; human confirmation required")
if depth is not None and depth >= 95:
return GateDecision("blocked", False, "queue ≥95: 禁 live")
return GateDecision( return GateDecision(
mode="live", mode="live",
allowed=True, allowed=True,
@@ -75,10 +85,10 @@ def attempt_live_submit(
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Attempt a live submit only if the gate opens and a client is injected. """Attempt a live submit only if the gate opens and a client is injected.
Phase1: without an injected client, even an open gate returns blocked with Phase2 TaskAddClient must be explicitly injected and dual-gated.
a message that real HTTP submit is not implemented in this skeleton. Tests must use fake clients or mock HTTP only.
Never calls platform write APIs from this module.
""" """
global _submit_count
decision = evaluate_gate() decision = evaluate_gate()
if not decision.allowed: if not decision.allowed:
return { return {
@@ -92,11 +102,17 @@ def attempt_live_submit(
"ok": False, "ok": False,
"mode": "blocked", "mode": "blocked",
"reason": ( "reason": (
"Phase1 skeleton: no real task/add client; " "Phase2 task/add client exists; explicit client injection required; "
"inject a fake client in tests only" "no real request without injection and dual gate"
), ),
"submitted": False, "submitted": False,
} }
with _submit_lock:
if _submit_count >= 1:
return dict(ok=False, mode="blocked", submitted=False,
reason="process limit: at most 1 real add")
# Reserve before invocation: a transport failure may still have created a task.
_submit_count += 1
result = client.submit(payload) result = client.submit(payload)
return { return {
"ok": True, "ok": True,

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)

58
src/task_add_client.py Normal file
View File

@@ -0,0 +1,58 @@
"""Explicit task/add client; import never sends requests. No retries.
Community Xc-Token authentication is C; platform code 60014 is D (unverified).
GPU enums/framework/configParams require human verification. Draft placeholders
must be replaced before a real call. Use through the dual gate with injection.
"""
import json
import os
import re
import urllib.request
import urllib.error
from pathlib import Path
def build_payload(model_address, target_gpu='<TARGET_GPU>', framework='<FRAMEWORK>',
strategy_id=None, config_params='<CONFIG_PARAMS>'):
return dict(modelAddress=model_address, taskType='text-generation', targetGpu=target_gpu,
framework=framework, strategyId=strategy_id or os.environ.get('STRATEGY_ID') or '<STRATEGY_ID>',
configParams=config_params)
def resolve_token():
file = os.environ.get('XC_TOKEN_FILE')
token = Path(file).read_text().strip() if file else next(
(os.environ[k].strip() for k in ('XC_TOKEN', 'MODELHUB_XC_TOKEN', 'EXTERNAL_SERVICE_TOKEN') if os.environ.get(k)), '')
if not token or re.search(r'(?i)(?:^|[^a-z0-9])(placeholder|changeme|tmp)(?:$|[^a-z0-9])', token):
raise ValueError('Missing or invalid service credential')
return token
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

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 ('天垓150', '智铠 mrv-100', '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)

View File

@@ -19,6 +19,27 @@ class FakeClient:
class TestGate(unittest.TestCase): class TestGate(unittest.TestCase):
def setUp(self):
self.counter = mock.patch('src.gate._submit_count', 0)
self.counter.start()
self.addCleanup(self.counter.stop)
self.env = mock.patch.dict(os.environ, {}, clear=True)
self.env.start()
self.addCleanup(self.env.stop)
def test_queue_and_process_limit(self):
client = FakeClient()
with mock.patch.dict(os.environ, {
'STRATEGY_ID': 'sid', 'ALLOW_LIVE_SUBMIT': '1',
'LIVE_CONFIRM': LIVE_CONFIRM_VALUE, 'ASYNC_QUEUE_DEPTH': '95'
}):
self.assertIn('≥95', attempt_live_submit({}, client)['reason'])
self.assertEqual(client.calls, [])
os.environ['ASYNC_QUEUE_DEPTH'] = '94'
self.assertTrue(attempt_live_submit({}, client)['submitted'])
self.assertFalse(attempt_live_submit({}, client)['submitted'])
self.assertEqual(len(client.calls), 1)
def _clear_gate_env(self): def _clear_gate_env(self):
return mock.patch.dict( return mock.patch.dict(
os.environ, os.environ,
@@ -78,7 +99,7 @@ class TestGate(unittest.TestCase):
self.assertEqual(d.mode, "live") self.assertEqual(d.mode, "live")
def test_open_gate_without_client_still_blocked(self) -> None: def test_open_gate_without_client_still_blocked(self) -> None:
"""Phase1: no real HTTP client even when gate is open.""" """Phase2: an open gate never constructs a network client."""
with mock.patch.dict( with mock.patch.dict(
os.environ, os.environ,
{ {

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

@@ -21,9 +21,23 @@ class TestReportSafety(unittest.TestCase):
}, clear=True): }, clear=True):
report = _render([], "test-time", Path("example.json")) report = _render([], "test-time", Path("example.json"))
self.assertNotIn(sentinel, report) self.assertNotIn(sentinel, report)
for required in ("待对接 pending-submit", "A→B", "需 Victor 人工提交", "积分:待平台核实", "≥95"): for required in ("A→B", "需 Victor 人工提交", "积分:待平台核实", "≥95"):
self.assertIn(required, report) 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): def test_http_log_omits_request_details(self):
output = io.StringIO() output = io.StringIO()
with contextlib.redirect_stderr(output): with contextlib.redirect_stderr(output):

View File

@@ -0,0 +1,69 @@
import importlib
import io
import os
import tempfile
import unittest
import urllib.error
from pathlib import Path
from unittest import mock
from src import task_add_client as module
class TestTaskAddClient(unittest.TestCase):
def setUp(self):
patch = mock.patch.dict(os.environ, {'STRATEGY_ID': 'sid', 'XC_TOKEN': 'test-secret-credential'}, clear=True)
patch.start()
self.addCleanup(patch.stop)
self.payload = module.build_payload('org/model', '壁砺', 'vllm', config_params={ 'max_model_len': 4096 })
def test_import_no_network(self):
with mock.patch('urllib.request.urlopen') as urlopen:
importlib.reload(module)
urlopen.assert_not_called()
def test_success(self):
response = mock.MagicMock()
response.__enter__.return_value = response
response.status = 200
response.read.return_value = b'{"code":0,"message":"test-secret-credential"}'
with mock.patch('urllib.request.urlopen', return_value=response) as urlopen:
result = module.TaskAddClient().submit(self.payload)
self.assertEqual(result['http_status'], 200)
self.assertNotIn(os.environ['XC_TOKEN'], str(result))
request = urlopen.call_args.args[0]
self.assertEqual(request.get_header('Xc-token'), os.environ['XC_TOKEN'])
self.assertEqual(request.method, 'POST')
urlopen.assert_called_once()
def test_http_4xx_60014(self):
error = urllib.error.HTTPError('https://example.invalid', 403, 'forbidden', {}, io.BytesIO(b'{"code":60014}'))
with mock.patch('urllib.request.urlopen', side_effect=error) as urlopen:
result = module.TaskAddClient().submit(self.payload)
self.assertEqual(result['http_status'], 403)
self.assertEqual(result['platform_code'], 60014)
self.assertIn('D (unverified)', result['error_attribution'])
urlopen.assert_called_once()
def test_transport_no_retry(self):
with mock.patch('urllib.request.urlopen', side_effect=TimeoutError('test-secret-credential')) as urlopen:
result = module.TaskAddClient().submit(self.payload)
self.assertNotIn(os.environ['XC_TOKEN'], str(result))
self.assertEqual(result['error_attribution'], 'transport/timeout')
urlopen.assert_called_once()
def test_token_precedence_and_rejections(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory)/'credential'
path.write_text(' file-credential \n')
with mock.patch.dict(os.environ, {'XC_TOKEN_FILE': str(path)}):
self.assertEqual(module.resolve_token(), 'file-credential')
for token in ('', 'tmp', 'PLACEHOLDER', 'changeme', 'tmp-token'):
with self.subTest(token=token), mock.patch.dict(os.environ, {'XC_TOKEN': token}):
with self.assertRaises(ValueError):
module.resolve_token()
def test_placeholder_never_requests(self):
with mock.patch('urllib.request.urlopen') as urlopen:
with self.assertRaises(ValueError):
module.TaskAddClient().submit(module.build_payload('org/model'))
urlopen.assert_not_called()