forked from luopingyi/xc_agent_platform_demo
Phase2 dry-run: pending A, probe, build-config client, gated task/add
Still default dry-run; ALLOW_LIVE_SUBMIT=0. Live gates closed.
This commit is contained in:
18
Makefile
18
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
|
||||
|
||||
@@ -72,3 +72,5 @@ tests/ # stdlib unittest
|
||||
## 许可与边界
|
||||
|
||||
本仓库用于 Victor 本地/Gitea 交付骨架;任何 ModelHub 写操作须 Victor 本人在控制台或显式批准后执行。
|
||||
|
||||
Phase2:`make dry-run-pending` 只读生成 A→B 草稿,名称硬门与禁提过滤已接入;需 Victor 人工提交/确认。task/add 客户端默认不调用,必须显式注入并通过双闸门、队列及单次预算;测试仅 mock HTTP。
|
||||
|
||||
@@ -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
|
||||
|
||||
## 冲突裁决提醒
|
||||
|
||||
25
docs/autoconfig-probe-plan.md
Normal file
25
docs/autoconfig-probe-plan.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# configParams 配置来源与容器验证
|
||||
|
||||
日期:2026-10-16 Asia/Shanghai。
|
||||
|
||||
结论:**API**。配置来自服务端,前端将响应 `data` 写入 `configParams`。
|
||||
依据:[autoconfig-capture-2026-10-16.md](/workspace/modelhub-adapt/logs/autoconfig-capture-2026-10-16.md)。原「公开面无 build-config」缺口关闭:**未公开文档但已抓包证实**。
|
||||
|
||||
## 已证实契约
|
||||
|
||||
- `POST /api/adapt/task/build-config`
|
||||
- Query:`gpuType=Sunrise_pt-200-x1&framework=vllm&taskType=text-generation`
|
||||
- 空 body,无 JSON;模型地址不进入 query/body,也无需传 strategyId。
|
||||
- HTTP 2xx,JSON `code == 0`,非空字符串 `data` 为 YAML。
|
||||
- 当前 UI 无「自动配置」标签。新建适配弹层选择模型、GPU、文本生成后,选择框架触发 effect 并填入配置。
|
||||
- 抓包观察到 `Authorization` 头,值未记录。容器默认按社区实践发送 `Xc-Token`;`BUILD_CONFIG_USE_AUTHORIZATION=1` 改用同一 resolve_token 值作为 Authorization,不自动加 Bearer。
|
||||
|
||||
## 客户端与验证
|
||||
|
||||
`src/build_config_client.py` 的 `build_config()` 成功返回 YAML,失败抛出 `BuildConfigError`,包含安全归因、HTTP 状态和平台数字码;不返回服务端消息或传输异常细节。缺失/无效凭证不联网,40100、40101、其他平台码、传输失败和无效 data 均可区分。导入无 I/O。
|
||||
|
||||
默认 dry-run 使用 `templates/config_params.sample.yaml`,无网络。`CONFIG_PARAMS_SOURCE=build-config` 强制 API;或 `ALLOW_BUILD_CONFIG=1` 且可解析 token 时使用 API。调用失败直接报错,不回退 sample。调用方须传真实 GPU enum。`make build-config-dry` 仅生成配置,缺 token 非零退出,不输出 YAML 或凭证。
|
||||
|
||||
## 剩余 GAP 与边界
|
||||
|
||||
尚无容器 token 鉴权实证;需运行时 token + API 调用并与只读探针对照,不能宣称鉴权就绪。sample 本身不是 production-ready。队列 ≥95 禁 live。本轮仅 mock 测试,不进行真实 API 调用,不调用 task/add,不保存秘密,不提交任务或推送 tag。
|
||||
49
docs/gpu-perf-order.md
Normal file
49
docs/gpu-perf-order.md
Normal file
@@ -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。
|
||||
@@ -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 优先;不抢队列
|
||||
|
||||
13
docs/tag-dryrun-notes-draft.md
Normal file
13
docs/tag-dryrun-notes-draft.md
Normal file
@@ -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
|
||||
26
scripts/build_config_dry.py
Normal file
26
scripts/build_config_dry.py
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate config only; never creates a task or prints returned YAML/secrets."""
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from src.build_config_client import build_config, BuildConfigError
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--gpu-type', default='Sunrise_pt-200-x1')
|
||||
parser.add_argument('--framework', default='vllm')
|
||||
parser.add_argument('--task-type', default='text-generation')
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
yaml = build_config(args.gpu_type, args.framework, args.task_type)
|
||||
except BuildConfigError as error:
|
||||
print(str(error), file=sys.stderr)
|
||||
return 1
|
||||
print(f'configParams_source=build-config; generated YAML characters={len(yaml)}; no task submitted')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -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-<Asia/Shanghai-timestamp>.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": "<PLATFORM_INJECTED_STRATEGY_ID>",
|
||||
"configParams": "<from build-config — not fetched in Phase1 dry-run>",
|
||||
"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 '<MODEL>'
|
||||
gpus = experience_order(c.get('try_next_gpus', []))
|
||||
target = gpus[0] if gpus else c.get('targetGpu', '<TARGET_GPU:需人工确认>')
|
||||
status = c.get('status', 'needs_human')
|
||||
lines.extend([f'### {i}. Section {c.get("section", "B")} — `{model}`', '',
|
||||
f'状态:{status};needs_human={c.get("needs_human", True)}',
|
||||
f'过滤:{c.get("skip_reason") or "名称硬门未命中;元数据/唯一性待人工核实"}', ''])
|
||||
lines.extend([f'未适配卡(仅 pending 缺口):{", ".join(gpus) or "未提供,需确认"}',
|
||||
'perf_order=unset;order_source=experience;天垓100 一般不扇出', ''])
|
||||
draft = build_payload(model, target, 'vllm', strategy_id='<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=<targetGpu>&framework=vllm&taskType=text-generation; POST empty body', 'POST 字段草稿(skipped 行禁止提交):', '```json', json.dumps(draft, ensure_ascii=False, indent=2), '```',
|
||||
'需 Victor 人工提交/确认;积分:待平台核实', ''])
|
||||
report = '\n'.join(lines)
|
||||
# Defensive redaction even if an input happens to contain an injected credential.
|
||||
secrets = [os.environ.get(k, '') for k in ('XC_TOKEN', 'MODELHUB_XC_TOKEN', 'EXTERNAL_SERVICE_TOKEN', 'Xc-Token')]
|
||||
if os.environ.get('XC_TOKEN_FILE'):
|
||||
try:
|
||||
secrets.append(Path(os.environ['XC_TOKEN_FILE']).read_text().strip())
|
||||
except OSError:
|
||||
pass
|
||||
for secret in secrets:
|
||||
if secret:
|
||||
report = report.replace(secret, '[REDACTED]')
|
||||
return report
|
||||
|
||||
|
||||
def main() -> 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()
|
||||
|
||||
17
scripts/probe_readonly.py
Normal file
17
scripts/probe_readonly.py
Normal file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run only the task/page GET; print credential-free summary."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from src.readonly_probe import probe_readonly
|
||||
|
||||
|
||||
def main():
|
||||
result = probe_readonly()
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
return 0 if result['auth_ok'] else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
60
src/build_config_client.py
Normal file
60
src/build_config_client.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Explicit configuration generation; import performs no I/O.
|
||||
|
||||
Returns YAML on success, otherwise raises BuildConfigError with safe attribution
|
||||
and HTTP/platform status. Server messages and transport details are never exposed.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from src.task_add_client import resolve_token
|
||||
|
||||
|
||||
class BuildConfigError(RuntimeError):
|
||||
def __init__(self, reason, http_status=None, platform_code=None):
|
||||
self.error_attribution = reason
|
||||
self.http_status = http_status
|
||||
self.platform_code = platform_code
|
||||
super().__init__('build-config: ' + reason)
|
||||
|
||||
|
||||
def build_config(gpu_type, framework='vllm', task_type='text-generation'):
|
||||
try:
|
||||
token = resolve_token()
|
||||
except (ValueError, OSError):
|
||||
raise BuildConfigError('missing/invalid credential') from None
|
||||
if not isinstance(gpu_type, str) or not gpu_type.strip() or '<' in gpu_type:
|
||||
raise BuildConfigError('gpu_type required (platform enum)')
|
||||
query = urllib.parse.urlencode(dict(gpuType=gpu_type, framework=framework, taskType=task_type))
|
||||
header = 'Authorization' if os.environ.get('BUILD_CONFIG_USE_AUTHORIZATION') == '1' else 'Xc-Token'
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
os.environ.get('MODELHUB_API_BASE', 'https://www.modelhub.org.cn').rstrip('/')
|
||||
+ '/api/adapt/task/build-config?' + query,
|
||||
headers={header: token, 'Accept': 'application/json'}, method='POST')
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
status, raw = response.status, response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
status, raw = error.code, error.read()
|
||||
except (OSError, urllib.error.URLError, ValueError):
|
||||
raise BuildConfigError('transport/timeout') from None
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
except (ValueError, UnicodeError):
|
||||
raise BuildConfigError('invalid JSON response', status) from None
|
||||
if not isinstance(body, dict):
|
||||
raise BuildConfigError('invalid response object', status)
|
||||
code = body.get('code')
|
||||
# Only numeric codes are retained; arbitrary server strings may echo secrets.
|
||||
code = code if type(code) is int else None
|
||||
if code in (40100, 40101):
|
||||
raise BuildConfigError({40100: '40100 NOT_LOGIN_ERROR', 40101: '40101 NO_AUTH_ERROR'}[code], status, code)
|
||||
if not 200 <= status < 300 or code != 0:
|
||||
raise BuildConfigError('other HTTP/platform code', status, code)
|
||||
data = body.get('data')
|
||||
if not isinstance(data, str) or not data.strip():
|
||||
raise BuildConfigError('empty/non-str data', status, code)
|
||||
if token in data:
|
||||
raise BuildConfigError('credential echoed in data', status, code)
|
||||
return data
|
||||
31
src/config_params.py
Normal file
31
src/config_params.py
Normal file
@@ -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()
|
||||
30
src/gate.py
30
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,
|
||||
|
||||
106
src/pending_pool.py
Normal file
106
src/pending_pool.py
Normal file
@@ -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')
|
||||
22
src/queue_depth.py
Normal file
22
src/queue_depth.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Read queue hints without contacting the platform; unknown is conservative."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def queue_depth():
|
||||
value = os.environ.get('ASYNC_QUEUE_DEPTH')
|
||||
if value is None and os.environ.get('QUEUE_SNAPSHOT_JSON'):
|
||||
try:
|
||||
data = json.loads(Path(os.environ['QUEUE_SNAPSHOT_JSON']).read_text())
|
||||
if isinstance(data, dict):
|
||||
value = next((data[k] for k in ('depth', 'queue_depth', 'async_queue_depth', 'ASYNC_QUEUE_DEPTH') if k in data), None)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
try:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
depth = int(str(value))
|
||||
return depth if depth >= 0 else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
44
src/readonly_probe.py
Normal file
44
src/readonly_probe.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Explicit read-only auth probe. Import performs no I/O; never calls task/add."""
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from src.task_add_client import resolve_token
|
||||
|
||||
|
||||
def probe_readonly():
|
||||
try:
|
||||
token = resolve_token()
|
||||
except (ValueError, OSError):
|
||||
return dict(http_status=None, platform_code=None, auth_ok=False,
|
||||
record_summary={'count': 0}, error_attribution='missing/invalid credential')
|
||||
request = urllib.request.Request(
|
||||
os.environ.get('MODELHUB_API_BASE', 'https://modelhub.org.cn').rstrip('/')
|
||||
+ '/api/adapt/task/page?onlyMine=true&pageSize=5¤t=1',
|
||||
headers={'Xc-Token': token, 'Accept': 'application/json'}, method='GET')
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
status, raw = response.status, response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
status, raw = error.code, error.read()
|
||||
except (OSError, urllib.error.URLError, ValueError):
|
||||
return dict(http_status=None, platform_code=None, auth_ok=False,
|
||||
record_summary={'count': 0}, error_attribution='transport/timeout')
|
||||
# Never expose response strings: servers can echo credentials in any field.
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
except (ValueError, UnicodeError):
|
||||
body = {}
|
||||
body = body if isinstance(body, dict) else {}
|
||||
code = body.get('code')
|
||||
code = code if type(code) is int else int(code) if isinstance(code, str) and code in ('0', '40100', '40101') else None
|
||||
ok = 200 <= status < 300 and code == 0
|
||||
data = body.get('data')
|
||||
records = data.get('records') if isinstance(data, dict) else None
|
||||
summary = {'count': len(records) if isinstance(records, list) else 0}
|
||||
if isinstance(data, dict) and type(data.get('total')) is int:
|
||||
summary['total'] = data['total']
|
||||
attribution = {40100: '40100 NOT_LOGIN_ERROR', 40101: '40101 NO_AUTH_ERROR'}.get(
|
||||
code, 'success' if ok else 'other HTTP/platform/invalid response')
|
||||
return dict(http_status=status, platform_code=code, auth_ok=ok,
|
||||
record_summary=summary, error_attribution=attribution)
|
||||
58
src/task_add_client.py
Normal file
58
src/task_add_client.py
Normal 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)
|
||||
44
templates/config_params.sample.yaml
Normal file
44
templates/config_params.sample.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
framework: vllm
|
||||
api: completion
|
||||
lang: en
|
||||
max_model_len: 4096
|
||||
max_tokens: 1024
|
||||
temperature: 0.7
|
||||
repetition_penalty: 1.1
|
||||
top_p: 0.9
|
||||
sut_config:
|
||||
gpu_num: 1
|
||||
values:
|
||||
command:
|
||||
- /opt/conda/bin/vllm
|
||||
- serve
|
||||
- /model
|
||||
- --port
|
||||
- '20644'
|
||||
- --served-model-name
|
||||
- llm
|
||||
- --max-model-len
|
||||
- '4096'
|
||||
- --gpu-memory-utilization
|
||||
- '0.9'
|
||||
- -tp
|
||||
- '1'
|
||||
- --enforce-eager
|
||||
- --trust-remote-code
|
||||
ref_config:
|
||||
gpu_num: 1
|
||||
values:
|
||||
command:
|
||||
- vllm
|
||||
- serve
|
||||
- /model
|
||||
- --port
|
||||
- '80'
|
||||
- --served-model-name
|
||||
- llm
|
||||
- --max-model-len
|
||||
- '4096'
|
||||
- -tp
|
||||
- '1'
|
||||
- --enforce-eager
|
||||
- --trust-remote-code
|
||||
73
tests/test_build_config_client.py
Normal file
73
tests/test_build_config_client.py
Normal file
@@ -0,0 +1,73 @@
|
||||
import runpy
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
from src.build_config_client import build_config, BuildConfigError
|
||||
from scripts.build_config_dry import main
|
||||
|
||||
|
||||
class TestBuildConfigClient(unittest.TestCase):
|
||||
def response(self, body, status=200):
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.status = status
|
||||
response.read.return_value = json.dumps(body).encode()
|
||||
return response
|
||||
|
||||
def test_success_request_and_auth(self):
|
||||
for authorization in ('0', '1'):
|
||||
with patch.dict(os.environ, {'XC_TOKEN': 'test-secret', 'BUILD_CONFIG_USE_AUTHORIZATION': authorization}, clear=True), patch('urllib.request.urlopen', return_value=self.response({'code': 0, 'data': 'framework: vllm\n'})) as call:
|
||||
self.assertEqual(build_config('Sunrise_pt-200-x1'), 'framework: vllm\n')
|
||||
request = call.call_args.args[0]
|
||||
self.assertEqual(request.get_method(), 'POST')
|
||||
self.assertIsNone(request.data)
|
||||
parsed = urlparse(request.full_url)
|
||||
self.assertEqual(parsed.path, '/api/adapt/task/build-config')
|
||||
self.assertNotIn('/task/add', request.full_url)
|
||||
self.assertEqual(parse_qs(parsed.query), {'gpuType': ['Sunrise_pt-200-x1'], 'framework': ['vllm'], 'taskType': ['text-generation']})
|
||||
self.assertEqual(request.get_header('Authorization' if authorization == '1' else 'Xc-token'), 'test-secret')
|
||||
self.assertIsNone(request.get_header('Xc-token' if authorization == '1' else 'Authorization'))
|
||||
|
||||
def test_missing_invalid_no_network(self):
|
||||
for env in ({}, {'XC_TOKEN': 'placeholder'}, {'XC_TOKEN_FILE': '/nonexistent/token'}):
|
||||
with patch.dict(os.environ, env, clear=True), patch('urllib.request.urlopen') as call:
|
||||
with self.assertRaisesRegex(BuildConfigError, 'missing/invalid credential'):
|
||||
build_config('gpu')
|
||||
with patch('sys.argv', ['build_config_dry']), patch('sys.stderr', io.StringIO()):
|
||||
self.assertEqual(main(), 1)
|
||||
call.assert_not_called()
|
||||
|
||||
def test_platform_and_data_errors(self):
|
||||
for body, reason in [({'code': 40100}, '40100'), ({'code': 40101}, '40101'), ({'code': 9}, 'other HTTP/platform'), ({'code': 0, 'data': ''}, 'empty/non-str'), ({'code': 0, 'data': []}, 'empty/non-str'), ({'code': 0, 'data': 'test-secret'}, 'credential echoed')]:
|
||||
with patch.dict(os.environ, {'XC_TOKEN': 'test-secret'}, clear=True), patch('urllib.request.urlopen', return_value=self.response(body)):
|
||||
with self.assertRaisesRegex(BuildConfigError, reason):
|
||||
build_config('gpu')
|
||||
|
||||
def test_http_transport_invalid_response(self):
|
||||
for code in (40100, 40101):
|
||||
error = urllib.error.HTTPError('https://example.test', 403, '', {}, io.BytesIO(json.dumps({'code': code}).encode()))
|
||||
with patch.dict(os.environ, {'XC_TOKEN': 'test-secret'}, clear=True), patch('urllib.request.urlopen', side_effect=error):
|
||||
with self.assertRaisesRegex(BuildConfigError, str(code)):
|
||||
build_config('gpu')
|
||||
with patch.dict(os.environ, {'XC_TOKEN': 'test-secret'}, clear=True):
|
||||
for error in (TimeoutError('secret'), urllib.error.URLError('secret')):
|
||||
with patch('urllib.request.urlopen', side_effect=error), self.assertRaisesRegex(BuildConfigError, 'transport/timeout'):
|
||||
build_config('gpu')
|
||||
for response in (self.response([], 200), self.response({'code': 0, 'data': 'yaml'}, 500)):
|
||||
with patch('urllib.request.urlopen', return_value=response), self.assertRaises(BuildConfigError):
|
||||
build_config('gpu')
|
||||
response = self.response({})
|
||||
response.read.return_value = b'not-json'
|
||||
with patch('urllib.request.urlopen', return_value=response), self.assertRaisesRegex(BuildConfigError, 'invalid JSON'):
|
||||
build_config('gpu')
|
||||
|
||||
def test_import_no_io(self):
|
||||
import src.build_config_client
|
||||
with patch('urllib.request.urlopen') as call, patch('pathlib.Path.read_text') as read:
|
||||
runpy.run_path('src/build_config_client.py')
|
||||
call.assert_not_called()
|
||||
read.assert_not_called()
|
||||
47
tests/test_config_params.py
Normal file
47
tests/test_config_params.py
Normal file
@@ -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)
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
49
tests/test_pending_pool.py
Normal file
49
tests/test_pending_pool.py
Normal file
@@ -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')
|
||||
61
tests/test_readonly_probe.py
Normal file
61
tests/test_readonly_probe.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
import urllib.error
|
||||
from unittest.mock import patch, MagicMock
|
||||
from src.readonly_probe import probe_readonly
|
||||
from scripts.probe_readonly import main
|
||||
|
||||
|
||||
class TestReadonlyProbe(unittest.TestCase):
|
||||
def test_missing_token_no_network(self):
|
||||
with patch.dict(os.environ, {}, clear=True), patch('urllib.request.urlopen') as call:
|
||||
self.assertFalse(probe_readonly()['auth_ok'])
|
||||
with patch('sys.stdout', io.StringIO()):
|
||||
self.assertEqual(main(), 1)
|
||||
call.assert_not_called()
|
||||
|
||||
def test_success_and_exact_request(self):
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.status = 200
|
||||
response.read.return_value = json.dumps({'code': 0, 'data': {
|
||||
'records': [{'modelId': 'secret-value'}], 'total': 10}}).encode()
|
||||
with patch.dict(os.environ, {'EXTERNAL_SERVICE_TOKEN': 'secret-value'}, clear=True), patch('urllib.request.urlopen', return_value=response) as call:
|
||||
result = probe_readonly()
|
||||
request = call.call_args.args[0]
|
||||
self.assertEqual(request.get_method(), 'GET')
|
||||
self.assertEqual(request.full_url, 'https://modelhub.org.cn/api/adapt/task/page?onlyMine=true&pageSize=5¤t=1')
|
||||
self.assertEqual(request.get_header('Xc-token'), 'secret-value')
|
||||
self.assertTrue(result['auth_ok'])
|
||||
self.assertEqual(result['record_summary'], {'count': 1, 'total': 10})
|
||||
self.assertNotIn('secret-value', json.dumps(result))
|
||||
|
||||
def test_errors_no_fallback_or_echo(self):
|
||||
for code in (40100, 40101, 50000):
|
||||
error = urllib.error.HTTPError('https://example.test', 403, '', {}, io.BytesIO(json.dumps({'code': code, 'message': 'secret-value'}).encode()))
|
||||
with patch.dict(os.environ, {'XC_TOKEN': 'secret-value'}, clear=True), patch('urllib.request.urlopen', side_effect=error) as call:
|
||||
result = probe_readonly()
|
||||
self.assertEqual(call.call_count, 1)
|
||||
self.assertFalse(result['auth_ok'])
|
||||
self.assertEqual(result['platform_code'], code)
|
||||
self.assertIn(str(code) if code != 50000 else 'other', result['error_attribution'])
|
||||
self.assertNotIn('secret-value', json.dumps(result))
|
||||
|
||||
def test_transport_and_invalid_json(self):
|
||||
for error in (OSError('secret-value'), urllib.error.URLError('secret-value')):
|
||||
with patch.dict(os.environ, {'XC_TOKEN': 'secret-value'}, clear=True), patch('urllib.request.urlopen', side_effect=error):
|
||||
self.assertEqual(probe_readonly()['error_attribution'], 'transport/timeout')
|
||||
response = MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.status, response.read.return_value = 200, b'invalid secret-value'
|
||||
with patch.dict(os.environ, {'XC_TOKEN': 'secret-value'}, clear=True), patch('urllib.request.urlopen', return_value=response):
|
||||
self.assertFalse(probe_readonly()['auth_ok'])
|
||||
|
||||
def test_import_no_network(self):
|
||||
import importlib
|
||||
import src.readonly_probe
|
||||
with patch('urllib.request.urlopen') as call:
|
||||
importlib.reload(src.readonly_probe)
|
||||
call.assert_not_called()
|
||||
@@ -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):
|
||||
|
||||
69
tests/test_task_add_client.py
Normal file
69
tests/test_task_add_client.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import importlib
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
from src import task_add_client as module
|
||||
|
||||
|
||||
class TestTaskAddClient(unittest.TestCase):
|
||||
def setUp(self):
|
||||
patch = mock.patch.dict(os.environ, {'STRATEGY_ID': 'sid', 'XC_TOKEN': 'test-secret-credential'}, clear=True)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
self.payload = module.build_payload('org/model', '壁砺', 'vllm', config_params={ 'max_model_len': 4096 })
|
||||
|
||||
def test_import_no_network(self):
|
||||
with mock.patch('urllib.request.urlopen') as urlopen:
|
||||
importlib.reload(module)
|
||||
urlopen.assert_not_called()
|
||||
|
||||
def test_success(self):
|
||||
response = mock.MagicMock()
|
||||
response.__enter__.return_value = response
|
||||
response.status = 200
|
||||
response.read.return_value = b'{"code":0,"message":"test-secret-credential"}'
|
||||
with mock.patch('urllib.request.urlopen', return_value=response) as urlopen:
|
||||
result = module.TaskAddClient().submit(self.payload)
|
||||
self.assertEqual(result['http_status'], 200)
|
||||
self.assertNotIn(os.environ['XC_TOKEN'], str(result))
|
||||
request = urlopen.call_args.args[0]
|
||||
self.assertEqual(request.get_header('Xc-token'), os.environ['XC_TOKEN'])
|
||||
self.assertEqual(request.method, 'POST')
|
||||
urlopen.assert_called_once()
|
||||
|
||||
def test_http_4xx_60014(self):
|
||||
error = urllib.error.HTTPError('https://example.invalid', 403, 'forbidden', {}, io.BytesIO(b'{"code":60014}'))
|
||||
with mock.patch('urllib.request.urlopen', side_effect=error) as urlopen:
|
||||
result = module.TaskAddClient().submit(self.payload)
|
||||
self.assertEqual(result['http_status'], 403)
|
||||
self.assertEqual(result['platform_code'], 60014)
|
||||
self.assertIn('D (unverified)', result['error_attribution'])
|
||||
urlopen.assert_called_once()
|
||||
|
||||
def test_transport_no_retry(self):
|
||||
with mock.patch('urllib.request.urlopen', side_effect=TimeoutError('test-secret-credential')) as urlopen:
|
||||
result = module.TaskAddClient().submit(self.payload)
|
||||
self.assertNotIn(os.environ['XC_TOKEN'], str(result))
|
||||
self.assertEqual(result['error_attribution'], 'transport/timeout')
|
||||
urlopen.assert_called_once()
|
||||
|
||||
def test_token_precedence_and_rejections(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory)/'credential'
|
||||
path.write_text(' file-credential \n')
|
||||
with mock.patch.dict(os.environ, {'XC_TOKEN_FILE': str(path)}):
|
||||
self.assertEqual(module.resolve_token(), 'file-credential')
|
||||
for token in ('', 'tmp', 'PLACEHOLDER', 'changeme', 'tmp-token'):
|
||||
with self.subTest(token=token), mock.patch.dict(os.environ, {'XC_TOKEN': token}):
|
||||
with self.assertRaises(ValueError):
|
||||
module.resolve_token()
|
||||
|
||||
def test_placeholder_never_requests(self):
|
||||
with mock.patch('urllib.request.urlopen') as urlopen:
|
||||
with self.assertRaises(ValueError):
|
||||
module.TaskAddClient().submit(module.build_payload('org/model'))
|
||||
urlopen.assert_not_called()
|
||||
Reference in New Issue
Block a user