#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import queue import sys import threading import time from concurrent.futures import Future from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any JUDGE_ROOT = Path(__file__).resolve().parent if str(JUDGE_ROOT) not in sys.path: sys.path.insert(0, str(JUDGE_ROOT)) from contract import ( # noqa: E402 JUDGER_GENERATION, JUDGER_MODEL_PATH, JUDGER_PROMPT_HASH, JUDGER_SYSTEM_PROMPT, JUDGER_USER_PROMPT, judger_metadata, parse_score_payload, ) NON_THINKING_PREFIX = "\n\n\n\n" def strip_non_thinking_prefix(text: str) -> str: raw = str(text or "") if raw.startswith(NON_THINKING_PREFIX): return raw[len(NON_THINKING_PREFIX) :] return raw def build_infer_request_payload(image_path: str) -> dict[str, Any]: return { "messages": [ {"role": "system", "content": JUDGER_SYSTEM_PROMPT}, {"role": "user", "content": f"{JUDGER_USER_PROMPT}"}, ], "images": [image_path], "chat_template_kwargs": {"enable_thinking": False}, } def build_request_config_kwargs() -> dict[str, Any]: return { key: JUDGER_GENERATION[key] for key in ( "max_tokens", "temperature", "top_p", "top_k", "repetition_penalty", "presence_penalty", "seed", "return_details", ) } def summarize_completions(outputs: list[dict[str, Any] | str]) -> dict[str, Any]: records: list[dict[str, Any]] = [] valid_scores: list[float] = [] for output in outputs: if isinstance(output, dict): completion = str(output.get("completion") or "") record = dict(output) else: completion = str(output) record = {} parsed = parse_score_payload(completion) score = parsed["score"] errors = parsed["errors"] if score is not None: valid_scores.append(float(score)) record.update( { "completion": completion, "score": score, "errors": errors, "rating_text": parsed["rating_text"], "rating_format_ok": parsed["rating_format_ok"], "rating_representation": parsed["rating_representation"], "rating_format_warning": parsed["rating_format_warning"], "rating_prompt_range_ok": parsed["rating_prompt_range_ok"], "rating_range_warning": parsed["rating_range_warning"], "reasoning_evidence": parsed["reasoning_evidence"], "reasoning_solution": parsed["reasoning_solution"], } ) records.append(record) return { "status": "success" if valid_scores else "unparsed", "mean": ( sum(valid_scores) / len(valid_scores) if valid_scores else None ), "valid_count": len(valid_scores), "requested_count": len(outputs), "outputs": records, } class FrozenJudger: def __init__(self, model_path: str) -> None: if model_path != JUDGER_MODEL_PATH: raise RuntimeError( "Judge model path does not match the cache-compatible contract: " f"{model_path!r} != {JUDGER_MODEL_PATH!r}" ) os.environ["MAX_PIXELS"] = str(JUDGER_GENERATION["max_pixels"]) os.environ["MIN_PIXELS"] = str(JUDGER_GENERATION["min_pixels"]) os.environ["IMAGE_MAX_TOKEN_NUM"] = str( int(JUDGER_GENERATION["max_pixels"]) // 1024 ) os.environ["IMAGE_MIN_TOKEN_NUM"] = str( max(1, int(JUDGER_GENERATION["min_pixels"]) // 1024) ) from swift.infer_engine import InferRequest, RequestConfig, VllmEngine self._InferRequest = InferRequest self._request_config = RequestConfig(**build_request_config_kwargs()) self._engine = VllmEngine( model_path, tensor_parallel_size=int( JUDGER_GENERATION["tensor_parallel_size"] ), gpu_memory_utilization=float( JUDGER_GENERATION["gpu_memory_utilization"] ), max_model_len=int(JUDGER_GENERATION["max_model_len"]), max_num_seqs=int(JUDGER_GENERATION["max_num_seqs"]), enforce_eager=bool(JUDGER_GENERATION["enforce_eager"]), limit_mm_per_prompt=dict( JUDGER_GENERATION["limit_mm_per_prompt"] ), seed=int(JUDGER_GENERATION["seed"]), ) self._max_batch_size = int(os.environ.get("VF_JUDGER_MAX_BATCH_SIZE", "1")) self._batch_wait_ms = float(os.environ.get("VF_JUDGER_BATCH_WAIT_MS", "0")) if not 1 <= self._max_batch_size <= int(JUDGER_GENERATION["max_num_seqs"]): raise RuntimeError( "Judge max batch size must be in [1, max_num_seqs]: " f"batch={self._max_batch_size}, " f"max_num_seqs={JUDGER_GENERATION['max_num_seqs']}" ) if not 0 <= self._batch_wait_ms <= 100: raise RuntimeError("Judge batch wait must be in [0, 100] milliseconds") self._queue: queue.Queue[_ScoreJob] = queue.Queue() self._batch_index = 0 self._worker = threading.Thread( target=self._batch_loop, name="vf-frozen-judger-batcher", daemon=True, ) self._worker.start() @staticmethod def _completion_record(response: Any) -> dict[str, Any]: choice = response.choices[0] content = choice.message.content completion = strip_non_thinking_prefix( content if isinstance(content, str) else str(content or "") ).strip() return { "completion": completion, "finish_reason": choice.finish_reason, "prompt_token_count": len(response.prompt_token_ids or []), "completion_token_count": len(choice.token_ids or []), } def _batch_loop(self) -> None: while True: first = self._queue.get() jobs = [first] deadline = time.perf_counter() + self._batch_wait_ms / 1000.0 while len(jobs) < self._max_batch_size: remaining = deadline - time.perf_counter() if remaining <= 0: break try: jobs.append(self._queue.get(timeout=remaining)) except queue.Empty: break batch_started = time.perf_counter() self._batch_index += 1 batch_index = self._batch_index requests: list[Any] = [] owners: list[int] = [] try: for owner, job in enumerate(jobs): for _ in range(job.repeats): requests.append( self._InferRequest( **build_infer_request_payload(job.image_path) ) ) owners.append(owner) responses = self._engine.infer( requests, request_config=self._request_config, use_tqdm=False, ) if len(responses) != len(owners): raise RuntimeError( "Judge batched inference response count mismatch: " f"responses={len(responses)}, requests={len(owners)}" ) grouped: list[list[dict[str, Any]]] = [[] for _ in jobs] for owner, response in zip(owners, responses): grouped[owner].append(self._completion_record(response)) batch_runtime = time.perf_counter() - batch_started for job, outputs in zip(jobs, grouped): result = summarize_completions(outputs) result.update( { "image_path": job.image_path, "runtime_sec": time.perf_counter() - job.submitted_at, "queue_wait_sec": batch_started - job.submitted_at, "batch_runtime_sec": batch_runtime, "batch_size": len(requests), "batch_request_count": len(jobs), "batch_index": batch_index, "judger": judger_metadata(), } ) job.future.set_result(result) except Exception as exc: for job in jobs: if not job.future.done(): job.future.set_exception(exc) finally: for _ in jobs: self._queue.task_done() def score_image(self, image_path: str, repeats: int) -> dict[str, Any]: path = Path(image_path) if not path.is_file(): raise FileNotFoundError(f"Judge input image does not exist: {path}") if not 1 <= repeats <= 4: raise ValueError("repeats must be in [1, 4]") future: Future[dict[str, Any]] = Future() self._queue.put( _ScoreJob( image_path=str(path.resolve()), repeats=repeats, submitted_at=time.perf_counter(), future=future, ) ) timeout = float(os.environ.get("VF_JUDGER_REQUEST_TIMEOUT_SEC", "900")) return future.result(timeout=timeout) def batching_metadata(self) -> dict[str, Any]: return { "schema_version": "vf_frozen_judger_dynamic_batch_v1", "max_batch_size": self._max_batch_size, "batch_wait_ms": self._batch_wait_ms, "max_num_seqs": int(JUDGER_GENERATION["max_num_seqs"]), "queue_depth": self._queue.qsize(), } class _ScoreJob: def __init__( self, *, image_path: str, repeats: int, submitted_at: float, future: Future[dict[str, Any]], ) -> None: self.image_path = image_path self.repeats = repeats self.submitted_at = submitted_at self.future = future class JudgerHandler(BaseHTTPRequestHandler): server_version = "VFFrozenJudger/2" def _write_json(self, status: HTTPStatus, payload: dict[str, Any]) -> None: body = json.dumps( payload, ensure_ascii=True, allow_nan=False, sort_keys=True, ).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self) -> None: if self.path != "/health": self._write_json(HTTPStatus.NOT_FOUND, {"error": "not_found"}) return metadata = judger_metadata() self._write_json( HTTPStatus.OK, { "ready": True, "backend": metadata["backend"], "model_id": metadata["model_id"], "model_path": metadata["model_path"], "model_tree_sha256": metadata["model_tree_sha256"], "prompt_hash": metadata["prompt_hash"], "generation": metadata["generation"], "batching": self.server.judger.batching_metadata(), # type: ignore[attr-defined] "judger": metadata, }, ) def do_POST(self) -> None: if self.path != "/score_image": self._write_json(HTTPStatus.NOT_FOUND, {"error": "not_found"}) return try: length = int(self.headers.get("Content-Length", "0")) payload = json.loads(self.rfile.read(length)) image_path = payload["image_path"] repeats = int(payload.get("repeats", 1)) result = self.server.judger.score_image(image_path, repeats) # type: ignore[attr-defined] except (FileNotFoundError, KeyError, TypeError, ValueError) as exc: self._write_json( HTTPStatus.BAD_REQUEST, {"error": f"{type(exc).__name__}: {exc}"}, ) return except Exception as exc: self._write_json( HTTPStatus.INTERNAL_SERVER_ERROR, {"error": f"{type(exc).__name__}: {exc}"}, ) return self._write_json(HTTPStatus.OK, result) def log_message(self, format: str, *args: Any) -> None: sys.stderr.write( "%s - - [%s] %s\n" % ( self.address_string(), self.log_date_time_string(), format % args, ) ) sys.stderr.flush() def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, required=True) parser.add_argument("--model-path", default=JUDGER_MODEL_PATH) return parser.parse_args() def main() -> int: args = parse_args() judger = FrozenJudger(args.model_path) server = ThreadingHTTPServer((args.host, args.port), JudgerHandler) server.judger = judger # type: ignore[attr-defined] metadata = judger_metadata() print( json.dumps( { "event": "judger_ready", "host": args.host, "port": args.port, "model_id": metadata["model_id"], "model_path": metadata["model_path"], "prompt_hash": JUDGER_PROMPT_HASH, }, sort_keys=True, ), flush=True, ) try: server.serve_forever() finally: server.server_close() return 0 if __name__ == "__main__": raise SystemExit(main())