"""信创模盒(ModelHub XC)适配智能体。 平台契约(见 https://modelhub.org.cn/#/docs/6): - 仓库根目录必须有 Dockerfile,且 EXPOSE 一个端口用于健康检查 - 必须提供 GET /health,返回 200 表示存活 - 通过环境变量 STRATEGY_ID 获取自身策略 ID - 必须正确处理 SIGTERM,30s 内完成退出 - 资源:requests 100m/256Mi,limits 1C/512Mi 业务逻辑: 1. 拉取社区模型库,筛出「文本生成 + 仓库完整 + 未在该卡适配过」的模型; 2. 按卡依次调用 /api/adapt/task/add 提交验证任务; 3. 命中 60027(该卡已验证成功)/ 60028(该卡验证中)就换下一个组合; 4. 直到提交满 TARGET_TASKS 条或模型池耗尽。 """ from __future__ import annotations import json import os import signal import sys import threading import time import urllib.error import urllib.parse import urllib.request from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from cards import CARDS # ---------------------------------------------------------------- 配置 API_BASE = os.getenv("MODELHUB_API_BASE", "https://www.modelhub.org.cn").rstrip("/") STRATEGY_ID = os.getenv("STRATEGY_ID", "") TOKEN_ENV_KEYS = ("XC_TOKEN", "EXTERNAL_SERVICE_TOKEN", "MODELHUB_TOKEN", "SERVICE_TOKEN", "XC_SERVICE_TOKEN", "API_TOKEN", "AGENT_TOKEN") TOKEN_FILE_CANDIDATES = ("/etc/xc/token", "/etc/modelhub/token", "/var/run/secrets/xc/token", "/var/run/secrets/modelhub/token", "/token", "/app/token") def _read_token() -> str: for key in TOKEN_ENV_KEYS: val = os.getenv(key, "").strip() if val: return val for path in TOKEN_FILE_CANDIDATES: try: with open(path, "r", encoding="utf-8") as fh: val = fh.read().strip() if val: return val except Exception: continue return "" XC_TOKEN = _read_token() # 平台在 pod 内可能用不同方式鉴权:依次尝试 Xc-Token → Authorization: Bearer → 无鉴权 AUTH_MODES = ("xctoken", "bearer", "none") AUTH_MODE = None # 运行期探测到的可用方式 TARGET_TASKS = int(os.getenv("TARGET_TASKS", "250")) QUOTA_PER_MODEL = int(os.getenv("QUOTA_PER_MODEL", "2")) INTERVAL_SEC = float(os.getenv("INTERVAL_SEC", "1.5")) MAX_ATTEMPTS = int(os.getenv("MAX_ATTEMPTS", "4000")) DRY_RUN = os.getenv("DRY_RUN", "").strip().lower() in ("1", "true", "yes", "on") STATE_FILE = os.getenv("STATE_FILE", "/tmp/modelhub_agent_state.json") POOL_FILE = os.getenv("POOL_FILE", "/tmp/modelhub_agent_pool.json") PAGE_SIZE = int(os.getenv("PAGE_SIZE", "50")) SEED_QUERIES = [q for q in os.getenv( "SEED_QUERIES", "Qwen2.5-0.5B,Qwen2.5-1.5B,Qwen2.5-3B,Qwen2.5-7B,Qwen3-0.6B,Qwen3-1.7B,Qwen3-4B,Qwen3-8B," "Llama-3.2,Llama-3.1,InternLM2.5,MiniCPM,Phi-3,Phi-4,gemma-2,Mistral-7B,Yi-1.5,GLM-4," "ChatGLM3,DeepSeek-R1-Distill,Qwen2-1.5B,Qwen2-7B", ).split(",") if q.strip()] # 明显不能直接跑文本生成验证的仓库(统一转小写后匹配) BAD_TOKENS = ("gensyn", "gguf", "awq", "gptq", "fp8", "fp4", "nvfp4", "adapter", "lora", "int4", "int8", "w4a16", "w8a8", "w8a16", "quantized", "bnb", "exl2", "hqq", "mlx", "onnx", "openvino", "tflite") MIN_MACHINES = int(os.getenv("MIN_MACHINES", "2")) # 至少在一张卡上适配成功过,说明仓库完整 # ---------------------------------------------------------------- 全局状态 STOP = threading.Event() LOCK = threading.Lock() STATE = { "started_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "submitted": [], # [{model, card, taskId, at}] "attempts": 0, "codes": {}, # code -> count "last_event": None, "finished": False, "error": None, } def log(msg: str) -> None: print(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}", flush=True) def _save_state() -> None: try: tmp = STATE_FILE + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(STATE, f, ensure_ascii=False, indent=1) os.replace(tmp, STATE_FILE) except Exception as exc: # 状态落盘失败不影响主流程 log(f"save state failed: {exc}") def _load_state() -> None: try: with open(STATE_FILE, "r", encoding="utf-8") as f: saved = json.load(f) STATE["submitted"] = saved.get("submitted", []) log(f"resume: {len(STATE['submitted'])} submitted") except FileNotFoundError: pass except Exception as exc: log(f"load state failed: {exc}") # ---------------------------------------------------------------- HTTP def _auth_header(mode: str) -> dict: if not XC_TOKEN or mode == "none": return {} if mode == "bearer": return {"Authorization": "Bearer " + XC_TOKEN} return {"Xc-Token": XC_TOKEN} def _request(method: str, path: str, body: dict | None = None, timeout: int = 30, auth_mode: str | None = None) -> tuple[int, str]: url = API_BASE + path data = None headers = {"Accept": "application/json", "User-Agent": "modelhub-xc-agent/1.0"} if body is not None: data = json.dumps(body, ensure_ascii=False).encode("utf-8") headers["Content-Type"] = "application/json" headers.update(_auth_header(auth_mode or AUTH_MODE or "xctoken")) req = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=timeout) as resp: return resp.status, resp.read().decode("utf-8", "replace") except urllib.error.HTTPError as exc: return exc.code, exc.read().decode("utf-8", "replace") except Exception as exc: return 0, str(exc) def _get_json(path: str, timeout: int = 30, auth_mode: str | None = None) -> dict: status, text = _request("GET", path, timeout=timeout, auth_mode=auth_mode) try: return json.loads(text) except Exception: return {"code": -1, "message": f"HTTP {status}: {text[:200]}"} def _post_json(path: str, body: dict, timeout: int = 60, auth_mode: str | None = None) -> dict: status, text = _request("POST", path, body=body, timeout=timeout, auth_mode=auth_mode) try: return json.loads(text) except Exception: return {"code": -1, "message": f"HTTP {status}: {text[:200]}"} # ---------------------------------------------------------------- 模型池 def build_pool() -> list[dict]: """拉模型库,返回 [{id, machines}],按「已适配卡数」升序(可选卡更多)。""" seen: dict[str, dict] = {} for query in SEED_QUERIES: for page in range(1, 6): payload = {"pageNum": page, "pageSize": PAGE_SIZE, "searchText": query} res = _post_json("/api/computility/models/list/page/vo", payload, timeout=40) records = (res.get("data") or {}).get("records") or [] if not records: break for item in records: model_id = item.get("modelId") or "" machines = [m.get("machineName") for m in (item.get("machines") or [])] level = (item.get("taskLevelsInfo") or {}).get("taskLevelChineseName") if not model_id or not machines or level != "文本生成": continue lower = model_id.lower() if any(tok in lower for tok in BAD_TOKENS): continue if len(machines) < MIN_MACHINES: continue if model_id in seen: continue seen[model_id] = {"id": model_id, "machines": machines} if len(records) < PAGE_SIZE: break if STOP.is_set(): break pool = sorted(seen.values(), key=lambda x: len(x["machines"])) log(f"pool built: {len(pool)} models") try: with open(POOL_FILE, "w", encoding="utf-8") as f: json.dump(pool, f) except Exception: pass return pool def load_pool() -> list[dict]: try: with open(POOL_FILE, "r", encoding="utf-8") as f: pool = json.load(f) if pool: log(f"pool loaded from cache: {len(pool)}") return pool except Exception: pass return build_pool() # ---------------------------------------------------------------- 提交 def submit(model: str, card: dict, auth_mode: str | None = None) -> dict: body = { "modelAddress": f"https://huggingface.co/{model}", "taskType": "text-generation", "targetGpu": card["gpu"], "framework": card["framework"], "strategyId": STRATEGY_ID, "configParams": card["configParams"], } if DRY_RUN: return {"code": 0, "data": {"id": "dry-run"}, "dryRun": True} return _post_json("/api/adapt/task/add", body, auth_mode=auth_mode) def worker() -> None: _load_state() if not STRATEGY_ID: STATE["error"] = "STRATEGY_ID 未注入,智能体无法提交(平台运行时自动注入)" log(STATE["error"]) return global AUTH_MODE pool = load_pool() submitted_pairs = {(s["model"], s["card"]) for s in STATE["submitted"]} per_model: dict[str, int] = {} for s in STATE["submitted"]: per_model[s["model"]] = per_model.get(s["model"], 0) + 1 while not STOP.is_set() and len(STATE["submitted"]) < TARGET_TASKS: progress = False for card in CARDS: if STOP.is_set() or len(STATE["submitted"]) >= TARGET_TASKS: break for cand in pool: if STOP.is_set() or len(STATE["submitted"]) >= TARGET_TASKS: break if STATE["attempts"] >= MAX_ATTEMPTS: log("reach MAX_ATTEMPTS, stop") return if card["gpu"] in cand["machines"]: continue if per_model.get(cand["id"], 0) >= QUOTA_PER_MODEL: continue pair = (cand["id"], card["gpu"]) if pair in submitted_pairs: continue res = submit(cand["id"], card) code = res.get("code") # 401 时换一种鉴权方式重试(pod 内可能用 Bearer 或免鉴权) if code == 401: for mode in AUTH_MODES: if mode == (AUTH_MODE or "xctoken"): continue res = submit(cand["id"], card, auth_mode=mode) code = res.get("code") if code != 401: AUTH_MODE = mode log(f"auth mode ok: {mode}") break if code == 401: STATE["error"] = "401: 三种鉴权方式均被拒绝" log(STATE["error"]) _save_state() STOP.wait(300) STATE["attempts"] += 1 STATE["codes"][str(code)] = STATE["codes"].get(str(code), 0) + 1 STATE["last_event"] = { "at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "model": cand["id"], "gpu": card["gpu"], "code": code, "message": (res.get("message") or "")[:160], } progress = True if code == 0: task_id = (res.get("data") or {}).get("id") with LOCK: STATE["submitted"].append({ "model": cand["id"], "card": card["gpu"], "taskId": task_id, "at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), }) per_model[cand["id"]] = per_model.get(cand["id"], 0) + 1 submitted_pairs.add(pair) log(f"OK {len(STATE['submitted'])}/{TARGET_TASKS} | {card['label']} | {cand['id']} | {task_id}") _save_state() elif code == 60027: # 该卡已经验证成功过 submitted_pairs.add(pair) elif code == 60028: # 该组合正在验证中 submitted_pairs.add(pair) elif code == 50000: # 拉不到上游模型信息 submitted_pairs.add(pair) elif code in (401, 403, 60014): log(f"fatal {code}: {res.get('message')}") STATE["error"] = f"{code}: {res.get('message')}" _save_state() STOP.wait(120) elif code in (-1, 0) and res.get("message", "").startswith("HTTP"): log(f"network issue: {res.get('message')}") STOP.wait(10) time.sleep(INTERVAL_SEC) if not progress: log("no progress this round; rebuild pool") pool = build_pool() if not pool: log("empty pool, exit") break time.sleep(30) STATE["finished"] = True _save_state() log(f"worker done: submitted={len(STATE['submitted'])} attempts={STATE['attempts']}") # ---------------------------------------------------------------- HTTP 服务 class Handler(BaseHTTPRequestHandler): server_version = "modelhub-xc-agent/1.0" def do_GET(self) -> None: # noqa: N802 path = urllib.parse.urlparse(self.path).path if path == "/health": self._send({"status": "ok"}) return if path == "/stats": with LOCK: payload = json.loads(json.dumps(STATE, ensure_ascii=False)) payload["target"] = TARGET_TASKS payload["progress"] = f"{len(payload['submitted'])}/{TARGET_TASKS}" payload["strategyId"] = STRATEGY_ID or None payload["dryRun"] = DRY_RUN self._send(payload) return if path == "/": self._send({ "name": "modelhub-xc-adapt-agent", "status": "stopped" if STOP.is_set() else "running", "strategyId": STRATEGY_ID or None, "target": TARGET_TASKS, "submitted": len(STATE["submitted"]), "attempts": STATE["attempts"], "dryRun": DRY_RUN, }) return self._send({"error": "not found"}, status=404) def log_message(self, fmt: str, *args) -> None: print(f"{self.address_string()} - {fmt % args}", flush=True) def _send(self, body: dict, status: int = 200) -> None: payload = json.dumps(body, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def _handle_signal(signum: int, _frame) -> None: log(f"received signal {signum}, shutting down") STOP.set() def _diagnose() -> None: """打印运行环境的令牌线索(脱敏),便于排查 401。""" log("---- env diagnose ----") for key in sorted(os.environ): if any(k in key.upper() for k in ("TOKEN", "STRATEGY", "XC", "SECRET", "KEY", "PASS", "API")): val = os.environ[key] log(f"env {key} = {val[:6]}...(len={len(val)})") log(f"resolved token: {'yes len=' + str(len(XC_TOKEN)) if XC_TOKEN else 'NO'}") for path in TOKEN_FILE_CANDIDATES: log(f"file {path} exists={os.path.exists(path)}") for root in ("/var/run/secrets", "/etc/xc", "/etc/modelhub"): try: log(f"dir {root}: {os.listdir(root)[:10]}") except Exception as exc: log(f"dir {root}: {type(exc).__name__}") log("---- env diagnose end ----") def main() -> None: signal.signal(signal.SIGTERM, _handle_signal) _diagnose() signal.signal(signal.SIGINT, _handle_signal) thread = threading.Thread(target=worker, name="submit-worker", daemon=True) thread.start() server = ThreadingHTTPServer(("0.0.0.0", int(os.getenv("PORT", "8080"))), Handler) server.timeout = 1 log(f"agent listening on 0.0.0.0:{os.getenv('PORT', '8080')} (dryRun={DRY_RUN}, target={TARGET_TASKS})") while not STOP.is_set(): server.handle_request() thread.join(timeout=25) server.server_close() _save_state() log("bye") sys.exit(0) if __name__ == "__main__": main()