fix: prevent repeated model GPU submissions
This commit is contained in:
10
README.md
10
README.md
@@ -38,6 +38,7 @@ Optional tuning:
|
||||
- `MODELHUB_AGENT_RESERVATION_TTL_SECONDS` default `120`
|
||||
- `MODELHUB_AGENT_INSTANCE_ID` optional stable worker identity used to spread concurrent agents across accounts and candidates
|
||||
- `MODELHUB_AGENT_CLAIMS_PATH` default `.modelhub_state/submission_claims.jsonl`
|
||||
- `MODELHUB_SUBMISSION_EXCLUSIONS_PATH` default `.modelhub_state/submission_exclusions.jsonl`
|
||||
- `MODELHUB_AGENT_DAILY_TARGET`
|
||||
- `MODELHUB_AGENT_MIN_DOWNLOADS`
|
||||
- `MODELHUB_AGENT_GPUS`
|
||||
@@ -77,6 +78,12 @@ locally failed model/GPU pair cools down for 24 hours instead of being excluded
|
||||
forever. The `[scan]` lines show every expansion stage, while `[daily] wave_done`
|
||||
includes `skip_reasons` so an empty candidate pool is directly diagnosable.
|
||||
|
||||
Community deduplication is scoped to the exact model/GPU combination. A model
|
||||
adapted on one GPU remains eligible for another GPU. Before each submission the
|
||||
worker performs an uncached community check; a lookup failure defers the task
|
||||
instead of failing open. Platform uniqueness rejections are persisted per
|
||||
model/GPU in `.modelhub_state/submission_exclusions.jsonl` and are not retried.
|
||||
|
||||
## Concurrent Agents
|
||||
|
||||
The token pool keeps a local reservation for every in-flight submission, so a
|
||||
@@ -95,7 +102,8 @@ is retained and the runner immediately draws replacement candidates from the
|
||||
same scan instead of retrying the duplicate every cycle. Startup logs and the
|
||||
health response expose `agent_version`; version `2026.08.02.3` or newer includes
|
||||
duplicate replacement behavior, while version `2026.08.02.4` adds adaptive
|
||||
candidate-window expansion and skip-reason reporting.
|
||||
candidate-window expansion and skip-reason reporting. Version `2026.08.02.5`
|
||||
adds fail-closed model/GPU prechecks and persistent uniqueness exclusions.
|
||||
|
||||
## Deploy
|
||||
|
||||
|
||||
@@ -83,6 +83,10 @@ bash run_poll.sh --dry-run
|
||||
7 days, 30 days, and older history (up to 3,000 models) when the recent pool is exhausted.
|
||||
- Model verification responses are cached across poll cycles for 15 minutes. Local model/GPU
|
||||
failures cool down after 24 hours instead of remaining permanently blocked.
|
||||
- Community deduplication is model/GPU-specific: another GPU's adaptation does not block the
|
||||
current GPU. Every actual submission performs a fresh uncached check for its exact GPU.
|
||||
- If the community lookup is unavailable, submission is deferred. A platform model-uniqueness
|
||||
rejection permanently excludes only that model/GPU combination from future local retries.
|
||||
- Each model can be submitted at most once per GPU.
|
||||
- Multiple ModelHub tokens are pooled and used to route submissions to the account with available async capacity.
|
||||
- Concurrent submissions reserve account slots locally, and an account-capacity race automatically falls through to another account.
|
||||
@@ -148,6 +152,7 @@ Persistent local scheduler state is written under `.modelhub_state/`:
|
||||
|
||||
- `gpu_strategy.json`: GPU ranks, generation progress, and 50/30/20 accepted counters
|
||||
- `account_capacity.json`: learned per-account active-task limits
|
||||
- `submission_exclusions.jsonl`: non-retryable model/GPU uniqueness rejections
|
||||
|
||||
## Verification
|
||||
|
||||
|
||||
@@ -95,6 +95,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
default=os.getenv("MODELHUB_AGENT_CLAIMS_PATH", str(DEFAULT_CLAIMS_PATH)),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--submission-exclusions-path",
|
||||
default=os.getenv("MODELHUB_SUBMISSION_EXCLUSIONS_PATH", ".modelhub_state/submission_exclusions.jsonl"),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument("--history-archive-path", default="history/platform_tasks.jsonl", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--history-archive-limit", type=int, default=5000, help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
@@ -157,6 +162,11 @@ def make_wave_namespace(base_args: argparse.Namespace, wave: WaveSpec) -> argpar
|
||||
ledger_path=base_args.ledger_path,
|
||||
outcomes_path=getattr(base_args, "outcomes_path", "outcomes/submissions.jsonl"),
|
||||
claims_path=getattr(base_args, "claims_path", str(DEFAULT_CLAIMS_PATH)),
|
||||
submission_exclusions_path=getattr(
|
||||
base_args,
|
||||
"submission_exclusions_path",
|
||||
".modelhub_state/submission_exclusions.jsonl",
|
||||
),
|
||||
history_archive_path=base_args.history_archive_path,
|
||||
history_archive_limit=base_args.history_archive_limit,
|
||||
hf_base_url=base_args.hf_base_url,
|
||||
@@ -245,7 +255,9 @@ def run_daily_batches(
|
||||
f"[daily] wave_done name={wave.name} "
|
||||
f"candidates={summary['candidateCount']} planned={summary['plannedSubmitCount']} "
|
||||
f"submitted={summary['submittedCount']} skipped={summary['skippedCount']} "
|
||||
f"duplicates={summary.get('duplicateCount', 0)} failed={summary['failedCount']} "
|
||||
f"duplicates={summary.get('duplicateCount', 0)} "
|
||||
f"uniqueness_rejected={summary.get('modelGpuUniquenessRejectedCount', 0)} "
|
||||
f"failed={summary['failedCount']} "
|
||||
f"skip_reasons={skip_reason_text} "
|
||||
f"remaining_before_run={summary['remainingDailyQuotaBeforeRun']}"
|
||||
)
|
||||
|
||||
@@ -18,10 +18,18 @@ from history_stats import (
|
||||
load_ledger,
|
||||
update_history_archive,
|
||||
)
|
||||
from modelhub_client import DEFAULT_CAPACITY_STATE_PATH, ModelHubAPIError, ModelHubClient, ModelHubClientPool, is_duplicate_submission_error
|
||||
from modelhub_client import (
|
||||
DEFAULT_CAPACITY_STATE_PATH,
|
||||
ModelHubAPIError,
|
||||
ModelHubClient,
|
||||
ModelHubClientPool,
|
||||
is_duplicate_submission_error,
|
||||
is_model_uniqueness_error,
|
||||
)
|
||||
from models import CandidateModel, HFModelSummary, ModelInspection
|
||||
from outcome_tracker import DEFAULT_OUTCOMES_PATH, OutcomeTracker
|
||||
from submission_claims import DEFAULT_CLAIMS_PATH, SubmissionClaimStore, candidate_key, diversify_candidates
|
||||
from submission_exclusions import DEFAULT_SUBMISSION_EXCLUSIONS_PATH, SubmissionExclusionStore
|
||||
from task_registry import TASK_SPEC_BY_TYPE, all_task_types, choose_framework_for_task, choose_text_generation_framework, pipeline_tags_for_task_types, task_specs_for_model
|
||||
from template_selector import TemplateSelector
|
||||
|
||||
@@ -94,6 +102,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
parser.add_argument("--history-archive-path", default="history/platform_tasks.jsonl", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--history-archive-limit", type=int, default=5000, help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
"--submission-exclusions-path",
|
||||
default=os.getenv("MODELHUB_SUBMISSION_EXCLUSIONS_PATH", str(DEFAULT_SUBMISSION_EXCLUSIONS_PATH)),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gpu-strategy-state-path",
|
||||
default=os.getenv("MODELHUB_GPU_STRATEGY_STATE_PATH", str(DEFAULT_GPU_STRATEGY_PATH)),
|
||||
@@ -270,12 +283,24 @@ def process_model_for_candidates(
|
||||
target_gpus: list[str],
|
||||
allowed_task_types: list[str],
|
||||
outcome_tracker: OutcomeTracker | None = None,
|
||||
submission_exclusion_store: SubmissionExclusionStore | None = None,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
specs = [spec for spec in task_specs_for_model(model) if spec.task_type in allowed_task_types]
|
||||
if not specs:
|
||||
return [], [{"repoId": model.repo_id, "reason": f"unsupported_pipeline_tag:{model.pipeline_tag or 'unknown'}"}], []
|
||||
|
||||
processed_gpus = modelhub_client.processed_gpus_for_model(model.repo_id)
|
||||
try:
|
||||
if hasattr(modelhub_client, "model_submission_precheck"):
|
||||
precheck = modelhub_client.model_submission_precheck(model.repo_id)
|
||||
processed_gpus = set(precheck.get("processedGpus") or set())
|
||||
else:
|
||||
processed_gpus = modelhub_client.processed_gpus_for_model(model.repo_id)
|
||||
except Exception as exc:
|
||||
return (
|
||||
[],
|
||||
[{"repoId": model.repo_id, "reason": "community_precheck_unavailable_fail_closed"}],
|
||||
[{"repoId": model.repo_id, "reason": f"community_precheck_error:{exc}"}],
|
||||
)
|
||||
candidates: list[dict[str, Any]] = []
|
||||
skipped: list[dict[str, Any]] = []
|
||||
failed: list[dict[str, Any]] = []
|
||||
@@ -283,6 +308,11 @@ def process_model_for_candidates(
|
||||
pending_task_types_by_gpu: list[tuple[str, list[str]]] = []
|
||||
|
||||
for target_gpu in target_gpus:
|
||||
if submission_exclusion_store is not None and submission_exclusion_store.is_blocked(model.repo_id, target_gpu):
|
||||
skipped.append(
|
||||
{"repoId": model.repo_id, "targetGpu": target_gpu, "reason": "model_gpu_uniqueness_blocklist"}
|
||||
)
|
||||
continue
|
||||
if target_gpu in processed_gpus:
|
||||
skipped.append({"repoId": model.repo_id, "targetGpu": target_gpu, "reason": "already_processed_for_gpu"})
|
||||
continue
|
||||
@@ -391,6 +421,7 @@ def collect_candidates_from_models(
|
||||
target_gpus: list[str],
|
||||
selected_task_types: list[str],
|
||||
outcome_tracker: OutcomeTracker | None,
|
||||
submission_exclusion_store: SubmissionExclusionStore | None,
|
||||
read_concurrency: int,
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], int]:
|
||||
candidates: list[dict[str, Any]] = []
|
||||
@@ -419,6 +450,7 @@ def collect_candidates_from_models(
|
||||
target_gpus=target_gpus,
|
||||
allowed_task_types=selected_task_types,
|
||||
outcome_tracker=outcome_tracker,
|
||||
submission_exclusion_store=submission_exclusion_store,
|
||||
): index
|
||||
for index, model in enumerate(chunk)
|
||||
}
|
||||
@@ -445,6 +477,27 @@ def submit_candidate(
|
||||
candidate: dict[str, Any],
|
||||
modelhub_client: ModelHubClient,
|
||||
) -> dict[str, Any]:
|
||||
if hasattr(modelhub_client, "model_submission_precheck"):
|
||||
try:
|
||||
precheck = modelhub_client.model_submission_precheck(candidate["repoId"], force_refresh=True)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"[submit] deferred repo={candidate['repoId']} gpu={candidate['targetGpu']} "
|
||||
f"framework={candidate['framework']} reason=community_precheck_unavailable",
|
||||
flush=True,
|
||||
)
|
||||
return {
|
||||
"outcome": "precheck_deferred",
|
||||
"candidate": candidate,
|
||||
"reason": f"community_precheck_error:{exc}",
|
||||
}
|
||||
if candidate["targetGpu"] in set(precheck.get("processedGpus") or set()):
|
||||
return {
|
||||
"outcome": "duplicate",
|
||||
"candidate": candidate,
|
||||
"reason": "already_processed_for_gpu",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"modelAddress": candidate["modelAddress"],
|
||||
"taskType": candidate["taskType"],
|
||||
@@ -469,6 +522,17 @@ def submit_candidate(
|
||||
"responseData": response.get("data"),
|
||||
}
|
||||
except ModelHubAPIError as exc:
|
||||
if is_model_uniqueness_error(exc):
|
||||
print(
|
||||
f"[submit] skipped repo={candidate['repoId']} gpu={candidate['targetGpu']} "
|
||||
f"framework={candidate['framework']} reason=model_uniqueness_rejected",
|
||||
flush=True,
|
||||
)
|
||||
return {
|
||||
"outcome": "uniqueness_rejected",
|
||||
"candidate": candidate,
|
||||
"reason": str(exc),
|
||||
}
|
||||
if is_duplicate_submission_error(exc):
|
||||
print(
|
||||
f"[submit] skipped repo={candidate['repoId']} gpu={candidate['targetGpu']} "
|
||||
@@ -505,6 +569,18 @@ def make_run_dir(runs_dir: Path, now) -> Path:
|
||||
raise RuntimeError(f"Unable to allocate a unique run directory under {runs_dir}")
|
||||
|
||||
|
||||
def one_candidate_per_model(candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
selected: list[dict[str, Any]] = []
|
||||
seen_models: set[str] = set()
|
||||
for candidate in candidates:
|
||||
model_id = str(candidate.get("repoId") or candidate.get("modelAddress") or "")
|
||||
if not model_id or model_id in seen_models:
|
||||
continue
|
||||
seen_models.add(model_id)
|
||||
selected.append(candidate)
|
||||
return selected
|
||||
|
||||
|
||||
def run_submission(
|
||||
args: argparse.Namespace,
|
||||
*,
|
||||
@@ -543,6 +619,9 @@ def run_submission(
|
||||
history_archive_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
outcome_tracker = outcome_tracker or OutcomeTracker(Path(args.outcomes_path))
|
||||
submission_exclusion_store = SubmissionExclusionStore(
|
||||
Path(getattr(args, "submission_exclusions_path", DEFAULT_SUBMISSION_EXCLUSIONS_PATH))
|
||||
)
|
||||
strategy_manager: GPUStrategyManager | None = None
|
||||
strategy_summary: dict[str, Any] = {"enabled": False}
|
||||
strategy_enabled = (
|
||||
@@ -638,9 +717,12 @@ def run_submission(
|
||||
"plannedSubmitCount": 0,
|
||||
"submittedCount": 0,
|
||||
"duplicateCount": 0,
|
||||
"modelGpuUniquenessRejectedCount": 0,
|
||||
"skippedCount": 0,
|
||||
"skipReasonCounts": {},
|
||||
"failedCount": 0,
|
||||
"failureReasonCounts": {},
|
||||
"submissionExclusionsPath": str(submission_exclusion_store.path),
|
||||
"warnings": report.get("warnings", []),
|
||||
"runDir": str(run_dir),
|
||||
"outcomeSyncCount": synced_count,
|
||||
@@ -740,6 +822,7 @@ def run_submission(
|
||||
target_gpus=target_gpus,
|
||||
selected_task_types=selected_task_types,
|
||||
outcome_tracker=outcome_tracker,
|
||||
submission_exclusion_store=submission_exclusion_store,
|
||||
read_concurrency=max(1, args.read_concurrency),
|
||||
)
|
||||
candidates.extend(stage_candidates)
|
||||
@@ -767,6 +850,7 @@ def run_submission(
|
||||
|
||||
submitted: list[dict[str, Any]] = []
|
||||
duplicate_candidates: list[dict[str, Any]] = []
|
||||
uniqueness_rejected_candidates: list[dict[str, Any]] = []
|
||||
target_submit_count = resolve_max_submit_count(
|
||||
args=args,
|
||||
planned_count=len(candidates),
|
||||
@@ -806,7 +890,9 @@ def run_submission(
|
||||
candidate
|
||||
for candidate in diversified_candidates
|
||||
if candidate_key(candidate) not in attempted_keys
|
||||
and not submission_exclusion_store.is_blocked(candidate["repoId"], candidate["targetGpu"])
|
||||
]
|
||||
remaining_candidates = one_candidate_per_model(remaining_candidates)
|
||||
desired_count = min(
|
||||
target_submit_count - len(submitted),
|
||||
max_submit_attempts - len(attempted_candidates),
|
||||
@@ -843,12 +929,30 @@ def run_submission(
|
||||
|
||||
batch_submitted_candidates: list[dict[str, Any]] = []
|
||||
batch_duplicate_candidates: list[dict[str, Any]] = []
|
||||
batch_uniqueness_rejected_candidates: list[dict[str, Any]] = []
|
||||
batch_failed_candidates: list[dict[str, Any]] = []
|
||||
for index in range(len(batch_candidates)):
|
||||
result = ordered_results.get(index)
|
||||
if result is None:
|
||||
continue
|
||||
candidate = result["candidate"]
|
||||
if result["outcome"] == "uniqueness_rejected":
|
||||
duplicate_candidates.append(candidate)
|
||||
uniqueness_rejected_candidates.append(candidate)
|
||||
batch_uniqueness_rejected_candidates.append(candidate)
|
||||
submission_exclusion_store.block(
|
||||
candidate["repoId"],
|
||||
candidate["targetGpu"],
|
||||
reason=result.get("reason", "model_uniqueness_rejected"),
|
||||
)
|
||||
skipped.append(
|
||||
{
|
||||
"repoId": candidate["repoId"],
|
||||
"targetGpu": candidate["targetGpu"],
|
||||
"reason": "model_uniqueness_rejected",
|
||||
}
|
||||
)
|
||||
continue
|
||||
if result["outcome"] == "duplicate":
|
||||
duplicate_candidates.append(candidate)
|
||||
batch_duplicate_candidates.append(candidate)
|
||||
@@ -860,7 +964,7 @@ def run_submission(
|
||||
}
|
||||
)
|
||||
continue
|
||||
if result["outcome"] == "failed":
|
||||
if result["outcome"] in {"failed", "precheck_deferred"}:
|
||||
batch_failed_candidates.append(candidate)
|
||||
failed.append(
|
||||
{
|
||||
@@ -903,7 +1007,9 @@ def run_submission(
|
||||
submit_time=result["submitTime"],
|
||||
)
|
||||
|
||||
claim_store.mark_submitted([*batch_submitted_candidates, *batch_duplicate_candidates])
|
||||
claim_store.mark_submitted(
|
||||
[*batch_submitted_candidates, *batch_duplicate_candidates, *batch_uniqueness_rejected_candidates]
|
||||
)
|
||||
claim_store.release(batch_failed_candidates)
|
||||
if strategy_manager is not None:
|
||||
strategy_manager.record_accepted(batch_submitted_candidates)
|
||||
@@ -950,11 +1056,13 @@ def run_submission(
|
||||
),
|
||||
"plannedSubmitCount": len(attempted_candidates),
|
||||
"duplicateCount": len(duplicate_candidates),
|
||||
"modelGpuUniquenessRejectedCount": len(uniqueness_rejected_candidates),
|
||||
"submittedCount": len(submitted),
|
||||
"skippedCount": len(skipped),
|
||||
"skipReasonCounts": dict(skip_reason_counts.most_common()),
|
||||
"failedCount": len(failed),
|
||||
"failureReasonCounts": dict(failure_reason_counts.most_common()),
|
||||
"submissionExclusionsPath": str(submission_exclusion_store.path),
|
||||
"submitConcurrencyUsed": submit_workers,
|
||||
"warnings": report.get("warnings", []),
|
||||
"runDir": str(run_dir),
|
||||
|
||||
@@ -21,6 +21,23 @@ class ModelHubAPIError(RuntimeError):
|
||||
self.payload = payload
|
||||
|
||||
|
||||
def parse_model_submission_precheck(payload: Any) -> dict[str, Any]:
|
||||
"""Validate the community lookup response instead of failing open."""
|
||||
if not isinstance(payload, dict):
|
||||
raise ModelHubAPIError("Community model precheck returned a non-object response", payload=payload)
|
||||
data = payload.get("data")
|
||||
if not isinstance(data, dict) or "verifyResult" not in data:
|
||||
raise ModelHubAPIError("Community model precheck response is incomplete", payload=payload)
|
||||
verify_result = data.get("verifyResult") or {}
|
||||
if not isinstance(verify_result, dict):
|
||||
raise ModelHubAPIError("Community model precheck verifyResult is invalid", payload=payload)
|
||||
|
||||
return {
|
||||
"isInDB": data.get("isInDB") is True,
|
||||
"processedGpus": set(str(gpu) for gpu in verify_result),
|
||||
}
|
||||
|
||||
|
||||
class ModelHubClient:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -42,9 +59,13 @@ class ModelHubClient:
|
||||
retries=retries,
|
||||
)
|
||||
|
||||
def search_by_model_id(self, model_id: str) -> dict[str, Any]:
|
||||
def search_by_model_id(self, model_id: str, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||
del force_refresh
|
||||
return self._request("GET", "/api/computility/models/search-by-model-id", query={"modelId": model_id})
|
||||
|
||||
def model_submission_precheck(self, model_id: str, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||
return parse_model_submission_precheck(self.search_by_model_id(model_id, force_refresh=force_refresh))
|
||||
|
||||
def is_model_processed_for_gpu(self, model_id: str, target_gpu: str) -> bool:
|
||||
gpu_result = self.get_verify_result_map(model_id).get(target_gpu)
|
||||
if gpu_result is None:
|
||||
@@ -56,7 +77,7 @@ class ModelHubClient:
|
||||
return ((payload.get("data") or {}).get("verifyResult") or {})
|
||||
|
||||
def processed_gpus_for_model(self, model_id: str) -> set[str]:
|
||||
return set(self.get_verify_result_map(model_id).keys())
|
||||
return set(self.model_submission_precheck(model_id)["processedGpus"])
|
||||
|
||||
def list_tasks_page(
|
||||
self,
|
||||
@@ -256,6 +277,16 @@ DUPLICATE_SUBMISSION_MARKERS = (
|
||||
"already in progress",
|
||||
)
|
||||
|
||||
MODEL_UNIQUENESS_ERROR_MARKERS = (
|
||||
"模型唯一性检查",
|
||||
"唯一性检查没有通过",
|
||||
"模型已存在",
|
||||
"model uniqueness",
|
||||
"uniqueness check",
|
||||
"duplicate model",
|
||||
"model already exists",
|
||||
)
|
||||
|
||||
DEFAULT_CAPACITY_STATE_PATH = Path(".modelhub_state/account_capacity.json")
|
||||
|
||||
|
||||
@@ -275,6 +306,13 @@ def is_duplicate_submission_error(error: ModelHubAPIError) -> bool:
|
||||
return any(marker in message for marker in DUPLICATE_SUBMISSION_MARKERS)
|
||||
|
||||
|
||||
def is_model_uniqueness_error(error: ModelHubAPIError) -> bool:
|
||||
message = str(error).strip().lower()
|
||||
if isinstance(error.payload, dict):
|
||||
message = f"{message} {error.payload.get('message') or ''}".lower()
|
||||
return any(marker in message for marker in MODEL_UNIQUENESS_ERROR_MARKERS)
|
||||
|
||||
|
||||
class ModelHubClientPool:
|
||||
def __init__(
|
||||
self,
|
||||
@@ -385,13 +423,6 @@ class ModelHubClientPool:
|
||||
except Exception:
|
||||
return max(0, max_count - 1)
|
||||
|
||||
def _safe_search_by_model_id(self, client: ModelHubClient, model_id: str) -> dict[str, Any]:
|
||||
try:
|
||||
payload = client.search_by_model_id(model_id)
|
||||
return payload if isinstance(payload, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _safe_list_tasks(self, client: ModelHubClient, kwargs: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
try:
|
||||
return client.list_tasks(**kwargs)
|
||||
@@ -546,29 +577,33 @@ class ModelHubClientPool:
|
||||
if now - cached_at >= self._verify_cache_ttl:
|
||||
self._verify_cache.pop(model_id, None)
|
||||
|
||||
def search_by_model_id(self, model_id: str) -> dict[str, Any]:
|
||||
def search_by_model_id(self, model_id: str, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||
# Reuse recent model verification results across short poll cycles.
|
||||
with self._state_lock:
|
||||
cached = self._read_from_cache(model_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
if not force_refresh:
|
||||
with self._state_lock:
|
||||
cached = self._read_from_cache(model_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Only query ONE client (the reader) instead of fanning out to all clients.
|
||||
# verifyResult is model-specific platform data, not account-specific.
|
||||
response = self._safe_search_by_model_id(self._reader, model_id)
|
||||
response = self._reader.search_by_model_id(model_id)
|
||||
if not isinstance(response, dict):
|
||||
response = {"code": 0, "data": {"verifyResult": {}}}
|
||||
raise ModelHubAPIError("Community model precheck returned a non-object response", payload=response)
|
||||
|
||||
with self._state_lock:
|
||||
self._write_to_cache(model_id, response)
|
||||
return response
|
||||
|
||||
def model_submission_precheck(self, model_id: str, *, force_refresh: bool = False) -> dict[str, Any]:
|
||||
return parse_model_submission_precheck(self.search_by_model_id(model_id, force_refresh=force_refresh))
|
||||
|
||||
def get_verify_result_map(self, model_id: str) -> dict[str, Any]:
|
||||
payload = self.search_by_model_id(model_id)
|
||||
return ((payload.get("data") or {}).get("verifyResult") or {})
|
||||
|
||||
def processed_gpus_for_model(self, model_id: str) -> set[str]:
|
||||
return set(self.get_verify_result_map(model_id).keys())
|
||||
return set(self.model_submission_precheck(model_id)["processedGpus"])
|
||||
|
||||
def _reserve_account(self, excluded: set[int]) -> tuple[int, int] | None:
|
||||
with self._state_lock:
|
||||
|
||||
@@ -74,6 +74,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
default=os.getenv("MODELHUB_AGENT_CLAIMS_PATH", str(DEFAULT_CLAIMS_PATH)),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--submission-exclusions-path",
|
||||
default=os.getenv("MODELHUB_SUBMISSION_EXCLUSIONS_PATH", ".modelhub_state/submission_exclusions.jsonl"),
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
parser.add_argument("--history-archive-path", default="history/platform_tasks.jsonl", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--history-archive-limit", type=int, default=5000, help=argparse.SUPPRESS)
|
||||
parser.add_argument(
|
||||
|
||||
67
modelhub_submmit_api/submission_exclusions.py
Normal file
67
modelhub_submmit_api/submission_exclusions.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from common import read_jsonl, update_jsonl, utc_now
|
||||
|
||||
|
||||
DEFAULT_SUBMISSION_EXCLUSIONS_PATH = Path(".modelhub_state/submission_exclusions.jsonl")
|
||||
|
||||
|
||||
def exclusion_key(model_id: str, target_gpu: str) -> str:
|
||||
return f"{model_id}|{target_gpu}"
|
||||
|
||||
|
||||
class SubmissionExclusionStore:
|
||||
"""Persistent model/GPU exclusions for non-retryable platform rejections."""
|
||||
|
||||
def __init__(self, path: Path | str = DEFAULT_SUBMISSION_EXCLUSIONS_PATH) -> None:
|
||||
self.path = Path(path)
|
||||
self._lock = threading.Lock()
|
||||
self._blocked = {
|
||||
exclusion_key(str(record.get("modelId")), str(record.get("targetGpu")))
|
||||
for record in read_jsonl(self.path)
|
||||
if record.get("modelId") and record.get("targetGpu")
|
||||
}
|
||||
|
||||
def is_blocked(self, model_id: str, target_gpu: str) -> bool:
|
||||
with self._lock:
|
||||
return exclusion_key(model_id, target_gpu) in self._blocked
|
||||
|
||||
def block(self, model_id: str, target_gpu: str, *, reason: str) -> None:
|
||||
if not model_id or not target_gpu:
|
||||
return
|
||||
key = exclusion_key(model_id, target_gpu)
|
||||
now = utc_now().isoformat()
|
||||
|
||||
def update(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
existing = next(
|
||||
(
|
||||
record
|
||||
for record in records
|
||||
if exclusion_key(str(record.get("modelId")), str(record.get("targetGpu"))) == key
|
||||
),
|
||||
None,
|
||||
)
|
||||
if existing is None:
|
||||
records.append(
|
||||
{
|
||||
"modelId": model_id,
|
||||
"targetGpu": target_gpu,
|
||||
"reason": reason,
|
||||
"firstSeenAt": now,
|
||||
"lastSeenAt": now,
|
||||
"occurrences": 1,
|
||||
}
|
||||
)
|
||||
else:
|
||||
existing["reason"] = reason
|
||||
existing["lastSeenAt"] = now
|
||||
existing["occurrences"] = max(1, int(existing.get("occurrences") or 1)) + 1
|
||||
return records
|
||||
|
||||
update_jsonl(self.path, update)
|
||||
with self._lock:
|
||||
self._blocked.add(key)
|
||||
@@ -1 +1 @@
|
||||
AGENT_VERSION = "2026.08.02.4"
|
||||
AGENT_VERSION = "2026.08.02.5"
|
||||
|
||||
225
tests/test_submission_safety.py
Normal file
225
tests/test_submission_safety.py
Normal file
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PACKAGE_DIR = Path(__file__).resolve().parents[1] / "modelhub_submmit_api"
|
||||
if str(PACKAGE_DIR) in sys.path:
|
||||
sys.path.remove(str(PACKAGE_DIR))
|
||||
sys.path.insert(0, str(PACKAGE_DIR))
|
||||
|
||||
from main import build_parser, one_candidate_per_model, process_model_for_candidates, run_submission, submit_candidate # noqa: E402
|
||||
from modelhub_client import ( # noqa: E402
|
||||
ModelHubAPIError,
|
||||
ModelHubClientPool,
|
||||
is_model_uniqueness_error,
|
||||
parse_model_submission_precheck,
|
||||
)
|
||||
from models import HFModelSummary, ModelInspection # noqa: E402
|
||||
from submission_exclusions import SubmissionExclusionStore # noqa: E402
|
||||
from template_selector import TemplateSelector # noqa: E402
|
||||
|
||||
|
||||
class SafeDiscovery:
|
||||
def __init__(self) -> None:
|
||||
self.model = HFModelSummary(
|
||||
repo_id="owner/model",
|
||||
downloads=100,
|
||||
last_modified=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
pipeline_tag="text-generation",
|
||||
)
|
||||
|
||||
def list_recent_models(self, **_kwargs) -> list[HFModelSummary]: # noqa: ANN003
|
||||
return [self.model]
|
||||
|
||||
@staticmethod
|
||||
def inspect_model(model: HFModelSummary) -> ModelInspection:
|
||||
return ModelInspection(repo_id=model.repo_id, weight_files=["model.safetensors"])
|
||||
|
||||
|
||||
class OtherGpuProcessedClient:
|
||||
@staticmethod
|
||||
def model_submission_precheck(_model_id: str, *, force_refresh: bool = False) -> dict:
|
||||
del force_refresh
|
||||
return {"processedGpus": {"Biren_166m"}, "isInDB": True}
|
||||
|
||||
|
||||
class FailingLookupClient:
|
||||
@staticmethod
|
||||
def search_by_model_id(_model_id: str) -> dict:
|
||||
raise ModelHubAPIError("temporary lookup outage")
|
||||
|
||||
|
||||
class ExactGpuProcessedClient:
|
||||
def __init__(self) -> None:
|
||||
self.add_calls = 0
|
||||
|
||||
@staticmethod
|
||||
def model_submission_precheck(_model_id: str, *, force_refresh: bool = False) -> dict:
|
||||
del force_refresh
|
||||
return {"processedGpus": {"Vastai_va16"}, "isInDB": True}
|
||||
|
||||
def add_task(self, _payload: dict) -> dict:
|
||||
self.add_calls += 1
|
||||
return {"code": 0, "data": {"id": "should-not-submit"}}
|
||||
|
||||
|
||||
class UniquenessRejectingClient:
|
||||
@staticmethod
|
||||
def add_task(_payload: dict) -> dict:
|
||||
raise ModelHubAPIError("模型唯一性检查没有通过,无法进行同步")
|
||||
|
||||
|
||||
class UniquenessRunClient(UniquenessRejectingClient):
|
||||
def __init__(self) -> None:
|
||||
self.add_calls = 0
|
||||
|
||||
@staticmethod
|
||||
def available_submit_slots() -> int:
|
||||
return 1
|
||||
|
||||
@staticmethod
|
||||
def list_tasks_page(**_kwargs) -> dict: # noqa: ANN003
|
||||
return {"code": 0, "data": {"records": []}}
|
||||
|
||||
@staticmethod
|
||||
def processed_gpus_for_model(_model_id: str) -> set[str]:
|
||||
return set()
|
||||
|
||||
def add_task(self, _payload: dict) -> dict:
|
||||
self.add_calls += 1
|
||||
return super().add_task(_payload)
|
||||
|
||||
|
||||
def candidate(target_gpu: str = "Vastai_va16") -> dict:
|
||||
return {
|
||||
"repoId": "owner/model",
|
||||
"modelAddress": "https://modelscope.cn/models/owner/model",
|
||||
"taskType": "text-generation",
|
||||
"targetGpu": target_gpu,
|
||||
"framework": "vllm",
|
||||
"configParams": "framework: vllm",
|
||||
}
|
||||
|
||||
|
||||
class SubmissionSafetyTests(unittest.TestCase):
|
||||
def test_precheck_tracks_processed_gpus_without_blocking_other_gpus(self) -> None:
|
||||
payload = {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"isInDB": True,
|
||||
"verifyResult": {
|
||||
"Biren_166m": {
|
||||
"result": "已验证",
|
||||
"records": [{"verifyResult": 1}],
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
precheck = parse_model_submission_precheck(payload)
|
||||
self.assertEqual({"Biren_166m"}, precheck["processedGpus"])
|
||||
|
||||
model = HFModelSummary(
|
||||
repo_id="owner/model",
|
||||
downloads=100,
|
||||
last_modified=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
pipeline_tag="text-generation",
|
||||
)
|
||||
candidates, skipped, failed = process_model_for_candidates(
|
||||
model=model,
|
||||
hf_discovery=SafeDiscovery(), # type: ignore[arg-type]
|
||||
modelhub_client=OtherGpuProcessedClient(), # type: ignore[arg-type]
|
||||
template_selector=TemplateSelector(),
|
||||
target_gpus=["Biren_166m", "Vastai_va16"],
|
||||
allowed_task_types=["text-generation"],
|
||||
)
|
||||
self.assertEqual(["Vastai_va16"], [item["targetGpu"] for item in candidates])
|
||||
self.assertEqual("already_processed_for_gpu", skipped[0]["reason"])
|
||||
self.assertEqual([], failed)
|
||||
|
||||
def test_lookup_failure_is_fail_closed(self) -> None:
|
||||
pool = ModelHubClientPool([FailingLookupClient()]) # type: ignore[arg-type]
|
||||
with self.assertRaises(ModelHubAPIError):
|
||||
pool.model_submission_precheck("owner/model")
|
||||
|
||||
def test_submit_precheck_stops_an_exact_processed_gpu(self) -> None:
|
||||
client = ExactGpuProcessedClient()
|
||||
result = submit_candidate(candidate(), client) # type: ignore[arg-type]
|
||||
self.assertEqual("duplicate", result["outcome"])
|
||||
self.assertEqual(0, client.add_calls)
|
||||
|
||||
def test_uniqueness_rejection_is_non_retryable_for_that_combination(self) -> None:
|
||||
error = ModelHubAPIError("模型唯一性检查没有通过,无法进行同步")
|
||||
self.assertTrue(is_model_uniqueness_error(error))
|
||||
result = submit_candidate(candidate(), UniquenessRejectingClient()) # type: ignore[arg-type]
|
||||
self.assertEqual("uniqueness_rejected", result["outcome"])
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||
path = Path(temporary_dir) / "exclusions.jsonl"
|
||||
store = SubmissionExclusionStore(path)
|
||||
store.block("owner/model", "Vastai_va16", reason=str(error))
|
||||
reloaded = SubmissionExclusionStore(path)
|
||||
self.assertTrue(reloaded.is_blocked("owner/model", "Vastai_va16"))
|
||||
self.assertFalse(reloaded.is_blocked("owner/model", "Biren_166m"))
|
||||
|
||||
def test_run_persists_uniqueness_rejection_and_does_not_retry_it(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temporary_dir:
|
||||
root = Path(temporary_dir)
|
||||
args = build_parser().parse_args(
|
||||
[
|
||||
"--gpus",
|
||||
"Vastai_va16",
|
||||
"--task-types",
|
||||
"text-generation",
|
||||
"--limit",
|
||||
"1",
|
||||
"--max-scan-models",
|
||||
"1",
|
||||
"--skip-outcome-sync",
|
||||
"--skip-history-archive",
|
||||
]
|
||||
)
|
||||
args.runs_dir = str(root / "runs")
|
||||
args.ledger_path = str(root / "ledger.jsonl")
|
||||
args.outcomes_path = str(root / "outcomes.jsonl")
|
||||
args.claims_path = str(root / "claims.jsonl")
|
||||
args.submission_exclusions_path = str(root / "exclusions.jsonl")
|
||||
args.history_archive_path = str(root / "history.jsonl")
|
||||
client = UniquenessRunClient()
|
||||
discovery = SafeDiscovery()
|
||||
|
||||
first = run_submission(
|
||||
args,
|
||||
now=datetime(2026, 1, 1, 12, tzinfo=timezone.utc),
|
||||
hf_discovery=discovery, # type: ignore[arg-type]
|
||||
modelhub_client=client, # type: ignore[arg-type]
|
||||
)
|
||||
second = run_submission(
|
||||
args,
|
||||
now=datetime(2026, 1, 1, 12, 1, tzinfo=timezone.utc),
|
||||
hf_discovery=discovery, # type: ignore[arg-type]
|
||||
modelhub_client=client, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
self.assertEqual(1, first["modelGpuUniquenessRejectedCount"])
|
||||
self.assertEqual(1, client.add_calls)
|
||||
self.assertEqual(0, second["candidateCount"])
|
||||
self.assertEqual(1, second["skipReasonCounts"]["model_gpu_uniqueness_blocklist"])
|
||||
|
||||
def test_concurrent_batch_uses_at_most_one_gpu_per_model(self) -> None:
|
||||
selected = one_candidate_per_model(
|
||||
[
|
||||
candidate("Vastai_va16"),
|
||||
candidate("Biren_166m"),
|
||||
{**candidate("Biren_166m"), "repoId": "owner/other"},
|
||||
]
|
||||
)
|
||||
self.assertEqual(["owner/model", "owner/other"], [item["repoId"] for item in selected])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user