add automatic model task discovery and verification

This commit is contained in:
2026-08-26 21:18:27 +08:00
parent acd4866764
commit 50d9c42e02
3 changed files with 473 additions and 13 deletions

348
main.py
View File

@@ -1,6 +1,7 @@
"""Card-specific ModelHub XC deployment preflight agent.
This service is read-only and only makes claims supported by submitted facts.
It scans public candidates and only writes a deduplicated task when private runtime
credentials and verified platform configuration are present.
"""
from __future__ import annotations
@@ -10,23 +11,279 @@ import os
import re
import signal
import threading
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any
from urllib.parse import quote, urlencode, urlsplit
from urllib.request import ProxyHandler, Request, build_opener
AGENT_NAME = "xc-tianga100-advisor-agent"
AGENT_VERSION = "1.0.0"
AGENT_VERSION = "1.2.0"
CARD_VENDOR = "天数智芯"
CARD_MODEL = "天垓100"
DEFAULT_TARGET_GPU = "Iluvatar_bi-100"
PORT = int(os.getenv("PORT", "8080"))
STRATEGY_ID = os.getenv("STRATEGY_ID", "")
XC_TOKEN = os.getenv("MODELHUB_XC_TOKEN", "")
TARGET_GPU = os.getenv("MODELHUB_TARGET_GPU", DEFAULT_TARGET_GPU)
AUTO_SCAN_ENABLED = os.getenv("AUTO_SCAN_ENABLED", "true").lower() not in {
"0", "false", "no", "off"
}
AUTO_SUBMIT_ENABLED = os.getenv("AUTO_SUBMIT_ENABLED", "true").lower() not in {
"0", "false", "no", "off"
}
SCAN_INTERVAL_SECONDS = max(int(os.getenv("SCAN_INTERVAL_SECONDS", "3600")), 60)
CANDIDATE_ENDPOINT = "https://modelhub.org.cn/api/computility/models/top/models"
TASK_PAGE_ENDPOINT = "https://modelhub.org.cn/api/adapt/task/page"
TASK_ADD_ENDPOINT = "https://modelhub.org.cn/api/adapt/task/add"
DEFAULT_CONFIG_PARAMS = "framework: vllm\napi: completion\nlang: zh\nmax_model_len: 2048\nmax_tokens: 256\ntemperature: 0.1\nrepetition_penalty: 1.0\ntop_p: 0.9\nsut_config:\n gpu_num: 1\n values:\n command:\n - python3\n - -m\n - vllm.entrypoints.openai.api_server\n - --host\n - 0.0.0.0\n - --port\n - '20644'\n - --served-model-name\n - llm\n - --model\n - /model\n - --max-model-len\n - '2048'\n - --tensor-parallel-size\n - '1'\n - --max-num-seqs\n - '8'\n - --enforce-eager\n - --disable-log-requests\n - --enable-prefix-caching\n - --trust-remote-code\nref_config:\n gpu_num: 1\n values:\n command:\n - vllm\n - serve\n - /model\n - --port\n - '80'\n - --served-model-name\n - llm\n - --max-model-len\n - '2048'\n - -tp\n - '1'\n - --enforce-eager\n - --trust-remote-code\n"
CONFIG_PARAMS = os.getenv("MODELHUB_CONFIG_PARAMS", DEFAULT_CONFIG_PARAMS)
MAX_BODY_BYTES = 1_000_000
SECRET_ASSIGNMENT_RE = re.compile(
r"(?i)(token|secret|password|api[_-]?key)=([^&\s]+)"
)
HTTP_OPENER = build_opener(ProxyHandler({}))
SCANNER_LOCK = threading.Lock()
SCANNER_STATE: dict[str, Any] = {
"enabled": AUTO_SCAN_ENABLED,
"running": False,
"last_scan_at": None,
"last_candidate": None,
"last_action": "not_started",
"last_error_type": None,
}
class ValidationError(ValueError):
"""Raised when a request field is invalid."""
def _safe_model_name(value: Any) -> str:
"""Return a bounded model identifier without URL credentials or query data."""
text = str(value or "未指定模型").strip()
if "://" in text:
try:
parsed = urlsplit(text)
text = f"{parsed.scheme}://{parsed.hostname or ''}{parsed.path}"
except ValueError:
text = "无效模型地址"
text = SECRET_ASSIGNMENT_RE.sub(r"\1=[REDACTED]", text)
return text[:200] or "未指定模型"
def _log_event(event: str, **fields: Any) -> None:
"""Write one structured, unbuffered log record to stdout."""
record = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"event": event,
"agent": AGENT_NAME,
"version": AGENT_VERSION,
**fields,
}
print(json.dumps(record, ensure_ascii=False, separators=(",", ":")), flush=True)
def _scanner_state_update(**fields: Any) -> None:
with SCANNER_LOCK:
SCANNER_STATE.update(fields)
def scanner_snapshot() -> dict[str, Any]:
with SCANNER_LOCK:
return dict(SCANNER_STATE)
def _http_json(
method: str,
url: str,
*,
payload: dict[str, Any] | None = None,
token: str = "",
) -> dict[str, Any]:
"""Call one ModelHub JSON endpoint without logging headers or response bodies."""
body = None
headers = {"Accept": "application/json"}
if payload is not None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
headers["Content-Type"] = "application/json"
if token:
headers["Xc-Token"] = token
request = Request(url, data=body, headers=headers, method=method)
with HTTP_OPENER.open(request, timeout=15) as response:
result = json.loads(response.read().decode("utf-8"))
if not isinstance(result, dict):
raise RuntimeError("unexpected_response_shape")
return result
def _candidate_records(payload: dict[str, Any]) -> list[dict[str, str]]:
"""Extract text-generation candidates from the public ModelHub response."""
data = payload.get("data")
if not isinstance(data, list):
return []
candidates: list[dict[str, str]] = []
for item in data:
if not isinstance(item, dict):
continue
model_id = str(item.get("modelId") or "").strip()
info = item.get("taskLevelsInfo")
info = info if isinstance(info, dict) else {}
raw_type = str(
info.get("taskLevelName")
or info.get("taskLevelCode")
or info.get("taskLevelEnglishName")
or ""
).lower()
chinese_type = str(info.get("taskLevelChineseName") or "")
if not model_id or not (
"text-generation" in raw_type
or "text_generation" in raw_type
or chinese_type.strip() == "文本生成"
):
continue
candidates.append({"model_id": model_id, "task_type": "text-generation"})
return candidates
def _model_size_score(model_id: str) -> float:
match = re.search(r"(?i)(\d+(?:\.\d+)?)\s*([bm])(?:\b|[_-])", model_id)
if not match:
return 1_000_000.0
size = float(match.group(1))
return size * (1_000 if match.group(2).lower() == "b" else 1)
def select_candidate(payload: dict[str, Any]) -> dict[str, str] | None:
"""Prefer a small official-EngineX text model from the public hot list."""
candidates = _candidate_records(payload)
if not candidates:
return None
def score(item: dict[str, str]) -> tuple[int, float, str]:
model_id = item["model_id"].lower()
supported_family = any(
name in model_id for name in ("qwen3", "llama3", "deepseek-r1-distill")
)
return (0 if supported_family else 1, _model_size_score(model_id), model_id)
return min(candidates, key=score)
def _model_address(model_id: str) -> str:
safe_path = "/".join(quote(part, safe="") for part in model_id.split("/"))
return f"https://www.modelscope.cn/models/{safe_path}"
def scan_once(request_json: Any = None) -> dict[str, Any]:
"""Discover one candidate, deduplicate it, and optionally submit one task."""
call = request_json or _http_json
scan_at = datetime.now(timezone.utc).isoformat()
_scanner_state_update(running=True, last_scan_at=scan_at, last_error_type=None)
_log_event("candidate_scan_started", source="modelhub_hot_models")
candidate_payload = call("GET", CANDIDATE_ENDPOINT)
candidate = select_candidate(candidate_payload)
if candidate is None:
result = {"action": "no_candidate"}
_scanner_state_update(running=False, last_candidate=None, last_action=result["action"])
_log_event("candidate_scan_completed", candidate_count=0, action=result["action"])
return result
model_id = _safe_model_name(candidate["model_id"])
_scanner_state_update(last_candidate=model_id)
_log_event(
"candidate_discovered",
model=model_id,
task_type=candidate["task_type"],
target_gpu_configured=bool(TARGET_GPU),
)
missing = []
if not AUTO_SUBMIT_ENABLED:
missing.append("auto_submit_disabled")
if not XC_TOKEN:
missing.append("xc_token_missing")
if not STRATEGY_ID:
missing.append("strategy_id_missing")
if not TARGET_GPU:
missing.append("target_gpu_missing")
if not CONFIG_PARAMS:
missing.append("task_config_missing")
if missing:
result = {"action": "submission_skipped", "reasons": missing, "model": model_id}
_scanner_state_update(running=False, last_action=result["action"])
_log_event("task_submission_skipped", model=model_id, reasons=missing)
return result
query = urlencode(
{
"current": 1,
"pageSize": 1,
"onlyMine": "true",
"modelId": candidate["model_id"],
"gpuType": TARGET_GPU,
}
)
history = call("GET", f"{TASK_PAGE_ENDPOINT}?{query}", token=XC_TOKEN)
history_data = history.get("data") if isinstance(history, dict) else None
records = history_data.get("records") if isinstance(history_data, dict) else []
if records:
result = {"action": "duplicate_skipped", "model": model_id}
_scanner_state_update(running=False, last_action=result["action"])
_log_event("task_duplicate_skipped", model=model_id, target_gpu=TARGET_GPU)
return result
task_payload = {
"modelAddress": _model_address(candidate["model_id"]),
"taskType": candidate["task_type"],
"targetGpu": TARGET_GPU,
"framework": "vllm",
"strategyId": STRATEGY_ID,
"configParams": CONFIG_PARAMS,
}
response = call("POST", TASK_ADD_ENDPOINT, payload=task_payload, token=XC_TOKEN)
response_data = response.get("data") if isinstance(response, dict) else None
task_id = response_data.get("taskId") if isinstance(response_data, dict) else response_data
result = {"action": "submitted", "model": model_id, "task_id": task_id}
_scanner_state_update(running=False, last_action=result["action"])
_log_event(
"task_submitted",
model=model_id,
target_gpu=TARGET_GPU,
task_id=task_id,
)
return result
def _scanner_loop() -> None:
_log_event(
"scanner_started",
interval_seconds=SCAN_INTERVAL_SECONDS,
auto_submit=AUTO_SUBMIT_ENABLED,
token_present=bool(XC_TOKEN),
target_gpu_configured=bool(TARGET_GPU),
)
while not STOP.is_set():
try:
scan_once()
except Exception as exc:
error_type = type(exc).__name__
_scanner_state_update(
running=False,
last_action="scan_error",
last_error_type=error_type,
)
_log_event("scanner_error", error_type=error_type)
if STOP.wait(SCAN_INTERVAL_SECONDS):
break
def _first(payload: dict[str, Any], *names: str) -> Any:
for name in names:
if name in payload and payload[name] not in (None, ""):
@@ -160,7 +417,7 @@ def analyze(payload: dict[str, Any]) -> dict[str, Any]:
class Handler(BaseHTTPRequestHandler):
server_version = "ModelHubCardAdvisor/1.0"
server_version = "ModelHubCardAdvisor/1.2"
def _json(self, payload: dict[str, Any], status: int = 200) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
@@ -172,7 +429,17 @@ class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract
if self.path == "/health":
self._json({"status": "ok", "agent": AGENT_NAME, "version": AGENT_VERSION})
self._json(
{
"status": "ok",
"agent": AGENT_NAME,
"version": AGENT_VERSION,
"scanner": scanner_snapshot(),
}
)
return
if self.path == "/scanner":
self._json(scanner_snapshot())
return
if self.path == "/":
self._json(
@@ -182,8 +449,20 @@ class Handler(BaseHTTPRequestHandler):
"description": f"{CARD_VENDOR} {CARD_MODEL} 专属部署前预检",
"official_target": {"vendor": CARD_VENDOR, "model": CARD_MODEL},
"strategy_id_present": bool(STRATEGY_ID),
"endpoints": ["GET /health", "POST /analyze", "POST /task"],
"external_writes": False,
"target_gpu": TARGET_GPU or None,
"endpoints": [
"GET /health",
"GET /scanner",
"POST /analyze",
"POST /task",
],
"external_writes_enabled": bool(
AUTO_SUBMIT_ENABLED
and XC_TOKEN
and STRATEGY_ID
and TARGET_GPU
and CONFIG_PARAMS
),
}
)
return
@@ -198,24 +477,52 @@ class Handler(BaseHTTPRequestHandler):
if length <= 0:
raise ValidationError("请求正文不能为空")
if length > MAX_BODY_BYTES:
_log_event("request_rejected", path=self.path, reason="payload_too_large")
self._json({"error": "payload_too_large"}, 413)
return
payload = json.loads(self.rfile.read(length))
self._json(analyze(payload))
model_name = _safe_model_name(
_first(payload, "model_name", "model", "model_address")
if isinstance(payload, dict)
else None
)
_log_event("analysis_started", path=self.path, model=model_name)
result = analyze(payload)
_log_event(
"analysis_completed",
path=self.path,
model=model_name,
verdict=result["verdict"],
target_matches=result["target_matches"],
)
self._json(result)
except (json.JSONDecodeError, UnicodeDecodeError):
_log_event("request_rejected", path=self.path, reason="invalid_json")
self._json({"error": "invalid_json", "message": "请求正文必须是有效 JSON"}, 400)
except ValidationError as exc:
_log_event(
"request_rejected",
path=self.path,
reason="validation_error",
message=str(exc),
)
self._json({"error": "validation_error", "message": str(exc)}, 422)
def log_message(self, _format: str, *_args: Any) -> None:
return
def log_message(self, _format: str, *args: Any) -> None:
status = str(args[1]) if len(args) > 1 else "unknown"
_log_event(
"http_access",
method=self.command,
path=urlsplit(self.path).path,
status=status,
)
STOP = threading.Event()
def _handle_signal(signum: int, _frame: Any) -> None:
print(f"received signal {signum}; shutting down", flush=True)
_log_event("shutdown_requested", signal=signum)
STOP.set()
@@ -224,10 +531,29 @@ def main() -> None:
signal.signal(signal.SIGINT, _handle_signal)
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
server.timeout = 1
print(f"{AGENT_NAME} {AGENT_VERSION} listening on 0.0.0.0:{PORT}", flush=True)
_log_event(
"service_started",
host="0.0.0.0",
port=PORT,
target_vendor=CARD_VENDOR,
target_model=CARD_MODEL,
)
scanner_thread = None
if AUTO_SCAN_ENABLED:
scanner_thread = threading.Thread(
target=_scanner_loop,
name="modelhub-candidate-scanner",
daemon=True,
)
scanner_thread.start()
else:
_scanner_state_update(last_action="disabled")
_log_event("scanner_disabled", reason="auto_scan_disabled")
while not STOP.is_set():
server.handle_request()
server.server_close()
if scanner_thread is not None:
scanner_thread.join(timeout=5)
if __name__ == "__main__":