From bed3bc11ca81d05225dfa62c2e48ac28ebe49193 Mon Sep 17 00:00:00 2001 From: Victor Cao Date: Fri, 16 Oct 2026 19:00:15 +0800 Subject: [PATCH] Phase2 dry-run: pending A, probe, build-config client, gated task/add Still default dry-run; ALLOW_LIVE_SUBMIT=0. Live gates closed. --- Makefile | 18 +++- README.md | 2 + docs/api-probe-design.md | 17 ++-- docs/autoconfig-probe-plan.md | 25 +++++ docs/gpu-perf-order.md | 49 +++++++++ docs/issues.md | 10 +- docs/tag-dryrun-notes-draft.md | 13 +++ scripts/build_config_dry.py | 26 +++++ scripts/dry_run.py | 152 ++++++++++++---------------- scripts/probe_readonly.py | 17 ++++ src/build_config_client.py | 60 +++++++++++ src/config_params.py | 31 ++++++ src/gate.py | 30 ++++-- src/pending_pool.py | 106 +++++++++++++++++++ src/queue_depth.py | 22 ++++ src/readonly_probe.py | 44 ++++++++ src/task_add_client.py | 58 +++++++++++ templates/config_params.sample.yaml | 44 ++++++++ tests/test_build_config_client.py | 73 +++++++++++++ tests/test_config_params.py | 47 +++++++++ tests/test_gate.py | 23 ++++- tests/test_pending_pool.py | 49 +++++++++ tests/test_readonly_probe.py | 61 +++++++++++ tests/test_report_safety.py | 16 ++- tests/test_task_add_client.py | 69 +++++++++++++ 25 files changed, 952 insertions(+), 110 deletions(-) create mode 100644 docs/autoconfig-probe-plan.md create mode 100644 docs/gpu-perf-order.md create mode 100644 docs/tag-dryrun-notes-draft.md create mode 100644 scripts/build_config_dry.py create mode 100644 scripts/probe_readonly.py create mode 100644 src/build_config_client.py create mode 100644 src/config_params.py create mode 100644 src/pending_pool.py create mode 100644 src/queue_depth.py create mode 100644 src/readonly_probe.py create mode 100644 src/task_add_client.py create mode 100644 templates/config_params.sample.yaml create mode 100644 tests/test_build_config_client.py create mode 100644 tests/test_config_params.py create mode 100644 tests/test_pending_pool.py create mode 100644 tests/test_readonly_probe.py create mode 100644 tests/test_task_add_client.py diff --git a/Makefile b/Makefile index c854043..7eb9d72 100644 --- a/Makefile +++ b/Makefile @@ -2,18 +2,21 @@ # Allowed: test / build / smoke-health / dry-run # 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 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 + local SIGTERM integration (needs sockets)" @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 local example report; pending-submit integration outstanding" - @echo "No register/submit/deploy/push — Phase1 hard boundary." + @echo "dry-run read-only pending report (example fallback)" + @echo "No register/submit/deploy/push — default live off." test: $(PYTHON) -m unittest discover -s tests -v @@ -30,3 +33,12 @@ smoke-health: 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 diff --git a/README.md b/README.md index 0e9b345..41f8f90 100644 --- a/README.md +++ b/README.md @@ -72,3 +72,5 @@ tests/ # stdlib unittest ## 许可与边界 本仓库用于 Victor 本地/Gitea 交付骨架;任何 ModelHub 写操作须 Victor 本人在控制台或显式批准后执行。 + +Phase2:`make dry-run-pending` 只读生成 A→B 草稿,名称硬门与禁提过滤已接入;需 Victor 人工提交/确认。task/add 客户端默认不调用,必须显式注入并通过双闸门、队列及单次预算;测试仅 mock HTTP。 diff --git a/docs/api-probe-design.md b/docs/api-probe-design.md index 123542d..6c3b0cd 100644 --- a/docs/api-probe-design.md +++ b/docs/api-probe-design.md @@ -1,7 +1,7 @@ # API 探针设计(只读意图 · 禁止代调写接口) - 日期:2026-10-16 Asia/Shanghai -- Phase1:**仅设计文档**;本仓不实现真实 HTTP 写客户端 +- Phase2:客户端已实现但默认不调用;本轮禁止真实 task/add ## 管理 API `/agent_platform/strategies*` @@ -22,15 +22,20 @@ |------|------|----------| | GET | `/api/adapt/task/page` | 未来只读鉴权探针(对齐 huni-probe,`ALLOW_SUBMIT=0`) | | 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 | -容器内社区鉴权(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)` 仅供单测 -- **无**默认 `urllib`/`requests` 客户端指向 `task/add` 或 `strategies` POST +- `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 ## 冲突裁决提醒 diff --git a/docs/autoconfig-probe-plan.md b/docs/autoconfig-probe-plan.md new file mode 100644 index 0000000..bf67ca6 --- /dev/null +++ b/docs/autoconfig-probe-plan.md @@ -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。 diff --git a/docs/gpu-perf-order.md b/docs/gpu-perf-order.md new file mode 100644 index 0000000..d9a9557 --- /dev/null +++ b/docs/gpu-perf-order.md @@ -0,0 +1,49 @@ +# 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 离线 sample;CONFIG_PARAMS_SOURCE=build-config 或 ALLOW_BUILD_CONFIG=1 且 token 可解析时调用 API,失败不可静默回退。 +- **live 前必须有 token + API 调用实证**;sample 单独不是 production-ready。队列 ≥95 继续禁 live,本轮无 live。 diff --git a/docs/issues.md b/docs/issues.md index fc4ca2a..7bbf124 100644 --- a/docs/issues.md +++ b/docs/issues.md @@ -8,9 +8,9 @@ |-------------------|-----|--------------| | Phase 0 调研冻结 | M0 | 文档已落 `docs/` | | 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 4 一次 live 验证 | M4 | 闸门已备;**无真实客户端** | +| Phase 4 一次 live 验证 | M4 | 闸门已备;客户端已实现;默认关闭,必须显式注入 | | Phase 5 规模化 | — | 未开 | ## P0 Issues @@ -28,9 +28,9 @@ Phase1 构建与运行验收须独立记录;环境阻塞不等于通过。 -## P1(未做) +## P1 -- [ ] 对接 `/workspace/modelhub-adapt/pending-submit.md` A→B 只读 +- [x] 对接 `/workspace/modelhub-adapt/pending-submit.md` A→B 只读 - [ ] 结构化 JSON 日志(禁密钥) - [ ] evidence-agent 模板填写 @@ -43,5 +43,5 @@ Phase1 构建与运行验收须独立记录;环境阻塞不等于通过。 - 无 register / submit / deploy / push Makefile 目标 - 无 `/agent_platform/strategies*` 写调用 -- 无 `/api/adapt/task/add` 真客户端 +- `/api/adapt/task/add` 客户端默认不调用;双闸门、队列≥95 阻断、每进程最多一次;本轮未授权真网 - 适配 W1 优先;不抢队列 diff --git a/docs/tag-dryrun-notes-draft.md b/docs/tag-dryrun-notes-draft.md new file mode 100644 index 0000000..1cc2464 --- /dev/null +++ b/docs/tag-dryrun-notes-draft.md @@ -0,0 +1,13 @@ +# Tag 文案草稿(仍默认 dry-run)— 未授权勿 push + +- 建议 Tag 名(待 Victor 确认):`v0.2.0-phase2-dryrun` 或 `v0.2.0-submit-ready-dryrun` +- 相对 Phase1 `v0.1.0-phase1@0d0f3b6`:pending A 池 + 经验序排序、只读 task/page 探针、task/add 客户端仍双重闸门默认关、configParams sample 仅 dry-run +- **不含**:live 开闸、真实自动配置、开闸环境变量默认开启 +- 推 Tag 条件:仅当 Victor **明确**说「先上线 dry-run 客户端」;产品当前:**先别 push Tag** +- 队列满 / configParams 缺口期间:**不开闸** + +推送检查清单(人工): +1. `make test` 绿 +2. ALLOW_LIVE_SUBMIT 默认非 1;LIVE_CONFIRM 未设 +3. 控制台重建后日志仍为 dry-run / Phase2 格式 +4. 不代注册、不代 live diff --git a/scripts/build_config_dry.py b/scripts/build_config_dry.py new file mode 100644 index 0000000..6ef64be --- /dev/null +++ b/scripts/build_config_dry.py @@ -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()) diff --git a/scripts/dry_run.py b/scripts/dry_run.py index 53df4aa..bf0426f 100755 --- a/scripts/dry_run.py +++ b/scripts/dry_run.py @@ -1,12 +1,5 @@ #!/usr/bin/env python3 -"""Generate a dry-run adaptation report (no platform side effects). - -Reads config/candidates.example.json (or DRY_RUN_CANDIDATES path). -Writes reports/dry-run-.md -""" - -from __future__ import annotations - +"""Read-only Phase2 pending draft report. Offline by default; opt-in configuration generation only.""" import json import os import sys @@ -15,93 +8,78 @@ from pathlib import Path from zoneinfo import ZoneInfo 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, config_params_source, 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]: - data = json.loads(path.read_text(encoding="utf-8")) - if isinstance(data, list): - return data - if isinstance(data, dict) and "candidates" in data: - return list(data["candidates"]) - raise SystemExit(f"unexpected candidates schema in {path}") +def _load_candidates(path): + data = json.loads(path.read_text()) + return data if isinstance(data, list) else list(data['candidates']) -def _render(candidates: list[dict], ts: str, src: Path) -> str: - lines = [ - f"# Dry-run 适配建议报告 — {ts} (Asia/Shanghai)", - "", - "> **模式:dry-run** — 本报告不产生平台侧真实提交。", - "> **需 Victor 人工提交**:请登录模盒控制台按草稿确认后手工提交。", - "> **积分:待平台核实**(发布智能体本身不得分;成功适配按账本计)。", - "", - "## 闸门状态", - "", - f"- ALLOW_LIVE_SUBMIT is 1: {os.environ.get('ALLOW_LIVE_SUBMIT', '0').strip() == '1'}", - f"- LIVE_CONFIRM set: {'yes' if os.environ.get('LIVE_CONFIRM', '').strip() else 'no'}", - f"- STRATEGY_ID present: {'yes' if os.environ.get('STRATEGY_ID', '').strip() else 'no'}", - "", - "## 输入", - "", - f"- 候选文件:`{src}`", - f"- 候选条数:{len(candidates)}", - "", - "## 建议草稿(字段示意,非真实 POST)", - "", - ] +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;零网络写;需 Victor 人工提交/确认', + '> 积分:待平台核实', '> perf_order=unset;order_source=experience(经验序 / 待平台核实)', + f'> configParams source: {SOURCE}', f'> configParams_source={config_params_source()}', 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("modelAddress") or c.get("model_id") or "(missing model_id)" - lines.append(f"### {i}. `{model}`") - lines.append("") - lines.append("```json") - draft = { - "modelAddress": model, - "taskType": c.get("taskType", "text-generation"), - "targetGpu": c.get("targetGpu", "(unset)"), - "framework": c.get("framework", "(unset)"), - "strategyId": "", - "configParams": "", - "notes": c.get("notes", ""), - } - lines.append(json.dumps(draft, ensure_ascii=False, indent=2)) - lines.append("```") - lines.append("") - lines.append("- 风险:Phase1 未做唯一性/硬门预检;提交前请对照 do-not-resubmit 与队列空位。") - lines.append("- **需 Victor 人工提交**(或经双重闸门的受控 live;本骨架默认拒绝 live)。") - lines.append("- **积分:待平台核实**") - lines.append("") - lines.extend( - [ - "## 后续对接(Phase2)", - "", - "- **待对接 pending-submit**:尚未读取 `/workspace/modelhub-adapt/pending-submit.md`,未验证 A→B、PASS、硬门与 do-not-resubmit;本报告仅保留输入顺序。", - "- 适配 W1 优先;同一 100 队列,≥95 全体停交;空位先 A 再 B,人工核验后决定,不凭示例报告提交。", - "- 禁止 Makefile / 脚本调用 `/api/adapt/task/add` 或 `/agent_platform/strategies*` 写接口。", - "", - ] - ) - return "\n".join(lines) + model = c.get('model_address') or c.get('modelAddress') or c.get('model_id') or '' + gpus = experience_order(c.get('try_next_gpus', [])) + target = gpus[0] if gpus else c.get('targetGpu', '') + 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 一般不扇出', '']) + draft = build_payload(model, target, 'vllm', strategy_id='', + config_params=build_config_params(target)) + # 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=&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() -> None: - src = Path( - os.environ.get( - "DRY_RUN_CANDIDATES", - str(ROOT / "config" / "candidates.example.json"), - ) - ) - if not src.is_file(): - raise SystemExit(f"candidates file not found: {src}") - - candidates = _load_candidates(src) +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) - ts = now.strftime("%Y%m%d-%H%M%S") - out_dir = ROOT / "reports" - out_dir.mkdir(parents=True, exist_ok=True) - out_path = out_dir / f"dry-run-{ts}.md" - out_path.write_text(_render(candidates, now.strftime("%Y-%m-%d %H:%M:%S"), src), encoding="utf-8") - print(f"Wrote {out_path}") + 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__": +if __name__ == '__main__': main() diff --git a/scripts/probe_readonly.py b/scripts/probe_readonly.py new file mode 100644 index 0000000..a70dea5 --- /dev/null +++ b/scripts/probe_readonly.py @@ -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()) diff --git a/src/build_config_client.py b/src/build_config_client.py new file mode 100644 index 0000000..d513e4b --- /dev/null +++ b/src/build_config_client.py @@ -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 diff --git a/src/config_params.py b/src/config_params.py new file mode 100644 index 0000000..f366521 --- /dev/null +++ b/src/config_params.py @@ -0,0 +1,31 @@ +"""Offline sample by default; explicit opt-in enables captured build-config API.""" +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); default templates/config_params.sample.yaml' +GAP = 'GAP: build-config API discovered via capture (autoconfig-capture-2026-10-16.md); live/实锤 needs token + API call and auth proof. Sample is dry-run draft only, NOT production-ready.' + + +def config_params_source(): + source = os.environ.get('CONFIG_PARAMS_SOURCE', '').strip() + if source not in ('', 'sample', 'build-config'): + raise BuildConfigError('unsupported CONFIG_PARAMS_SOURCE') + if source == 'build-config': + return 'build-config' + if os.environ.get('ALLOW_BUILD_CONFIG') == '1': + try: + resolve_token() + except (ValueError, OSError): + pass + else: + return 'build-config' + return 'sample' + + +def build_config_params(gpu_type=None, framework='vllm', task_type='text-generation'): + """Return YAML; opted-in API failures propagate, never fall back to sample.""" + if config_params_source() == 'build-config': + return build_config(gpu_type, framework, task_type) + return (Path(__file__).resolve().parents[1] / 'templates/config_params.sample.yaml').read_text() diff --git a/src/gate.py b/src/gate.py index 3fb9dd3..f7f2a55 100644 --- a/src/gate.py +++ b/src/gate.py @@ -5,18 +5,23 @@ Live is allowed only when ALL of: - LIVE_CONFIRM=I_CONFIRM_LIVE_SUBMIT - STRATEGY_ID present -Phase1: no real HTTP client for /api/adapt/task/add. Live path accepts an -injected fake client for unit tests only. +Phase2 client exists but requires explicit injection. No automatic wiring. +Queue ≥95 blocks; at most one injected submit per process. """ from __future__ import annotations import os +import threading +from .queue_depth import queue_depth from dataclasses import dataclass from typing import Any, Callable, Dict, Optional, Protocol from .strategy import strategy_id_present +_submit_count = 0 +_submit_lock = threading.Lock() + LIVE_CONFIRM_VALUE = "I_CONFIRM_LIVE_SUBMIT" @@ -62,6 +67,11 @@ def evaluate_gate() -> GateDecision: allowed=False, 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( mode="live", allowed=True, @@ -75,10 +85,10 @@ def attempt_live_submit( ) -> Dict[str, Any]: """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 - a message that real HTTP submit is not implemented in this skeleton. - Never calls platform write APIs from this module. + Phase2 TaskAddClient must be explicitly injected and dual-gated. + Tests must use fake clients or mock HTTP only. """ + global _submit_count decision = evaluate_gate() if not decision.allowed: return { @@ -92,11 +102,17 @@ def attempt_live_submit( "ok": False, "mode": "blocked", "reason": ( - "Phase1 skeleton: no real task/add client; " - "inject a fake client in tests only" + "Phase2 task/add client exists; explicit client injection required; " + "no real request without injection and dual gate" ), "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) return { "ok": True, diff --git a/src/pending_pool.py b/src/pending_pool.py new file mode 100644 index 0000000..eeb9594 --- /dev/null +++ b/src/pending_pool.py @@ -0,0 +1,106 @@ +"""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 _candidate(model, section, gpu_text, notes): + return dict(model_address=model, section=section, + 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])) + 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) + 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') diff --git a/src/queue_depth.py b/src/queue_depth.py new file mode 100644 index 0000000..ec3202c --- /dev/null +++ b/src/queue_depth.py @@ -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 diff --git a/src/readonly_probe.py b/src/readonly_probe.py new file mode 100644 index 0000000..abdd7dc --- /dev/null +++ b/src/readonly_probe.py @@ -0,0 +1,44 @@ +"""Explicit read-only auth probe. Import performs no I/O; never calls task/add.""" +import json +import os +import urllib.error +import urllib.request +from src.task_add_client import resolve_token + + +def probe_readonly(): + try: + token = resolve_token() + except (ValueError, OSError): + return dict(http_status=None, platform_code=None, auth_ok=False, + record_summary={'count': 0}, error_attribution='missing/invalid credential') + request = urllib.request.Request( + os.environ.get('MODELHUB_API_BASE', 'https://modelhub.org.cn').rstrip('/') + + '/api/adapt/task/page?onlyMine=true&pageSize=5¤t=1', + headers={'Xc-Token': token, 'Accept': 'application/json'}, method='GET') + try: + with urllib.request.urlopen(request, timeout=30) as response: + status, raw = response.status, response.read() + except urllib.error.HTTPError as error: + status, raw = error.code, error.read() + except (OSError, urllib.error.URLError, ValueError): + return dict(http_status=None, platform_code=None, auth_ok=False, + record_summary={'count': 0}, error_attribution='transport/timeout') + # Never expose response strings: servers can echo credentials in any field. + try: + body = json.loads(raw) + except (ValueError, UnicodeError): + body = {} + body = body if isinstance(body, dict) else {} + code = body.get('code') + code = code if type(code) is int else int(code) if isinstance(code, str) and code in ('0', '40100', '40101') else None + ok = 200 <= status < 300 and code == 0 + data = body.get('data') + records = data.get('records') if isinstance(data, dict) else None + summary = {'count': len(records) if isinstance(records, list) else 0} + if isinstance(data, dict) and type(data.get('total')) is int: + summary['total'] = data['total'] + attribution = {40100: '40100 NOT_LOGIN_ERROR', 40101: '40101 NO_AUTH_ERROR'}.get( + code, 'success' if ok else 'other HTTP/platform/invalid response') + return dict(http_status=status, platform_code=code, auth_ok=ok, + record_summary=summary, error_attribution=attribution) diff --git a/src/task_add_client.py b/src/task_add_client.py new file mode 100644 index 0000000..da5e2ec --- /dev/null +++ b/src/task_add_client.py @@ -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='', framework='', + strategy_id=None, 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 '', + 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) diff --git a/templates/config_params.sample.yaml b/templates/config_params.sample.yaml new file mode 100644 index 0000000..9449ef5 --- /dev/null +++ b/templates/config_params.sample.yaml @@ -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 diff --git a/tests/test_build_config_client.py b/tests/test_build_config_client.py new file mode 100644 index 0000000..f065b37 --- /dev/null +++ b/tests/test_build_config_client.py @@ -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() diff --git a/tests/test_config_params.py b/tests/test_config_params.py new file mode 100644 index 0000000..42d222c --- /dev/null +++ b/tests/test_config_params.py @@ -0,0 +1,47 @@ +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, 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): + with patch("urllib.request.urlopen") as network: + self.assertEqual(config_params_source(), "sample") + build_config_params() + network.assert_not_called() + self.assertIn("discovered via capture", GAP) + with patch.dict(os.environ, {"CONFIG_PARAMS_SOURCE": "build-config"}): + with self.assertRaisesRegex(BuildConfigError, "missing/invalid credential"): + build_config_params("gpu") + with patch.dict(os.environ, {"ALLOW_BUILD_CONFIG": "1", "XC_TOKEN": "test-secret"}), patch("src.config_params.build_config", return_value="api: yaml") as client: + self.assertEqual(config_params_source(), "build-config") + self.assertEqual(build_config_params("gpu"), "api: yaml") + client.assert_called_once_with("gpu", "vllm", "text-generation") + with patch.dict(os.environ, {"CONFIG_PARAMS_SOURCE": "build-config"}), patch("src.config_params.build_config", side_effect=BuildConfigError("transport/timeout")): + with self.assertRaisesRegex(BuildConfigError, "transport/timeout"): + build_config_params("gpu") + + 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]) + self.assertEqual(build_config_params(), body['configParams']) + 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): + report = _render([{'model_address': 'org/a', 'try_next_gpus': ['壁砺']}], 'test', 'fixture') + for value in ('代提候选 / 需 Victor 确认', 'configParams source', 'configParams_source=sample', 'GAP:', 'truncated', 'max_model_len: 4096', 'perf_order=unset', 'order_source=experience'): + self.assertIn(value, report) diff --git a/tests/test_gate.py b/tests/test_gate.py index 3f7a50f..7a2d9e2 100644 --- a/tests/test_gate.py +++ b/tests/test_gate.py @@ -19,6 +19,27 @@ class FakeClient: 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): return mock.patch.dict( os.environ, @@ -78,7 +99,7 @@ class TestGate(unittest.TestCase): self.assertEqual(d.mode, "live") 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( os.environ, { diff --git a/tests/test_pending_pool.py b/tests/test_pending_pool.py new file mode 100644 index 0000000..375ceac --- /dev/null +++ b/tests/test_pending_pool.py @@ -0,0 +1,49 @@ +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') diff --git a/tests/test_readonly_probe.py b/tests/test_readonly_probe.py new file mode 100644 index 0000000..1a5d337 --- /dev/null +++ b/tests/test_readonly_probe.py @@ -0,0 +1,61 @@ +import io +import json +import os +import unittest +import urllib.error +from unittest.mock import patch, MagicMock +from src.readonly_probe import probe_readonly +from scripts.probe_readonly import main + + +class TestReadonlyProbe(unittest.TestCase): + def test_missing_token_no_network(self): + with patch.dict(os.environ, {}, clear=True), patch('urllib.request.urlopen') as call: + self.assertFalse(probe_readonly()['auth_ok']) + with patch('sys.stdout', io.StringIO()): + self.assertEqual(main(), 1) + call.assert_not_called() + + def test_success_and_exact_request(self): + response = MagicMock() + response.__enter__.return_value = response + response.status = 200 + response.read.return_value = json.dumps({'code': 0, 'data': { + 'records': [{'modelId': 'secret-value'}], 'total': 10}}).encode() + with patch.dict(os.environ, {'EXTERNAL_SERVICE_TOKEN': 'secret-value'}, clear=True), patch('urllib.request.urlopen', return_value=response) as call: + result = probe_readonly() + request = call.call_args.args[0] + self.assertEqual(request.get_method(), 'GET') + self.assertEqual(request.full_url, 'https://modelhub.org.cn/api/adapt/task/page?onlyMine=true&pageSize=5¤t=1') + self.assertEqual(request.get_header('Xc-token'), 'secret-value') + self.assertTrue(result['auth_ok']) + self.assertEqual(result['record_summary'], {'count': 1, 'total': 10}) + self.assertNotIn('secret-value', json.dumps(result)) + + def test_errors_no_fallback_or_echo(self): + for code in (40100, 40101, 50000): + error = urllib.error.HTTPError('https://example.test', 403, '', {}, io.BytesIO(json.dumps({'code': code, 'message': 'secret-value'}).encode())) + with patch.dict(os.environ, {'XC_TOKEN': 'secret-value'}, clear=True), patch('urllib.request.urlopen', side_effect=error) as call: + result = probe_readonly() + self.assertEqual(call.call_count, 1) + self.assertFalse(result['auth_ok']) + self.assertEqual(result['platform_code'], code) + self.assertIn(str(code) if code != 50000 else 'other', result['error_attribution']) + self.assertNotIn('secret-value', json.dumps(result)) + + def test_transport_and_invalid_json(self): + for error in (OSError('secret-value'), urllib.error.URLError('secret-value')): + with patch.dict(os.environ, {'XC_TOKEN': 'secret-value'}, clear=True), patch('urllib.request.urlopen', side_effect=error): + self.assertEqual(probe_readonly()['error_attribution'], 'transport/timeout') + response = MagicMock() + response.__enter__.return_value = response + response.status, response.read.return_value = 200, b'invalid secret-value' + with patch.dict(os.environ, {'XC_TOKEN': 'secret-value'}, clear=True), patch('urllib.request.urlopen', return_value=response): + self.assertFalse(probe_readonly()['auth_ok']) + + def test_import_no_network(self): + import importlib + import src.readonly_probe + with patch('urllib.request.urlopen') as call: + importlib.reload(src.readonly_probe) + call.assert_not_called() diff --git a/tests/test_report_safety.py b/tests/test_report_safety.py index b8caa3c..056cf5d 100644 --- a/tests/test_report_safety.py +++ b/tests/test_report_safety.py @@ -21,9 +21,23 @@ class TestReportSafety(unittest.TestCase): }, clear=True): report = _render([], "test-time", Path("example.json")) self.assertNotIn(sentinel, report) - for required in ("待对接 pending-submit", "A→B", "需 Victor 人工提交", "积分:待平台核实", "≥95"): + 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): diff --git a/tests/test_task_add_client.py b/tests/test_task_add_client.py new file mode 100644 index 0000000..da11590 --- /dev/null +++ b/tests/test_task_add_client.py @@ -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()