fix: prevent repeated model GPU submissions

This commit is contained in:
CoolBoy
2026-08-02 18:48:47 +08:00
parent 8e68c6f611
commit ccae7ff8f3
9 changed files with 489 additions and 24 deletions

View File

@@ -38,6 +38,7 @@ Optional tuning:
- `MODELHUB_AGENT_RESERVATION_TTL_SECONDS` default `120` - `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_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_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_DAILY_TARGET`
- `MODELHUB_AGENT_MIN_DOWNLOADS` - `MODELHUB_AGENT_MIN_DOWNLOADS`
- `MODELHUB_AGENT_GPUS` - `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` forever. The `[scan]` lines show every expansion stage, while `[daily] wave_done`
includes `skip_reasons` so an empty candidate pool is directly diagnosable. 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 ## Concurrent Agents
The token pool keeps a local reservation for every in-flight submission, so a 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 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 health response expose `agent_version`; version `2026.08.02.3` or newer includes
duplicate replacement behavior, while version `2026.08.02.4` adds adaptive 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 ## Deploy

View File

@@ -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. 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 - 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. 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. - 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. - 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. - 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 - `gpu_strategy.json`: GPU ranks, generation progress, and 50/30/20 accepted counters
- `account_capacity.json`: learned per-account active-task limits - `account_capacity.json`: learned per-account active-task limits
- `submission_exclusions.jsonl`: non-retryable model/GPU uniqueness rejections
## Verification ## Verification

View File

@@ -95,6 +95,11 @@ def build_parser() -> argparse.ArgumentParser:
default=os.getenv("MODELHUB_AGENT_CLAIMS_PATH", str(DEFAULT_CLAIMS_PATH)), default=os.getenv("MODELHUB_AGENT_CLAIMS_PATH", str(DEFAULT_CLAIMS_PATH)),
help=argparse.SUPPRESS, 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-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("--history-archive-limit", type=int, default=5000, help=argparse.SUPPRESS)
parser.add_argument( parser.add_argument(
@@ -157,6 +162,11 @@ def make_wave_namespace(base_args: argparse.Namespace, wave: WaveSpec) -> argpar
ledger_path=base_args.ledger_path, ledger_path=base_args.ledger_path,
outcomes_path=getattr(base_args, "outcomes_path", "outcomes/submissions.jsonl"), outcomes_path=getattr(base_args, "outcomes_path", "outcomes/submissions.jsonl"),
claims_path=getattr(base_args, "claims_path", str(DEFAULT_CLAIMS_PATH)), 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_path=base_args.history_archive_path,
history_archive_limit=base_args.history_archive_limit, history_archive_limit=base_args.history_archive_limit,
hf_base_url=base_args.hf_base_url, hf_base_url=base_args.hf_base_url,
@@ -245,7 +255,9 @@ def run_daily_batches(
f"[daily] wave_done name={wave.name} " f"[daily] wave_done name={wave.name} "
f"candidates={summary['candidateCount']} planned={summary['plannedSubmitCount']} " f"candidates={summary['candidateCount']} planned={summary['plannedSubmitCount']} "
f"submitted={summary['submittedCount']} skipped={summary['skippedCount']} " 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"skip_reasons={skip_reason_text} "
f"remaining_before_run={summary['remainingDailyQuotaBeforeRun']}" f"remaining_before_run={summary['remainingDailyQuotaBeforeRun']}"
) )

View File

@@ -18,10 +18,18 @@ from history_stats import (
load_ledger, load_ledger,
update_history_archive, 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 models import CandidateModel, HFModelSummary, ModelInspection
from outcome_tracker import DEFAULT_OUTCOMES_PATH, OutcomeTracker from outcome_tracker import DEFAULT_OUTCOMES_PATH, OutcomeTracker
from submission_claims import DEFAULT_CLAIMS_PATH, SubmissionClaimStore, candidate_key, diversify_candidates 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 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 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-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("--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( parser.add_argument(
"--gpu-strategy-state-path", "--gpu-strategy-state-path",
default=os.getenv("MODELHUB_GPU_STRATEGY_STATE_PATH", str(DEFAULT_GPU_STRATEGY_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], target_gpus: list[str],
allowed_task_types: list[str], allowed_task_types: list[str],
outcome_tracker: OutcomeTracker | None = None, outcome_tracker: OutcomeTracker | None = None,
submission_exclusion_store: SubmissionExclusionStore | None = None,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]: ) -> 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] specs = [spec for spec in task_specs_for_model(model) if spec.task_type in allowed_task_types]
if not specs: if not specs:
return [], [{"repoId": model.repo_id, "reason": f"unsupported_pipeline_tag:{model.pipeline_tag or 'unknown'}"}], [] 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]] = [] candidates: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = [] skipped: list[dict[str, Any]] = []
failed: 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]]] = [] pending_task_types_by_gpu: list[tuple[str, list[str]]] = []
for target_gpu in target_gpus: 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: if target_gpu in processed_gpus:
skipped.append({"repoId": model.repo_id, "targetGpu": target_gpu, "reason": "already_processed_for_gpu"}) skipped.append({"repoId": model.repo_id, "targetGpu": target_gpu, "reason": "already_processed_for_gpu"})
continue continue
@@ -391,6 +421,7 @@ def collect_candidates_from_models(
target_gpus: list[str], target_gpus: list[str],
selected_task_types: list[str], selected_task_types: list[str],
outcome_tracker: OutcomeTracker | None, outcome_tracker: OutcomeTracker | None,
submission_exclusion_store: SubmissionExclusionStore | None,
read_concurrency: int, read_concurrency: int,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], int]: ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], int]:
candidates: list[dict[str, Any]] = [] candidates: list[dict[str, Any]] = []
@@ -419,6 +450,7 @@ def collect_candidates_from_models(
target_gpus=target_gpus, target_gpus=target_gpus,
allowed_task_types=selected_task_types, allowed_task_types=selected_task_types,
outcome_tracker=outcome_tracker, outcome_tracker=outcome_tracker,
submission_exclusion_store=submission_exclusion_store,
): index ): index
for index, model in enumerate(chunk) for index, model in enumerate(chunk)
} }
@@ -445,6 +477,27 @@ def submit_candidate(
candidate: dict[str, Any], candidate: dict[str, Any],
modelhub_client: ModelHubClient, modelhub_client: ModelHubClient,
) -> dict[str, Any]: ) -> 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 = { payload = {
"modelAddress": candidate["modelAddress"], "modelAddress": candidate["modelAddress"],
"taskType": candidate["taskType"], "taskType": candidate["taskType"],
@@ -469,6 +522,17 @@ def submit_candidate(
"responseData": response.get("data"), "responseData": response.get("data"),
} }
except ModelHubAPIError as exc: 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): if is_duplicate_submission_error(exc):
print( print(
f"[submit] skipped repo={candidate['repoId']} gpu={candidate['targetGpu']} " 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}") 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( def run_submission(
args: argparse.Namespace, args: argparse.Namespace,
*, *,
@@ -543,6 +619,9 @@ def run_submission(
history_archive_path.parent.mkdir(parents=True, exist_ok=True) history_archive_path.parent.mkdir(parents=True, exist_ok=True)
outcome_tracker = outcome_tracker or OutcomeTracker(Path(args.outcomes_path)) 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_manager: GPUStrategyManager | None = None
strategy_summary: dict[str, Any] = {"enabled": False} strategy_summary: dict[str, Any] = {"enabled": False}
strategy_enabled = ( strategy_enabled = (
@@ -638,9 +717,12 @@ def run_submission(
"plannedSubmitCount": 0, "plannedSubmitCount": 0,
"submittedCount": 0, "submittedCount": 0,
"duplicateCount": 0, "duplicateCount": 0,
"modelGpuUniquenessRejectedCount": 0,
"skippedCount": 0, "skippedCount": 0,
"skipReasonCounts": {}, "skipReasonCounts": {},
"failedCount": 0, "failedCount": 0,
"failureReasonCounts": {},
"submissionExclusionsPath": str(submission_exclusion_store.path),
"warnings": report.get("warnings", []), "warnings": report.get("warnings", []),
"runDir": str(run_dir), "runDir": str(run_dir),
"outcomeSyncCount": synced_count, "outcomeSyncCount": synced_count,
@@ -740,6 +822,7 @@ def run_submission(
target_gpus=target_gpus, target_gpus=target_gpus,
selected_task_types=selected_task_types, selected_task_types=selected_task_types,
outcome_tracker=outcome_tracker, outcome_tracker=outcome_tracker,
submission_exclusion_store=submission_exclusion_store,
read_concurrency=max(1, args.read_concurrency), read_concurrency=max(1, args.read_concurrency),
) )
candidates.extend(stage_candidates) candidates.extend(stage_candidates)
@@ -767,6 +850,7 @@ def run_submission(
submitted: list[dict[str, Any]] = [] submitted: list[dict[str, Any]] = []
duplicate_candidates: list[dict[str, Any]] = [] duplicate_candidates: list[dict[str, Any]] = []
uniqueness_rejected_candidates: list[dict[str, Any]] = []
target_submit_count = resolve_max_submit_count( target_submit_count = resolve_max_submit_count(
args=args, args=args,
planned_count=len(candidates), planned_count=len(candidates),
@@ -806,7 +890,9 @@ def run_submission(
candidate candidate
for candidate in diversified_candidates for candidate in diversified_candidates
if candidate_key(candidate) not in attempted_keys 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( desired_count = min(
target_submit_count - len(submitted), target_submit_count - len(submitted),
max_submit_attempts - len(attempted_candidates), max_submit_attempts - len(attempted_candidates),
@@ -843,12 +929,30 @@ def run_submission(
batch_submitted_candidates: list[dict[str, Any]] = [] batch_submitted_candidates: list[dict[str, Any]] = []
batch_duplicate_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]] = [] batch_failed_candidates: list[dict[str, Any]] = []
for index in range(len(batch_candidates)): for index in range(len(batch_candidates)):
result = ordered_results.get(index) result = ordered_results.get(index)
if result is None: if result is None:
continue continue
candidate = result["candidate"] 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": if result["outcome"] == "duplicate":
duplicate_candidates.append(candidate) duplicate_candidates.append(candidate)
batch_duplicate_candidates.append(candidate) batch_duplicate_candidates.append(candidate)
@@ -860,7 +964,7 @@ def run_submission(
} }
) )
continue continue
if result["outcome"] == "failed": if result["outcome"] in {"failed", "precheck_deferred"}:
batch_failed_candidates.append(candidate) batch_failed_candidates.append(candidate)
failed.append( failed.append(
{ {
@@ -903,7 +1007,9 @@ def run_submission(
submit_time=result["submitTime"], 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) claim_store.release(batch_failed_candidates)
if strategy_manager is not None: if strategy_manager is not None:
strategy_manager.record_accepted(batch_submitted_candidates) strategy_manager.record_accepted(batch_submitted_candidates)
@@ -950,11 +1056,13 @@ def run_submission(
), ),
"plannedSubmitCount": len(attempted_candidates), "plannedSubmitCount": len(attempted_candidates),
"duplicateCount": len(duplicate_candidates), "duplicateCount": len(duplicate_candidates),
"modelGpuUniquenessRejectedCount": len(uniqueness_rejected_candidates),
"submittedCount": len(submitted), "submittedCount": len(submitted),
"skippedCount": len(skipped), "skippedCount": len(skipped),
"skipReasonCounts": dict(skip_reason_counts.most_common()), "skipReasonCounts": dict(skip_reason_counts.most_common()),
"failedCount": len(failed), "failedCount": len(failed),
"failureReasonCounts": dict(failure_reason_counts.most_common()), "failureReasonCounts": dict(failure_reason_counts.most_common()),
"submissionExclusionsPath": str(submission_exclusion_store.path),
"submitConcurrencyUsed": submit_workers, "submitConcurrencyUsed": submit_workers,
"warnings": report.get("warnings", []), "warnings": report.get("warnings", []),
"runDir": str(run_dir), "runDir": str(run_dir),

View File

@@ -21,6 +21,23 @@ class ModelHubAPIError(RuntimeError):
self.payload = payload 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: class ModelHubClient:
def __init__( def __init__(
self, self,
@@ -42,9 +59,13 @@ class ModelHubClient:
retries=retries, 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}) 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: 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) gpu_result = self.get_verify_result_map(model_id).get(target_gpu)
if gpu_result is None: if gpu_result is None:
@@ -56,7 +77,7 @@ class ModelHubClient:
return ((payload.get("data") or {}).get("verifyResult") or {}) return ((payload.get("data") or {}).get("verifyResult") or {})
def processed_gpus_for_model(self, model_id: str) -> set[str]: 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( def list_tasks_page(
self, self,
@@ -256,6 +277,16 @@ DUPLICATE_SUBMISSION_MARKERS = (
"already in progress", "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") 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) 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: class ModelHubClientPool:
def __init__( def __init__(
self, self,
@@ -385,13 +423,6 @@ class ModelHubClientPool:
except Exception: except Exception:
return max(0, max_count - 1) 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]]: def _safe_list_tasks(self, client: ModelHubClient, kwargs: dict[str, Any]) -> list[dict[str, Any]]:
try: try:
return client.list_tasks(**kwargs) return client.list_tasks(**kwargs)
@@ -546,29 +577,33 @@ class ModelHubClientPool:
if now - cached_at >= self._verify_cache_ttl: if now - cached_at >= self._verify_cache_ttl:
self._verify_cache.pop(model_id, None) 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. # Reuse recent model verification results across short poll cycles.
with self._state_lock: if not force_refresh:
cached = self._read_from_cache(model_id) with self._state_lock:
if cached is not None: cached = self._read_from_cache(model_id)
return cached if cached is not None:
return cached
# Only query ONE client (the reader) instead of fanning out to all clients. # Only query ONE client (the reader) instead of fanning out to all clients.
# verifyResult is model-specific platform data, not account-specific. # 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): 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: with self._state_lock:
self._write_to_cache(model_id, response) self._write_to_cache(model_id, response)
return 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]: def get_verify_result_map(self, model_id: str) -> dict[str, Any]:
payload = self.search_by_model_id(model_id) payload = self.search_by_model_id(model_id)
return ((payload.get("data") or {}).get("verifyResult") or {}) return ((payload.get("data") or {}).get("verifyResult") or {})
def processed_gpus_for_model(self, model_id: str) -> set[str]: 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: def _reserve_account(self, excluded: set[int]) -> tuple[int, int] | None:
with self._state_lock: with self._state_lock:

View File

@@ -74,6 +74,11 @@ def build_parser() -> argparse.ArgumentParser:
default=os.getenv("MODELHUB_AGENT_CLAIMS_PATH", str(DEFAULT_CLAIMS_PATH)), default=os.getenv("MODELHUB_AGENT_CLAIMS_PATH", str(DEFAULT_CLAIMS_PATH)),
help=argparse.SUPPRESS, 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-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("--history-archive-limit", type=int, default=5000, help=argparse.SUPPRESS)
parser.add_argument( parser.add_argument(

View 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)

View File

@@ -1 +1 @@
AGENT_VERSION = "2026.08.02.4" AGENT_VERSION = "2026.08.02.5"

View 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()