v3.8 model serving 部署成功
This commit is contained in:
@@ -24,3 +24,9 @@ v3.0 访问入口(dev/h1):
|
||||
- SFTPGo:
|
||||
- SFTP:`127.0.0.1:2022`
|
||||
- Admin API/UI:`http://127.0.0.1:8081`(容器内 8080,host 映射到 8081 避免与 API server 冲突)
|
||||
|
||||
v3.8(Ray Serve LLM / vLLM 模型服务):
|
||||
- 推理端口:`8000`(Ray Serve HTTP)
|
||||
- OpenAI-compatible endpoint:`http://127.0.0.1:8000/v1`
|
||||
- 注意:v3.8 推理接口**不做鉴权**
|
||||
- E2E 脚本:`scripts/run_all_v38_serving.sh`
|
||||
|
||||
@@ -69,3 +69,11 @@ data:
|
||||
jobs_trash_after_days: 3
|
||||
jobs_purge_after_days: 7
|
||||
janitor_interval_s: 3600
|
||||
|
||||
# v3.8: model serving via Ray Serve LLM (vLLM backend)
|
||||
serving:
|
||||
serve:
|
||||
http_port: 8000
|
||||
proxy_location: HeadOnly
|
||||
llm:
|
||||
accelerator_type: H20
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
services:
|
||||
ray_head:
|
||||
image: argus/argus-ray-node:vllm011.latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: images/argus-ray-node/Dockerfile
|
||||
args:
|
||||
BASE_IMAGE: verlai/verl:vllm011.latest
|
||||
container_name: argus-ray-head
|
||||
ports:
|
||||
- "8265:8265"
|
||||
- "8080:8080"
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
# NOTE: this compose file is intended for the dev env layout like:
|
||||
# /home2/argus/infra/mvp/{shared,verl,src/mvp}
|
||||
@@ -92,6 +98,11 @@ services:
|
||||
|
||||
ray_worker_0:
|
||||
image: argus/argus-ray-node:vllm011.latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: images/argus-ray-node/Dockerfile
|
||||
args:
|
||||
BASE_IMAGE: verlai/verl:vllm011.latest
|
||||
container_name: argus-ray-worker-0
|
||||
volumes:
|
||||
- ../../verl:/workspace/verl
|
||||
@@ -124,6 +135,11 @@ services:
|
||||
|
||||
ray_worker_1:
|
||||
image: argus/argus-ray-node:vllm011.latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: images/argus-ray-node/Dockerfile
|
||||
args:
|
||||
BASE_IMAGE: verlai/verl:vllm011.latest
|
||||
container_name: argus-ray-worker-1
|
||||
volumes:
|
||||
- ../../verl:/workspace/verl
|
||||
|
||||
@@ -6,6 +6,15 @@ SHELL ["/bin/bash", "-lc"]
|
||||
# Install supervisord (prefer pip to avoid relying on distro package manager).
|
||||
RUN python3 -m pip install --no-cache-dir supervisor
|
||||
|
||||
# v3.8: Ray Serve LLM deps (keep Ray version pinned to what's already in the base image).
|
||||
# NOTE: base image already includes Ray; we only add extras.
|
||||
RUN RAY_VER="$(python3 -c 'import ray; print(ray.__version__)')" && \
|
||||
python3 -m pip install --no-cache-dir "ray[serve,llm]==${RAY_VER}"
|
||||
# Ray Serve LLM's import chain currently pulls in ray.rllib which requires extra deps.
|
||||
# Install them explicitly to make `from ray.serve.llm import ...` work reliably.
|
||||
RUN python3 -m pip install --no-cache-dir gymnasium dm-tree && \
|
||||
python3 -c "from ray.serve.llm import LLMConfig, build_openai_app; print('ray_serve_llm_ok')"
|
||||
|
||||
RUN mkdir -p /opt/argus/py/argus/ray
|
||||
|
||||
# Minimal embedded code for stateless pool (API code is intentionally excluded).
|
||||
|
||||
@@ -16,9 +16,8 @@ exec ray start \
|
||||
--port="${ray_port}" \
|
||||
--dashboard-host=0.0.0.0 \
|
||||
--dashboard-port="${dashboard_port}" \
|
||||
--num-cpus=0 \
|
||||
--num-cpus="${ARGUS_HEAD_NUM_CPUS:-1}" \
|
||||
--num-gpus=0 \
|
||||
--disable-usage-stats \
|
||||
--block \
|
||||
${ARGUS_RAY_EXTRA_ARGS:-}
|
||||
|
||||
|
||||
@@ -26,3 +26,19 @@ def new_task_id(workload: str, *, user_id: str | None = None) -> str:
|
||||
|
||||
def attempt_submission_id(task_id: str, attempt_no: int) -> str:
|
||||
return f"{task_id}--a{attempt_no:02d}"
|
||||
|
||||
|
||||
def new_model_key(*, user_id: str) -> str:
|
||||
"""
|
||||
Internal identifier for a serving model record.
|
||||
|
||||
Note:
|
||||
- model_id is the OpenAI-facing name (user_id + timestamp prefix + suffix).
|
||||
- model_key is used for stable DB identity and API resource path.
|
||||
"""
|
||||
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
suffix = secrets.token_hex(2)
|
||||
u = _normalize_user_id(user_id)
|
||||
if not u:
|
||||
raise ValueError("user_id is required")
|
||||
return f"mvp2-{u}-serve-{ts}-{suffix}"
|
||||
|
||||
@@ -4,11 +4,13 @@ import os
|
||||
import secrets
|
||||
import threading
|
||||
from typing import Any
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
|
||||
import yaml
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
|
||||
from argus.core.ids import new_task_id
|
||||
from argus.core.ids import new_model_key, new_task_id
|
||||
from argus.ray.models import AdvancedTaskSpec, JobSpec, RayConfig, parse_taskspec
|
||||
|
||||
from .advanced_command import expand_advanced_command, validate_advanced_command
|
||||
@@ -16,6 +18,7 @@ from .config import V2Config
|
||||
from .db import Db
|
||||
from .janitor import JobsJanitor
|
||||
from .scheduler import Scheduler
|
||||
from .serving_spec import ServingSpec, parse_serving_spec, resolve_serving_spec
|
||||
from .sftpgo import SFTPGoAdminClient, SFTPGoError
|
||||
from .ui import register_ui_routes
|
||||
|
||||
@@ -85,6 +88,61 @@ def create_app(config_path: str) -> FastAPI:
|
||||
common_root=f"{shared_root}/common",
|
||||
)
|
||||
|
||||
def _serving_enabled() -> bool:
|
||||
return bool(v2_cfg.serving.enabled)
|
||||
|
||||
def _openai_base_url(req: Request) -> str:
|
||||
# Prefer forwarded headers if present; otherwise fall back to Host.
|
||||
host = req.headers.get("x-forwarded-host") or req.headers.get("host") or req.url.hostname or "127.0.0.1"
|
||||
# Strip port if present (common for Host header).
|
||||
hostname = host
|
||||
if hostname.startswith("[") and "]" in hostname:
|
||||
# IPv6 like: [::1]:8080
|
||||
hostname = hostname.split("]")[0] + "]"
|
||||
else:
|
||||
hostname = hostname.split(":")[0]
|
||||
scheme = req.headers.get("x-forwarded-proto") or req.url.scheme or "http"
|
||||
port = int(v2_cfg.serving.serve.http_port)
|
||||
return f"{scheme}://{hostname}:{port}/v1"
|
||||
|
||||
def _dump_yaml(obj: Any) -> str:
|
||||
return yaml.safe_dump(obj, sort_keys=False)
|
||||
|
||||
def _serving_spec_to_dict(spec: ServingSpec) -> dict[str, Any]:
|
||||
return {
|
||||
"model_id": spec.model_id,
|
||||
"model_source": spec.model_source,
|
||||
"num_replicas": int(spec.num_replicas),
|
||||
"gpus_per_replica": int(spec.gpus_per_replica),
|
||||
"engine_kwargs": spec.engine_kwargs,
|
||||
}
|
||||
|
||||
def _serve_model_public(row: dict[str, Any], *, req: Request) -> dict[str, Any]:
|
||||
num_replicas = int(row.get("num_replicas") or 0)
|
||||
gpus_per_replica = int(row.get("gpus_per_replica") or 0)
|
||||
total_gpus = num_replicas * gpus_per_replica
|
||||
model_id = str(row.get("model_id") or "")
|
||||
return {
|
||||
"model_key": str(row.get("model_key") or ""),
|
||||
"user_id": str(row.get("user_id") or ""),
|
||||
"model_id": model_id,
|
||||
"model_id_suffix": str(row.get("model_id_suffix") or ""),
|
||||
"model_id_prefix": str(row.get("model_id_prefix") or ""),
|
||||
"model_source": str(row.get("model_source") or ""),
|
||||
"num_replicas": num_replicas,
|
||||
"gpus_per_replica": gpus_per_replica,
|
||||
"total_gpus": total_gpus,
|
||||
"state": str(row.get("state") or ""),
|
||||
"error_summary": row.get("error_summary"),
|
||||
"created_at": str(row.get("created_at") or ""),
|
||||
"updated_at": str(row.get("updated_at") or ""),
|
||||
"deleted_at": row.get("deleted_at"),
|
||||
"endpoint": {
|
||||
"openai_base_url": _openai_base_url(req),
|
||||
"model": model_id,
|
||||
},
|
||||
}
|
||||
|
||||
def _auth(req: Request) -> dict[str, Any]:
|
||||
token_env = v2_cfg.auth.token_env
|
||||
admin_token = os.environ.get(token_env, "")
|
||||
@@ -565,6 +623,162 @@ def create_app(config_path: str) -> FastAPI:
|
||||
return db.list_queue()
|
||||
return db.list_queue(user_id=str(subject["user_id"]))
|
||||
|
||||
# v3.8: Model serving (Ray Serve LLM) management APIs.
|
||||
@app.post("/api/v2/serve/models")
|
||||
async def create_serve_model(req: Request) -> dict[str, Any]:
|
||||
subject = _auth(req)
|
||||
if not _serving_enabled():
|
||||
raise HTTPException(status_code=400, detail="serving is not enabled")
|
||||
|
||||
body = (await req.body()).decode("utf-8")
|
||||
try:
|
||||
obj = yaml.safe_load(body) or {}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"invalid YAML: {e!r}")
|
||||
if not isinstance(obj, dict):
|
||||
raise HTTPException(status_code=400, detail="serving spec must be a YAML mapping")
|
||||
|
||||
user_id = str(subject["user_id"]).strip()
|
||||
try:
|
||||
spec = parse_serving_spec(obj)
|
||||
resolved = resolve_serving_spec(spec=spec, user_id=user_id)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
except ValueError as e:
|
||||
msg = str(e)
|
||||
code = 422 if ("num_replicas" in msg or "gpus_per_replica" in msg) else 400
|
||||
raise HTTPException(status_code=code, detail=f"invalid serving spec: {e!r}")
|
||||
|
||||
model_key = new_model_key(user_id=user_id)
|
||||
try:
|
||||
engine_kwargs_json = json.dumps(resolved.engine_kwargs, sort_keys=True) if resolved.engine_kwargs is not None else None
|
||||
except TypeError as e:
|
||||
raise HTTPException(status_code=400, detail=f"engine_kwargs must be JSON-serializable: {e!r}")
|
||||
|
||||
spec_yaml = _dump_yaml(_serving_spec_to_dict(spec))
|
||||
resolved_spec_yaml = _dump_yaml(asdict(resolved))
|
||||
|
||||
db.create_serve_model(
|
||||
model_key=model_key,
|
||||
user_id=user_id,
|
||||
model_id_suffix=resolved.model_id_suffix,
|
||||
model_id_prefix=resolved.model_id_prefix,
|
||||
model_id=resolved.model_id,
|
||||
model_source=resolved.model_source,
|
||||
num_replicas=resolved.num_replicas,
|
||||
gpus_per_replica=resolved.gpus_per_replica,
|
||||
engine_kwargs_json=engine_kwargs_json,
|
||||
spec_yaml=spec_yaml,
|
||||
resolved_spec_yaml=resolved_spec_yaml,
|
||||
)
|
||||
return {"model_key": model_key, "state": "QUEUED"}
|
||||
|
||||
@app.get("/api/v2/serve/models")
|
||||
async def list_serve_models(req: Request, limit: int = 200, offset: int = 0, include_deleted: int = 0) -> dict[str, Any]:
|
||||
subject = _auth(req)
|
||||
if not _serving_enabled():
|
||||
raise HTTPException(status_code=400, detail="serving is not enabled")
|
||||
|
||||
lim = max(1, min(int(limit), 1000))
|
||||
off = max(0, int(offset))
|
||||
inc = bool(int(include_deleted))
|
||||
user_id = str(subject["user_id"])
|
||||
|
||||
items = db.list_serve_models(user_id=user_id, include_deleted=inc, limit=lim, offset=off)
|
||||
out = [_serve_model_public(i, req=req) for i in items]
|
||||
return {
|
||||
"items": out,
|
||||
"openai_base_url": _openai_base_url(req),
|
||||
"limit": lim,
|
||||
"offset": off,
|
||||
"has_more": bool(len(items) == lim),
|
||||
}
|
||||
|
||||
@app.get("/api/v2/serve/models/{model_key}")
|
||||
async def get_serve_model(model_key: str, req: Request) -> dict[str, Any]:
|
||||
subject = _auth(req)
|
||||
if not _serving_enabled():
|
||||
raise HTTPException(status_code=400, detail="serving is not enabled")
|
||||
|
||||
row = db.get_serve_model(model_key)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="model not found")
|
||||
if not subject.get("is_admin"):
|
||||
if str(row.get("user_id") or "") != str(subject["user_id"]):
|
||||
raise HTTPException(status_code=404, detail="model not found")
|
||||
|
||||
events = db.list_serve_events(model_key, limit=200, offset=0)
|
||||
ev_out = [
|
||||
{
|
||||
"id": int(e.get("id") or 0),
|
||||
"model_key": str(e.get("model_key") or ""),
|
||||
"created_at": str(e.get("ts") or ""),
|
||||
"event_type": str(e.get("event_type") or ""),
|
||||
"payload_json": e.get("payload_json"),
|
||||
}
|
||||
for e in events
|
||||
]
|
||||
return {
|
||||
"model": _serve_model_public(row, req=req),
|
||||
"resolved_spec_yaml": str(row.get("resolved_spec_yaml") or ""),
|
||||
"events": ev_out,
|
||||
"serve_status": None,
|
||||
}
|
||||
|
||||
@app.patch("/api/v2/serve/models/{model_key}")
|
||||
async def patch_serve_model(model_key: str, req: Request) -> dict[str, Any]:
|
||||
subject = _auth(req)
|
||||
if not _serving_enabled():
|
||||
raise HTTPException(status_code=400, detail="serving is not enabled")
|
||||
|
||||
row = db.get_serve_model(model_key)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="model not found")
|
||||
if not subject.get("is_admin"):
|
||||
if str(row.get("user_id") or "") != str(subject["user_id"]):
|
||||
raise HTTPException(status_code=404, detail="model not found")
|
||||
|
||||
obj = await req.json()
|
||||
if not isinstance(obj, dict):
|
||||
raise HTTPException(status_code=400, detail="body must be a JSON object")
|
||||
if "num_replicas" not in obj:
|
||||
raise HTTPException(status_code=400, detail="missing num_replicas")
|
||||
num_replicas = obj.get("num_replicas")
|
||||
if not isinstance(num_replicas, int) or int(num_replicas) < 1:
|
||||
raise HTTPException(status_code=422, detail="num_replicas must be an integer >= 1")
|
||||
|
||||
db.update_serve_model_num_replicas(model_key=model_key, num_replicas=int(num_replicas))
|
||||
return {"model_key": model_key, "state": "QUEUED"}
|
||||
|
||||
@app.delete("/api/v2/serve/models/{model_key}")
|
||||
async def delete_serve_model(model_key: str, req: Request) -> dict[str, Any]:
|
||||
subject = _auth(req)
|
||||
if not _serving_enabled():
|
||||
raise HTTPException(status_code=400, detail="serving is not enabled")
|
||||
|
||||
row = db.get_serve_model(model_key)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="model not found")
|
||||
if not subject.get("is_admin"):
|
||||
if str(row.get("user_id") or "") != str(subject["user_id"]):
|
||||
raise HTTPException(status_code=404, detail="model not found")
|
||||
|
||||
db.set_serve_model_state(model_key=model_key, state="DELETING", event_type="SERVE_DELETE_REQUESTED")
|
||||
return {"model_key": model_key, "state": "DELETING"}
|
||||
|
||||
@app.get("/api/v2/serve/status")
|
||||
async def serve_status(req: Request) -> dict[str, Any]:
|
||||
_require_admin(req)
|
||||
if not _serving_enabled():
|
||||
raise HTTPException(status_code=400, detail="serving is not enabled")
|
||||
return {
|
||||
"enabled": True,
|
||||
"openai_base_url": _openai_base_url(req),
|
||||
"http_port": int(v2_cfg.serving.serve.http_port),
|
||||
"proxy_location": str(v2_cfg.serving.serve.proxy_location),
|
||||
"accelerator_type": str(v2_cfg.serving.llm.accelerator_type),
|
||||
}
|
||||
|
||||
# v3.0: minimal WebUI (no server-side session; token stored in browser localStorage).
|
||||
register_ui_routes(app)
|
||||
|
||||
|
||||
@@ -57,6 +57,24 @@ class V2SFTPGoConfig:
|
||||
admin_password_env: str = "SFTPGO_ADMIN_PASSWORD"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class V2ServingServeConfig:
|
||||
http_port: int = 8000
|
||||
proxy_location: str = "HeadOnly"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class V2ServingLLMConfig:
|
||||
accelerator_type: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class V2ServingConfig:
|
||||
enabled: bool = False
|
||||
serve: V2ServingServeConfig = V2ServingServeConfig()
|
||||
llm: V2ServingLLMConfig = V2ServingLLMConfig()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class V2DataConfig:
|
||||
user_root: str
|
||||
@@ -72,6 +90,7 @@ class V2Config:
|
||||
scheduler: V2SchedulerConfig
|
||||
tracking: V2TrackingConfig
|
||||
data: V2DataConfig
|
||||
serving: V2ServingConfig
|
||||
|
||||
@staticmethod
|
||||
def from_root_dict(root: dict[str, Any]) -> "V2Config":
|
||||
@@ -112,6 +131,15 @@ class V2Config:
|
||||
if not isinstance(sftpgo, dict) or not isinstance(retention, dict):
|
||||
raise ValueError("config.data.{sftpgo,retention} must be mappings")
|
||||
|
||||
serving = root.get("serving") or {}
|
||||
if not isinstance(serving, dict):
|
||||
raise ValueError("config.serving must be a mapping")
|
||||
serving_enabled = bool(serving.get("enabled")) if "enabled" in serving else bool(serving)
|
||||
serving_serve = serving.get("serve") or {}
|
||||
serving_llm = serving.get("llm") or {}
|
||||
if not isinstance(serving_serve, dict) or not isinstance(serving_llm, dict):
|
||||
raise ValueError("config.serving.{serve,llm} must be mappings")
|
||||
|
||||
default_db_path = f"{shared_root}/common/db/mvp.sqlite3"
|
||||
db_path = str(sqlite.get("db_path") or default_db_path)
|
||||
|
||||
@@ -158,4 +186,14 @@ class V2Config:
|
||||
janitor_interval_s=int(retention.get("janitor_interval_s") or 3600),
|
||||
),
|
||||
),
|
||||
serving=V2ServingConfig(
|
||||
enabled=serving_enabled,
|
||||
serve=V2ServingServeConfig(
|
||||
http_port=int(serving_serve.get("http_port") or 8000),
|
||||
proxy_location=str(serving_serve.get("proxy_location") or "HeadOnly"),
|
||||
),
|
||||
llm=V2ServingLLMConfig(
|
||||
accelerator_type=str(serving_llm.get("accelerator_type") or ""),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -117,6 +117,43 @@ class Db:
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS serve_models (
|
||||
model_key TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
model_id_suffix TEXT NOT NULL,
|
||||
model_id_prefix TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
model_source TEXT NOT NULL,
|
||||
num_replicas INTEGER NOT NULL,
|
||||
gpus_per_replica INTEGER NOT NULL,
|
||||
engine_kwargs_json TEXT,
|
||||
state TEXT NOT NULL,
|
||||
spec_yaml TEXT NOT NULL,
|
||||
resolved_spec_yaml TEXT NOT NULL,
|
||||
error_summary TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS serve_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model_key TEXT NOT NULL,
|
||||
ts TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT,
|
||||
FOREIGN KEY (model_key) REFERENCES serve_models(model_key) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_serve_models_user ON serve_models(user_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_serve_models_state ON serve_models(state)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_serve_events_model ON serve_events(model_key)")
|
||||
|
||||
@contextmanager
|
||||
def tx(self) -> Iterator[sqlite3.Connection]:
|
||||
@@ -493,3 +530,239 @@ class Db:
|
||||
(str(end_time_le), int(limit)),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def create_serve_model(
|
||||
self,
|
||||
*,
|
||||
model_key: str,
|
||||
user_id: str,
|
||||
model_id_suffix: str,
|
||||
model_id_prefix: str,
|
||||
model_id: str,
|
||||
model_source: str,
|
||||
num_replicas: int,
|
||||
gpus_per_replica: int,
|
||||
spec_yaml: str,
|
||||
resolved_spec_yaml: str,
|
||||
engine_kwargs_json: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
now = _utc_now_iso()
|
||||
with self.tx() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO serve_models (
|
||||
model_key,
|
||||
user_id,
|
||||
model_id_suffix,
|
||||
model_id_prefix,
|
||||
model_id,
|
||||
model_source,
|
||||
num_replicas,
|
||||
gpus_per_replica,
|
||||
engine_kwargs_json,
|
||||
state,
|
||||
spec_yaml,
|
||||
resolved_spec_yaml,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'QUEUED', ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
model_key,
|
||||
user_id,
|
||||
model_id_suffix,
|
||||
model_id_prefix,
|
||||
model_id,
|
||||
model_source,
|
||||
int(num_replicas),
|
||||
int(gpus_per_replica),
|
||||
engine_kwargs_json,
|
||||
spec_yaml,
|
||||
resolved_spec_yaml,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO serve_events (model_key, ts, event_type, payload_json) VALUES (?, ?, 'SERVE_MODEL_CREATED', ?)",
|
||||
(model_key, now, None),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM serve_models WHERE model_key = ?", (model_key,)).fetchone()
|
||||
return dict(row) if row else {}
|
||||
|
||||
def list_serve_models(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
include_deleted: bool = False,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._connect() as conn:
|
||||
where_sql = "WHERE user_id = ?"
|
||||
params: list[Any] = [user_id]
|
||||
if not include_deleted:
|
||||
where_sql += " AND deleted_at IS NULL"
|
||||
params.append(int(limit))
|
||||
params.append(max(0, int(offset)))
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT
|
||||
model_key,
|
||||
user_id,
|
||||
model_id_suffix,
|
||||
model_id_prefix,
|
||||
model_id,
|
||||
model_source,
|
||||
num_replicas,
|
||||
gpus_per_replica,
|
||||
engine_kwargs_json,
|
||||
state,
|
||||
error_summary,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at
|
||||
FROM serve_models
|
||||
{where_sql}
|
||||
ORDER BY created_at DESC, model_key DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
tuple(params),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def list_all_serve_models(
|
||||
self,
|
||||
*,
|
||||
include_deleted: bool = False,
|
||||
limit: int = 2000,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
with self._connect() as conn:
|
||||
where_sql = ""
|
||||
if not include_deleted:
|
||||
where_sql = "WHERE deleted_at IS NULL"
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT
|
||||
model_key,
|
||||
user_id,
|
||||
model_id_suffix,
|
||||
model_id_prefix,
|
||||
model_id,
|
||||
model_source,
|
||||
num_replicas,
|
||||
gpus_per_replica,
|
||||
engine_kwargs_json,
|
||||
state,
|
||||
error_summary,
|
||||
spec_yaml,
|
||||
resolved_spec_yaml,
|
||||
created_at,
|
||||
updated_at,
|
||||
deleted_at
|
||||
FROM serve_models
|
||||
{where_sql}
|
||||
ORDER BY created_at ASC, model_key ASC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(int(limit), max(0, int(offset))),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_serve_model(self, model_key: str) -> dict[str, Any] | None:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT * FROM serve_models WHERE model_key = ?", (model_key,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def list_serve_events(self, model_key: str, limit: int = 200, offset: int = 0) -> list[dict[str, Any]]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, model_key, ts, event_type, payload_json
|
||||
FROM serve_events
|
||||
WHERE model_key = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(model_key, int(limit), max(0, int(offset))),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def append_serve_event(self, *, model_key: str, event_type: str, payload_json: str | None = None) -> None:
|
||||
now = _utc_now_iso()
|
||||
with self.tx() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO serve_events (model_key, ts, event_type, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(model_key, now, event_type, payload_json),
|
||||
)
|
||||
|
||||
def set_serve_model_state(
|
||||
self,
|
||||
*,
|
||||
model_key: str,
|
||||
state: str,
|
||||
error_summary: str | None = None,
|
||||
event_type: str = "SERVE_STATE_UPDATE",
|
||||
payload_json: str | None = None,
|
||||
) -> None:
|
||||
now = _utc_now_iso()
|
||||
with self.tx() as conn:
|
||||
sets = ["state = ?", "updated_at = ?"]
|
||||
params: list[Any] = [state, now]
|
||||
if error_summary is not None:
|
||||
sets.append("error_summary = ?")
|
||||
params.append(error_summary)
|
||||
if state == "DELETED":
|
||||
sets.append("deleted_at = ?")
|
||||
params.append(now)
|
||||
params.append(model_key)
|
||||
conn.execute(f"UPDATE serve_models SET {', '.join(sets)} WHERE model_key = ?", tuple(params))
|
||||
conn.execute(
|
||||
"INSERT INTO serve_events (model_key, ts, event_type, payload_json) VALUES (?, ?, ?, ?)",
|
||||
(model_key, now, event_type, payload_json),
|
||||
)
|
||||
|
||||
def update_serve_model_num_replicas(self, *, model_key: str, num_replicas: int) -> None:
|
||||
if not isinstance(num_replicas, int) or num_replicas < 1:
|
||||
raise ValueError("num_replicas must be an integer >= 1")
|
||||
now = _utc_now_iso()
|
||||
with self.tx() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE serve_models
|
||||
SET num_replicas = ?, state = 'QUEUED', error_summary = NULL, updated_at = ?
|
||||
WHERE model_key = ?
|
||||
""",
|
||||
(int(num_replicas), now, model_key),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO serve_events (model_key, ts, event_type, payload_json) VALUES (?, ?, 'SERVE_PATCH_NUM_REPLICAS', ?)",
|
||||
(model_key, now, str(num_replicas)),
|
||||
)
|
||||
|
||||
def pick_next_runnable_serve_change(self) -> dict[str, Any] | None:
|
||||
"""
|
||||
Returns the next serve model that needs reconciliation.
|
||||
|
||||
Minimal state machine for now:
|
||||
- QUEUED: needs (re)apply
|
||||
- DELETING: needs removal
|
||||
"""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT *
|
||||
FROM serve_models
|
||||
WHERE deleted_at IS NULL
|
||||
AND state IN ('QUEUED','DELETING')
|
||||
ORDER BY updated_at ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
# Backward compatible naming (v3.8 docs originally used "upsert").
|
||||
def upsert_serve_model(self, **kwargs: Any) -> dict[str, Any]:
|
||||
return self.create_serve_model(**kwargs)
|
||||
|
||||
@@ -16,6 +16,8 @@ from argus.ray.ray_job_tool import RayJobTool
|
||||
from .config import V2Config
|
||||
from .db import Db
|
||||
from .ray_resources import ensure_ray_connected, get_cluster_available
|
||||
from .serve_client import RayServeClient
|
||||
from .serving_reconciler import ServingReconciler
|
||||
|
||||
|
||||
_INSUFFICIENT_RE = re.compile(r"Total available GPUs\\s+\\d+\\s+is less than total desired GPUs\\s+\\d+")
|
||||
@@ -37,6 +39,18 @@ class Scheduler:
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.tool = RayJobTool(self.ray_cfg)
|
||||
self._serving: ServingReconciler | None = None
|
||||
if bool(self.v2_cfg.serving.enabled):
|
||||
self._serving = ServingReconciler(
|
||||
db=self.db,
|
||||
v2_cfg=self.v2_cfg,
|
||||
ray_runtime_env_env_vars=self.ray_cfg.runtime_env_env_vars,
|
||||
serve_client=RayServeClient(
|
||||
http_port=int(self.v2_cfg.serving.serve.http_port),
|
||||
proxy_location=str(self.v2_cfg.serving.serve.proxy_location),
|
||||
ray_init_address="auto",
|
||||
),
|
||||
)
|
||||
|
||||
def _job_dir_for_task(self, *, user_id: str | None, ray_submission_id: str) -> str:
|
||||
root = self.ray_cfg.shared_root.rstrip("/")
|
||||
@@ -251,6 +265,14 @@ class Scheduler:
|
||||
def tick(self) -> None:
|
||||
ensure_ray_connected()
|
||||
|
||||
# v3.8: reconcile serve_models (best-effort).
|
||||
if self._serving is not None:
|
||||
try:
|
||||
self._serving.tick()
|
||||
except Exception:
|
||||
# Keep scheduler alive even if serving tick fails.
|
||||
pass
|
||||
|
||||
# Sync active tasks
|
||||
for row in self.db.list_active_tasks(limit=50):
|
||||
self._sync_one_running(row)
|
||||
|
||||
45
src/mvp/py/argus/service/serve_client.py
Normal file
45
src/mvp/py/argus/service/serve_client.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RayServeClient:
|
||||
"""
|
||||
Minimal Ray Serve client wrapper.
|
||||
|
||||
This is intentionally tiny and uses runtime imports so that:
|
||||
- unit tests can stub `ray` modules without needing real Ray installed
|
||||
- production can run with the real Ray Serve stack (v3.8+)
|
||||
"""
|
||||
|
||||
http_port: int = 8000
|
||||
proxy_location: str = "HeadOnly"
|
||||
ray_init_address: str = "auto"
|
||||
|
||||
def ensure_started(self) -> None:
|
||||
import ray # runtime import
|
||||
|
||||
# Scheduler already calls ray.init(); make this idempotent.
|
||||
ray.init(address=self.ray_init_address, ignore_reinit_error=True, log_to_driver=False) # type: ignore[call-arg]
|
||||
|
||||
# Import serve lazily to allow tests to stub it.
|
||||
from ray import serve # type: ignore
|
||||
|
||||
serve.start(proxy_location=self.proxy_location, http_options={"host": "0.0.0.0", "port": int(self.http_port)})
|
||||
|
||||
def apply_app(self, *, app: Any, app_name: str, route_prefix: str = "/") -> Any:
|
||||
from ray import serve # type: ignore
|
||||
|
||||
# If Ray Serve LLM isn't available, callers may pass a plain dict placeholder.
|
||||
# Running that through serve.run() results in a confusing TypeError; fail fast.
|
||||
if isinstance(app, dict):
|
||||
raise ValueError("invalid serve app object (Ray Serve LLM not available or build_openai_app failed)")
|
||||
|
||||
return serve.run(app, name=app_name, route_prefix=route_prefix)
|
||||
|
||||
def get_status(self) -> Any:
|
||||
from ray import serve # type: ignore
|
||||
|
||||
return serve.status()
|
||||
63
src/mvp/py/argus/service/serve_llm_config.py
Normal file
63
src/mvp/py/argus/service/serve_llm_config.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .serving_spec import ResolvedServingSpec
|
||||
|
||||
|
||||
def _ensure_hf_env_defaults(env: dict[str, str]) -> dict[str, str]:
|
||||
out = dict(env or {})
|
||||
# Prefer existing values if present, but always force offline mode in the platform.
|
||||
out.setdefault("HF_HOME", "/private/hf")
|
||||
out.setdefault("HUGGINGFACE_HUB_CACHE", "/private/hf/hub")
|
||||
out.setdefault("TRANSFORMERS_CACHE", "/private/hf/transformers")
|
||||
out["HF_HUB_OFFLINE"] = "1"
|
||||
return out
|
||||
|
||||
|
||||
def build_llm_config_dict(
|
||||
resolved: ResolvedServingSpec,
|
||||
*,
|
||||
accelerator_type: str,
|
||||
runtime_env_env_vars: dict[str, str] | None,
|
||||
cpu_per_gpu: float = 1.0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Pure builder: maps a platform ResolvedServingSpec to a Ray Serve LLM-like config.
|
||||
|
||||
We return a plain dict here to keep this layer unit-testable without depending on
|
||||
a specific Ray Serve LLM version. The reconciler (later milestone) can choose to
|
||||
instantiate `ray.serve.llm.LLMConfig` using this dict.
|
||||
"""
|
||||
if not accelerator_type:
|
||||
raise ValueError("accelerator_type is required")
|
||||
if resolved.num_replicas < 1:
|
||||
raise ValueError("num_replicas must be >= 1")
|
||||
if resolved.gpus_per_replica < 1:
|
||||
raise ValueError("gpus_per_replica must be >= 1")
|
||||
if cpu_per_gpu <= 0:
|
||||
raise ValueError("cpu_per_gpu must be > 0")
|
||||
|
||||
engine_kwargs: dict[str, Any] = dict(resolved.engine_kwargs or {})
|
||||
# Enforce tensor parallel mapping; user-provided value must not contradict requested GPUs.
|
||||
engine_kwargs["tensor_parallel_size"] = int(resolved.gpus_per_replica)
|
||||
|
||||
# Ray Serve LLM (Ray 2.49.x) exposes `resources_per_bundle` instead of the older
|
||||
# `placement_group_config`. Use a single bundle that reserves the full GPU set
|
||||
# required by tensor-parallel execution.
|
||||
resources_per_bundle = {
|
||||
"GPU": float(resolved.gpus_per_replica),
|
||||
"CPU": float(cpu_per_gpu) * float(resolved.gpus_per_replica),
|
||||
}
|
||||
|
||||
env_vars = _ensure_hf_env_defaults(dict(runtime_env_env_vars or {}))
|
||||
|
||||
return {
|
||||
# Ray Serve LLM expects `model_loading_config` with model_id/model_source.
|
||||
"model_loading_config": {"model_id": resolved.model_id, "model_source": resolved.model_source},
|
||||
"accelerator_type": accelerator_type,
|
||||
"deployment_config": {"num_replicas": int(resolved.num_replicas)},
|
||||
"engine_kwargs": engine_kwargs,
|
||||
"resources_per_bundle": resources_per_bundle,
|
||||
"runtime_env": {"env_vars": env_vars},
|
||||
}
|
||||
151
src/mvp/py/argus/service/serving_reconciler.py
Normal file
151
src/mvp/py/argus/service/serving_reconciler.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import traceback
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from argus.service.ray_resources import ClusterAvailable, get_cluster_available
|
||||
|
||||
from .config import V2Config
|
||||
from .db import Db
|
||||
from .serve_llm_config import build_llm_config_dict
|
||||
from .serving_spec import ResolvedServingSpec
|
||||
|
||||
|
||||
class ServeClient(Protocol):
|
||||
def ensure_started(self) -> None: ...
|
||||
|
||||
def apply_app(self, *, app: Any, app_name: str, route_prefix: str = "/") -> Any: ...
|
||||
|
||||
def get_status(self) -> Any: ...
|
||||
|
||||
|
||||
def _parse_engine_kwargs(row: dict[str, Any]) -> dict[str, Any] | None:
|
||||
raw = row.get("engine_kwargs_json")
|
||||
if raw in (None, ""):
|
||||
return None
|
||||
try:
|
||||
obj = json.loads(str(raw))
|
||||
return obj if isinstance(obj, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _row_to_resolved_spec(row: dict[str, Any]) -> ResolvedServingSpec:
|
||||
return ResolvedServingSpec(
|
||||
user_id=str(row["user_id"]),
|
||||
model_id_suffix=str(row["model_id_suffix"]),
|
||||
model_id_prefix=str(row["model_id_prefix"]),
|
||||
model_id=str(row["model_id"]),
|
||||
model_source=str(row["model_source"]),
|
||||
num_replicas=int(row["num_replicas"]),
|
||||
gpus_per_replica=int(row["gpus_per_replica"]),
|
||||
engine_kwargs=_parse_engine_kwargs(row),
|
||||
)
|
||||
|
||||
|
||||
def _needed_total_gpus(rows: list[dict[str, Any]]) -> int:
|
||||
total = 0
|
||||
for r in rows:
|
||||
total += int(r.get("num_replicas") or 0) * int(r.get("gpus_per_replica") or 0)
|
||||
return total
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServingReconciler:
|
||||
"""
|
||||
v3.8: reconcile declared serve_models (SQLite) into a multi-model Ray Serve app.
|
||||
|
||||
This reconciler is intentionally conservative:
|
||||
- Only acts on models in states QUEUED/DELETING.
|
||||
- Performs a minimal GPU precheck using ray available GPU totals.
|
||||
- Writes events and state transitions for explainability.
|
||||
"""
|
||||
|
||||
db: Db
|
||||
v2_cfg: V2Config
|
||||
ray_runtime_env_env_vars: dict[str, str]
|
||||
serve_client: ServeClient
|
||||
app_name: str = "argus_llm_app"
|
||||
route_prefix: str = "/"
|
||||
cpu_per_gpu: float = 1.0
|
||||
get_available_fn: Any = get_cluster_available
|
||||
|
||||
def tick(self) -> None:
|
||||
# Pick the next desired change.
|
||||
change = self.db.pick_next_runnable_serve_change()
|
||||
if not change:
|
||||
return
|
||||
|
||||
model_key = str(change["model_key"])
|
||||
state = str(change.get("state") or "")
|
||||
|
||||
# Ensure Ray (and Serve) can be started before doing anything else.
|
||||
try:
|
||||
self.serve_client.ensure_started()
|
||||
except Exception as e:
|
||||
self.db.append_serve_event(model_key=model_key, event_type="SERVE_START_ERROR", payload_json=repr(e))
|
||||
return
|
||||
|
||||
# Desired set: all non-deleted models except those marked DELETING.
|
||||
all_rows = self.db.list_all_serve_models(include_deleted=False, limit=5000, offset=0)
|
||||
# FAILED models are not part of the desired running set. A user can PATCH to
|
||||
# re-queue a failed model (e.g., after fixing env/deps) which will move it back to QUEUED.
|
||||
desired_rows = [r for r in all_rows if str(r.get("state") or "") not in ("DELETING", "DELETED", "FAILED")]
|
||||
|
||||
# Precheck resources: multi-model app apply needs enough GPUs for the whole desired set.
|
||||
needed = _needed_total_gpus(desired_rows)
|
||||
avail: ClusterAvailable = self.get_available_fn()
|
||||
if float(avail.total_available_gpus) < float(needed):
|
||||
msg = f"Insufficient GPUs: need {needed}, available {avail.total_available_gpus}"
|
||||
self.db.append_serve_event(model_key=model_key, event_type="SERVE_PENDING_RESOURCES", payload_json=msg)
|
||||
return
|
||||
|
||||
# Build per-model LLM configs (dict form in M4).
|
||||
llm_cfg_dicts: list[dict[str, Any]] = []
|
||||
accelerator_type = str(self.v2_cfg.serving.llm.accelerator_type or "")
|
||||
for r in desired_rows:
|
||||
resolved = _row_to_resolved_spec(r)
|
||||
llm_cfg_dicts.append(
|
||||
build_llm_config_dict(
|
||||
resolved,
|
||||
accelerator_type=accelerator_type,
|
||||
runtime_env_env_vars=self.ray_runtime_env_env_vars,
|
||||
cpu_per_gpu=self.cpu_per_gpu,
|
||||
)
|
||||
)
|
||||
|
||||
# Build a Ray Serve OpenAI-compatible app if Ray Serve LLM is available.
|
||||
# Fall back to a plain dict so unit tests can run without real Ray Serve.
|
||||
app_obj: Any
|
||||
try:
|
||||
from ray.serve.llm import LLMConfig, build_openai_app # type: ignore
|
||||
|
||||
llm_cfgs = [LLMConfig(**d) for d in llm_cfg_dicts]
|
||||
app_obj = build_openai_app({"llm_configs": llm_cfgs})
|
||||
except Exception as e:
|
||||
self.db.append_serve_event(model_key=model_key, event_type="SERVE_LLM_IMPORT_ERROR", payload_json=repr(e))
|
||||
app_obj = {"llm_configs": llm_cfg_dicts}
|
||||
|
||||
try:
|
||||
self.db.append_serve_event(model_key=model_key, event_type="SERVE_APPLY_REQUESTED", payload_json=str(len(llm_cfg_dicts)))
|
||||
self.serve_client.apply_app(app=app_obj, app_name=self.app_name, route_prefix=self.route_prefix)
|
||||
except Exception as e:
|
||||
err = f"{type(e).__name__}: {e}"
|
||||
tb = traceback.format_exc(limit=10)
|
||||
self.db.set_serve_model_state(model_key=model_key, state="FAILED", error_summary=err, event_type="SERVE_APPLY_FAILED", payload_json=tb)
|
||||
return
|
||||
|
||||
# Apply succeeded. Update the changing model's state.
|
||||
if state == "DELETING":
|
||||
self.db.set_serve_model_state(model_key=model_key, state="DELETED", event_type="SERVE_DELETE_APPLIED")
|
||||
return
|
||||
|
||||
# Mark as deploying; best-effort status probe can promote to RUNNING.
|
||||
self.db.set_serve_model_state(model_key=model_key, state="DEPLOYING", event_type="SERVE_DEPLOYING")
|
||||
try:
|
||||
_ = self.serve_client.get_status()
|
||||
self.db.set_serve_model_state(model_key=model_key, state="RUNNING", event_type="SERVE_RUNNING")
|
||||
except Exception as e:
|
||||
self.db.append_serve_event(model_key=model_key, event_type="SERVE_STATUS_ERROR", payload_json=repr(e))
|
||||
144
src/mvp/py/argus/service/serving_spec.py
Normal file
144
src/mvp/py/argus/service/serving_spec.py
Normal file
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
_MODEL_ID_SUFFIX_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServingSpec:
|
||||
model_id: str
|
||||
model_source: str
|
||||
num_replicas: int = 1
|
||||
gpus_per_replica: int = 1
|
||||
engine_kwargs: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedServingSpec:
|
||||
user_id: str
|
||||
model_id_suffix: str
|
||||
model_id_prefix: str
|
||||
model_id: str
|
||||
model_source: str
|
||||
num_replicas: int
|
||||
gpus_per_replica: int
|
||||
engine_kwargs: dict[str, Any] | None
|
||||
|
||||
|
||||
def validate_model_id_suffix(suffix: str) -> None:
|
||||
if not isinstance(suffix, str):
|
||||
raise ValueError("model_id must be a string")
|
||||
s = suffix.strip()
|
||||
if s != suffix:
|
||||
raise ValueError("model_id must not contain leading/trailing whitespace")
|
||||
if not s:
|
||||
raise ValueError("model_id is required")
|
||||
if not _MODEL_ID_SUFFIX_RE.match(s):
|
||||
raise ValueError("model_id must match regex: ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
if ".." in s:
|
||||
raise ValueError("model_id must not contain '..'")
|
||||
|
||||
|
||||
def make_model_id_prefix(*, user_id: str, now_utc: datetime | None = None) -> str:
|
||||
if not user_id or not isinstance(user_id, str):
|
||||
raise ValueError("user_id is required")
|
||||
if "/" in user_id:
|
||||
raise ValueError("user_id must not contain '/'")
|
||||
|
||||
dt = now_utc or datetime.now(timezone.utc)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
dt = dt.astimezone(timezone.utc)
|
||||
stamp = dt.strftime("%Y%m%d%H%M")
|
||||
return f"{user_id}-{stamp}"
|
||||
|
||||
|
||||
def expand_home_macros(*, user_id: str, text: str) -> str:
|
||||
if not isinstance(text, str):
|
||||
raise ValueError("model_source must be a string")
|
||||
if not text:
|
||||
raise ValueError("model_source is required")
|
||||
|
||||
out = text
|
||||
out = out.replace("$HOME/common/hf", "/private/hf")
|
||||
out = out.replace("$HOME/common/datasets", "/private/datasets")
|
||||
out = out.replace("$HOME", f"/private/users/{user_id}")
|
||||
return out
|
||||
|
||||
|
||||
def validate_model_source_path(*, user_id: str, model_source: str) -> None:
|
||||
if not isinstance(model_source, str):
|
||||
raise ValueError("model_source must be a string")
|
||||
if not model_source.startswith("/"):
|
||||
raise ValueError("model_source must be an absolute path")
|
||||
if not model_source.startswith("/private/"):
|
||||
raise ValueError("model_source must be under /private")
|
||||
if "\x00" in model_source:
|
||||
raise ValueError("model_source contains null byte")
|
||||
parts = [p for p in model_source.split("/") if p]
|
||||
if any(p == ".." for p in parts):
|
||||
raise ValueError("model_source must not contain '..'")
|
||||
|
||||
allowed_user_prefix = f"/private/users/{user_id}/"
|
||||
allowed = model_source.startswith("/private/hf/") or model_source.startswith(allowed_user_prefix)
|
||||
if not allowed:
|
||||
raise PermissionError("model_source is not allowed (must be under /private/hf or your /private/users/<user_id>)")
|
||||
|
||||
|
||||
def parse_serving_spec(obj: Any) -> ServingSpec:
|
||||
if not isinstance(obj, dict):
|
||||
raise ValueError("serving spec must be a mapping")
|
||||
|
||||
model_id = obj.get("model_id")
|
||||
model_source = obj.get("model_source")
|
||||
num_replicas = obj.get("num_replicas", 1)
|
||||
gpus_per_replica = obj.get("gpus_per_replica", 1)
|
||||
engine_kwargs = obj.get("engine_kwargs", None)
|
||||
|
||||
if not isinstance(model_id, str):
|
||||
raise ValueError("missing required field: model_id")
|
||||
validate_model_id_suffix(model_id)
|
||||
|
||||
if not isinstance(model_source, str) or not model_source:
|
||||
raise ValueError("missing required field: model_source")
|
||||
|
||||
if not isinstance(num_replicas, int) or num_replicas < 1:
|
||||
raise ValueError("num_replicas must be an integer >= 1")
|
||||
if not isinstance(gpus_per_replica, int) or gpus_per_replica < 1:
|
||||
raise ValueError("gpus_per_replica must be an integer >= 1")
|
||||
|
||||
if engine_kwargs is not None and not isinstance(engine_kwargs, dict):
|
||||
raise ValueError("engine_kwargs must be a mapping when provided")
|
||||
|
||||
return ServingSpec(
|
||||
model_id=model_id,
|
||||
model_source=model_source,
|
||||
num_replicas=num_replicas,
|
||||
gpus_per_replica=gpus_per_replica,
|
||||
engine_kwargs=engine_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def resolve_serving_spec(*, spec: ServingSpec, user_id: str, now_utc: datetime | None = None) -> ResolvedServingSpec:
|
||||
validate_model_id_suffix(spec.model_id)
|
||||
prefix = make_model_id_prefix(user_id=user_id, now_utc=now_utc)
|
||||
full_model_id = f"{prefix}-{spec.model_id}"
|
||||
|
||||
resolved_source = expand_home_macros(user_id=user_id, text=spec.model_source)
|
||||
validate_model_source_path(user_id=user_id, model_source=resolved_source)
|
||||
|
||||
return ResolvedServingSpec(
|
||||
user_id=user_id,
|
||||
model_id_suffix=spec.model_id,
|
||||
model_id_prefix=prefix,
|
||||
model_id=full_model_id,
|
||||
model_source=resolved_source,
|
||||
num_replicas=spec.num_replicas,
|
||||
gpus_per_replica=spec.gpus_per_replica,
|
||||
engine_kwargs=spec.engine_kwargs,
|
||||
)
|
||||
@@ -112,6 +112,7 @@ def _nav(active: str) -> str:
|
||||
links = [
|
||||
("login", "/ui/login", "Login"),
|
||||
("tasks", "/ui/tasks", "Tasks"),
|
||||
("serving", "/ui/serving", "Serving"),
|
||||
("new", "/ui/tasks/new", "New Task"),
|
||||
("data", "/ui/data", "Data"),
|
||||
("admin", "/ui/admin", "Admin"),
|
||||
@@ -992,6 +993,253 @@ refresh();
|
||||
""".strip()
|
||||
return HTMLResponse(content=_page(f"Logs {task_id}", "tasks", body, script))
|
||||
|
||||
@app.get("/ui/serving")
|
||||
async def ui_serving() -> HTMLResponse:
|
||||
body = """
|
||||
<h1>Serving</h1>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<button class="btn" id="refresh">Refresh</button>
|
||||
<a class="btn" href="/ui/serving/new" style="display:inline-block">New Model</a>
|
||||
<a class="btn" id="openai-models" target="_blank" rel="noopener" href="#">OpenAI /v1/models</a>
|
||||
</div>
|
||||
<div style="height:10px"></div>
|
||||
<div id="out" class="muted">Loading...</div>
|
||||
</div>
|
||||
""".strip()
|
||||
script = """
|
||||
document.getElementById("nav-ray-dashboard").href = curOriginWithPort(8265);
|
||||
document.getElementById("openai-models").href = curOriginWithPort(8000) + "/v1/models";
|
||||
const out = document.getElementById("out");
|
||||
|
||||
function pill(state) {
|
||||
const s = String(state || "");
|
||||
if (s === "RUNNING") return `<span class="pill ok">${s}</span>`;
|
||||
if (s === "FAILED") return `<span class="pill bad">${s}</span>`;
|
||||
return `<span class="pill">${s}</span>`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
out.textContent = "Loading...";
|
||||
try {
|
||||
const lim = 50;
|
||||
const off = Number(localStorage.getItem("mvp_serving_offset") || "0") || 0;
|
||||
const resp = await apiJson("/api/v2/serve/models?limit=" + lim + "&offset=" + off + "&include_deleted=0");
|
||||
const items = resp.items || [];
|
||||
const hasMore = !!resp.has_more;
|
||||
const pageNo = Math.floor(off / lim) + 1;
|
||||
const prevDisabled = off <= 0;
|
||||
const nextDisabled = !hasMore;
|
||||
|
||||
function row(m) {
|
||||
return `<tr>
|
||||
<td><a href="/ui/serving/${m.model_key}">${m.model_key}</a></td>
|
||||
<td><code>${m.model_id}</code></td>
|
||||
<td>${pill(m.state)}</td>
|
||||
<td>${m.num_replicas} × ${m.gpus_per_replica} GPU</td>
|
||||
<td>${m.updated_at || ""}</td>
|
||||
</tr>`;
|
||||
}
|
||||
const rows = items.map(row).join("");
|
||||
|
||||
out.innerHTML = `
|
||||
<div class="row" style="justify-content: space-between; margin-bottom: 8px;">
|
||||
<div class="muted">OpenAI base: <code>${resp.openai_base_url || curOriginWithPort(8000) + "/v1"}</code></div>
|
||||
<div class="row">
|
||||
<span class="muted">Page ${pageNo}</span>
|
||||
<button class="btn" id="prev" ${prevDisabled ? "disabled" : ""}>Prev</button>
|
||||
<button class="btn" id="next" ${nextDisabled ? "disabled" : ""}>Next</button>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Model Key</th><th>Model ID</th><th>State</th><th>Resources</th><th>Updated</th></tr></thead>
|
||||
<tbody>${rows || "<tr><td colspan=5 class=muted>(none)</td></tr>"}</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
const prevBtn = document.getElementById("prev");
|
||||
const nextBtn = document.getElementById("next");
|
||||
if (prevBtn) prevBtn.onclick = () => { localStorage.setItem("mvp_serving_offset", String(Math.max(0, off - lim))); refresh(); };
|
||||
if (nextBtn) nextBtn.onclick = () => { localStorage.setItem("mvp_serving_offset", String(off + lim)); refresh(); };
|
||||
} catch (e) {
|
||||
let text = "Error: " + (e.status || "") + "\\n" + (e.body || String(e));
|
||||
if (e.body && String(e.body).includes("serving is not enabled")) {
|
||||
text = "Serving is not enabled in server config.\\nAsk admin to enable `serving:` in dev.yaml.";
|
||||
}
|
||||
out.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("refresh").onclick = refresh;
|
||||
refresh();
|
||||
""".strip()
|
||||
return HTMLResponse(content=_page("Serving", "serving", body, script))
|
||||
|
||||
@app.get("/ui/serving/new")
|
||||
async def ui_serving_new() -> HTMLResponse:
|
||||
example = """# ServingSpec (YAML)
|
||||
# 说明:
|
||||
# - model_id: 这里是 suffix(平台会自动加前缀:<user_id>-<YYYYMMDDHHMM>-<suffix>)
|
||||
# - model_source: 本地模型路径(支持 $HOME 宏;推荐使用 $HOME/common/hf 指向共享 HF cache)
|
||||
#
|
||||
# 常用路径:
|
||||
# - $HOME/common/hf -> /private/hf
|
||||
# - $HOME -> /private/users/<user_id>
|
||||
#
|
||||
model_id: qwen-0.5b
|
||||
model_source: $HOME/common/hf/hub/models--Qwen--Qwen2.5-0.5B-Instruct/snapshots/<SNAPSHOT_HASH>
|
||||
num_replicas: 1
|
||||
gpus_per_replica: 1
|
||||
|
||||
# engine_kwargs: # 可选:透传给 vLLM
|
||||
# gpu_memory_utilization: 0.4
|
||||
""".strip()
|
||||
body = f"""
|
||||
<h1>New Model</h1>
|
||||
<div class="card">
|
||||
<div class="muted">Paste ServingSpec YAML and submit to <code>/api/v2/serve/models</code>.</div>
|
||||
<div style="height:10px"></div>
|
||||
<textarea id="yaml" rows="14">{html.escape(example)}</textarea>
|
||||
<div style="height:10px"></div>
|
||||
<div class="row">
|
||||
<button class="btn" id="submit">Submit</button>
|
||||
<a class="btn" href="/ui/serving" style="display:inline-block">Back</a>
|
||||
</div>
|
||||
<div style="height:10px"></div>
|
||||
<pre id="out" class="muted"></pre>
|
||||
</div>
|
||||
""".strip()
|
||||
script = """
|
||||
document.getElementById("nav-ray-dashboard").href = curOriginWithPort(8265);
|
||||
const out = document.getElementById("out");
|
||||
document.getElementById("submit").onclick = async () => {
|
||||
out.textContent = "Submitting...";
|
||||
const yaml = document.getElementById("yaml").value || "";
|
||||
try {
|
||||
const resp = await apiJson("/api/v2/serve/models", { method: "POST", headers: { "Content-Type": "application/yaml" }, body: yaml });
|
||||
out.textContent = "Created: " + resp.model_key + "\\nState: " + resp.state;
|
||||
if (resp.model_key) window.location.href = "/ui/serving/" + encodeURIComponent(resp.model_key);
|
||||
} catch (e) {
|
||||
out.textContent = "Error: " + (e.status || "") + "\\n" + (e.body || String(e));
|
||||
}
|
||||
};
|
||||
""".strip()
|
||||
return HTMLResponse(content=_page("New Model", "serving", body, script))
|
||||
|
||||
@app.get("/ui/serving/{model_key}")
|
||||
async def ui_serving_detail(model_key: str) -> HTMLResponse:
|
||||
body = f"""
|
||||
<h1>Model</h1>
|
||||
<div class="card">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<div class="muted">model_key: <code>{html.escape(model_key)}</code></div>
|
||||
<div class="row">
|
||||
<a class="btn" href="/ui/serving" style="display:inline-block">Back</a>
|
||||
<a class="btn" id="openai-models" target="_blank" rel="noopener" href="#">OpenAI /v1/models</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="height:10px"></div>
|
||||
<div class="row">
|
||||
<label class="muted" style="min-width:120px">Scale replicas</label>
|
||||
<input id="replicas" type="number" min="1" step="1" value="1" style="max-width: 180px" />
|
||||
<button class="btn" id="scale">Apply</button>
|
||||
<button class="btn danger" id="delete">Delete</button>
|
||||
</div>
|
||||
<div style="height:10px"></div>
|
||||
<div id="meta" class="muted">Loading...</div>
|
||||
<div style="height:12px"></div>
|
||||
<h3 style="margin-top:0">Resolved Spec (YAML)</h3>
|
||||
<pre id="spec" class="muted">(loading)</pre>
|
||||
<div style="height:12px"></div>
|
||||
<h3 style="margin-top:0">Events</h3>
|
||||
<div id="events" class="muted">(loading)</div>
|
||||
<div style="height:12px"></div>
|
||||
<h3 style="margin-top:0">OpenAI Example</h3>
|
||||
<pre id="example" class="muted">(loading)</pre>
|
||||
</div>
|
||||
""".strip()
|
||||
script = f"""
|
||||
document.getElementById("nav-ray-dashboard").href = curOriginWithPort(8265);
|
||||
document.getElementById("openai-models").href = curOriginWithPort(8000) + "/v1/models";
|
||||
const modelKey = {json.dumps(model_key)};
|
||||
const meta = document.getElementById("meta");
|
||||
const spec = document.getElementById("spec");
|
||||
const eventsEl = document.getElementById("events");
|
||||
const example = document.getElementById("example");
|
||||
const replicas = document.getElementById("replicas");
|
||||
|
||||
function pill(state) {{
|
||||
const s = String(state || "");
|
||||
if (s === "RUNNING") return `<span class="pill ok">${{s}}</span>`;
|
||||
if (s === "FAILED") return `<span class="pill bad">${{s}}</span>`;
|
||||
return `<span class="pill">${{s}}</span>`;
|
||||
}}
|
||||
|
||||
function renderEvents(events) {{
|
||||
if (!events || !events.length) return "<div class=muted>(none)</div>";
|
||||
const rows = events.map(e => {{
|
||||
const payload = (e.payload_json || "");
|
||||
const short = String(payload).length > 240 ? String(payload).slice(0, 240) + "..." : String(payload);
|
||||
return `<tr><td>${{e.created_at || ""}}</td><td><code>${{e.event_type}}</code></td><td><pre class=muted style=\\"margin:0\\">${{short}}</pre></td></tr>`;
|
||||
}}).join("");
|
||||
return `<table><thead><tr><th>Time</th><th>Type</th><th>Payload</th></tr></thead><tbody>${{rows}}</tbody></table>`;
|
||||
}}
|
||||
|
||||
async function refresh() {{
|
||||
meta.textContent = "Loading...";
|
||||
spec.textContent = "(loading)";
|
||||
eventsEl.textContent = "(loading)";
|
||||
example.textContent = "(loading)";
|
||||
try {{
|
||||
const obj = await apiJson("/api/v2/serve/models/" + encodeURIComponent(modelKey));
|
||||
const m = obj.model || {{}};
|
||||
replicas.value = String(m.num_replicas || 1);
|
||||
meta.innerHTML = `
|
||||
<div class=row>
|
||||
<div>state: ${{pill(m.state)}}</div>
|
||||
<div class=muted>model_id: <code>${{m.model_id || ""}}</code></div>
|
||||
<div class=muted>source: <code>${{m.model_source || ""}}</code></div>
|
||||
</div>
|
||||
<div class=muted>endpoint: <code>${{(m.endpoint && m.endpoint.openai_base_url) || (curOriginWithPort(8000) + "/v1")}}</code></div>
|
||||
`;
|
||||
spec.textContent = obj.resolved_spec_yaml || "";
|
||||
eventsEl.innerHTML = renderEvents(obj.events || []);
|
||||
const base = (m.endpoint && m.endpoint.openai_base_url) || (curOriginWithPort(8000) + "/v1");
|
||||
const mid = m.model_id || "";
|
||||
example.textContent = `curl -sS -H 'Content-Type: application/json' -H 'Authorization: Bearer FAKE_KEY' \\\\\\n -X POST ${{base}}/chat/completions \\\\\\n --data-binary '{{\\"model\\":\\"${{mid}}\\",\\"messages\\":[{{\\"role\\":\\"user\\",\\"content\\":\\"hello\\"}}],\\"max_tokens\\":16,\\"stream\\":false}}' | python3 -m json.tool`;
|
||||
}} catch (e) {{
|
||||
meta.textContent = "Error: " + (e.status || "") + "\\n" + (e.body || String(e));
|
||||
spec.textContent = "";
|
||||
eventsEl.textContent = "";
|
||||
example.textContent = "";
|
||||
}}
|
||||
}}
|
||||
|
||||
document.getElementById("scale").onclick = async () => {{
|
||||
const n = Number(replicas.value || "1");
|
||||
if (!Number.isFinite(n) || n < 1) return;
|
||||
try {{
|
||||
await apiJson("/api/v2/serve/models/" + encodeURIComponent(modelKey), {{ method: "PATCH", headers: {{ "Content-Type": "application/json" }}, body: JSON.stringify({{ num_replicas: n }}) }});
|
||||
await refresh();
|
||||
}} catch (e) {{
|
||||
meta.textContent = "Error: " + (e.status || "") + "\\n" + (e.body || String(e));
|
||||
}}
|
||||
}};
|
||||
|
||||
document.getElementById("delete").onclick = async () => {{
|
||||
if (!confirm("Delete this model?")) return;
|
||||
try {{
|
||||
await apiJson("/api/v2/serve/models/" + encodeURIComponent(modelKey), {{ method: "DELETE" }});
|
||||
await refresh();
|
||||
}} catch (e) {{
|
||||
meta.textContent = "Error: " + (e.status || "") + "\\n" + (e.body || String(e));
|
||||
}}
|
||||
}};
|
||||
|
||||
refresh();
|
||||
""".strip()
|
||||
return HTMLResponse(content=_page("Model", "serving", body, script))
|
||||
|
||||
@app.get("/ui/data")
|
||||
async def ui_data() -> HTMLResponse:
|
||||
body = """
|
||||
|
||||
282
src/mvp/py/tests/test_app_serving_api.py
Normal file
282
src/mvp/py/tests/test_app_serving_api.py
Normal file
@@ -0,0 +1,282 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path) -> Path:
|
||||
cfg = {
|
||||
"ray": {
|
||||
"address": "http://127.0.0.1:8265",
|
||||
"shared_root": "/private",
|
||||
"entrypoint_resources": {"worker_node": 1},
|
||||
"runtime_env": {"env_vars": {}},
|
||||
},
|
||||
"data": {
|
||||
"user_root": str(tmp_path / "users"),
|
||||
},
|
||||
"service": {
|
||||
"api": {"host": "127.0.0.1", "port": 0},
|
||||
"auth": {"token_env": "MVP_INTERNAL_TOKEN"},
|
||||
"sqlite": {"db_path": str(tmp_path / "mvp.sqlite3")},
|
||||
"scheduler": {"tick_s": 1, "retry_interval_s": 1, "max_running_tasks": 1},
|
||||
},
|
||||
"serving": {
|
||||
"serve": {"http_port": 8000, "proxy_location": "HeadOnly"},
|
||||
"llm": {"accelerator_type": "H20"},
|
||||
},
|
||||
}
|
||||
p = tmp_path / "cfg.yaml"
|
||||
p.write_text(yaml.safe_dump(cfg), encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def test_serving_api_crud_flow(tmp_path: Path, monkeypatch):
|
||||
from argus.service import app as app_mod
|
||||
|
||||
cfg_path = _write_config(tmp_path)
|
||||
monkeypatch.setenv("MVP_INTERNAL_TOKEN", "admin-token")
|
||||
|
||||
class _Scheduler:
|
||||
def __init__(self, **kwargs):
|
||||
self.tool = object()
|
||||
|
||||
def run_forever(self, stop_flag):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(app_mod, "Scheduler", _Scheduler)
|
||||
monkeypatch.setattr(app_mod, "new_model_key", lambda user_id: f"mk-{user_id}")
|
||||
|
||||
app = app_mod.create_app(str(cfg_path))
|
||||
|
||||
admin_headers = {"authorization": "Bearer admin-token"}
|
||||
with TestClient(app) as c:
|
||||
r = c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"})
|
||||
assert r.status_code == 200
|
||||
r2 = c.post("/api/v2/users/alice/tokens", headers=admin_headers)
|
||||
assert r2.status_code == 200
|
||||
user_token = r2.json()["token"]
|
||||
|
||||
headers = {"authorization": f"Bearer {user_token}"}
|
||||
|
||||
spec_yaml = (
|
||||
"model_id: qwen-0.5b\n"
|
||||
"model_source: $HOME/common/hf/models--Qwen--Qwen2.5-0.5B-Instruct/snapshots/sha\n"
|
||||
"num_replicas: 1\n"
|
||||
"gpus_per_replica: 1\n"
|
||||
)
|
||||
r3 = c.post("/api/v2/serve/models", headers=headers, data=spec_yaml)
|
||||
assert r3.status_code == 200
|
||||
assert r3.json()["model_key"] == "mk-alice"
|
||||
assert r3.json()["state"] == "QUEUED"
|
||||
|
||||
r4 = c.get("/api/v2/serve/models?limit=10&offset=0", headers=headers)
|
||||
assert r4.status_code == 200
|
||||
obj = r4.json()
|
||||
assert obj["openai_base_url"] == "http://testserver:8000/v1"
|
||||
assert len(obj["items"]) == 1
|
||||
assert obj["items"][0]["model_key"] == "mk-alice"
|
||||
|
||||
r5 = c.get("/api/v2/serve/models/mk-alice", headers=headers)
|
||||
assert r5.status_code == 200
|
||||
detail = r5.json()
|
||||
assert detail["model"]["model_key"] == "mk-alice"
|
||||
assert "model_id_prefix" in detail["model"]
|
||||
assert "resolved_spec_yaml" in detail
|
||||
assert isinstance(detail.get("events"), list)
|
||||
|
||||
r6 = c.patch("/api/v2/serve/models/mk-alice", headers=headers, json={"num_replicas": 2})
|
||||
assert r6.status_code == 200
|
||||
assert r6.json()["state"] == "QUEUED"
|
||||
|
||||
r7 = c.delete("/api/v2/serve/models/mk-alice", headers=headers)
|
||||
assert r7.status_code == 200
|
||||
assert r7.json()["state"] == "DELETING"
|
||||
|
||||
# Admin status endpoint
|
||||
r8 = c.get("/api/v2/serve/status", headers=admin_headers)
|
||||
assert r8.status_code == 200
|
||||
assert r8.json()["http_port"] == 8000
|
||||
|
||||
|
||||
def test_serving_api_rejects_path_outside_user_and_hf(tmp_path: Path, monkeypatch):
|
||||
from argus.service import app as app_mod
|
||||
|
||||
cfg_path = _write_config(tmp_path)
|
||||
monkeypatch.setenv("MVP_INTERNAL_TOKEN", "admin-token")
|
||||
|
||||
class _Scheduler:
|
||||
def __init__(self, **kwargs):
|
||||
self.tool = object()
|
||||
|
||||
def run_forever(self, stop_flag):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(app_mod, "Scheduler", _Scheduler)
|
||||
monkeypatch.setattr(app_mod, "new_model_key", lambda user_id: f"mk-{user_id}")
|
||||
|
||||
app = app_mod.create_app(str(cfg_path))
|
||||
|
||||
admin_headers = {"authorization": "Bearer admin-token"}
|
||||
with TestClient(app) as c:
|
||||
c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"})
|
||||
r2 = c.post("/api/v2/users/alice/tokens", headers=admin_headers)
|
||||
user_token = r2.json()["token"]
|
||||
headers = {"authorization": f"Bearer {user_token}"}
|
||||
|
||||
spec_yaml = (
|
||||
"model_id: x\n"
|
||||
"model_source: /private/users/bob/models/evil\n"
|
||||
"num_replicas: 1\n"
|
||||
"gpus_per_replica: 1\n"
|
||||
)
|
||||
r3 = c.post("/api/v2/serve/models", headers=headers, data=spec_yaml)
|
||||
assert r3.status_code == 403
|
||||
|
||||
|
||||
def test_serving_api_invalid_yaml_and_non_mapping(tmp_path: Path, monkeypatch):
|
||||
from argus.service import app as app_mod
|
||||
|
||||
cfg_path = _write_config(tmp_path)
|
||||
monkeypatch.setenv("MVP_INTERNAL_TOKEN", "admin-token")
|
||||
|
||||
class _Scheduler:
|
||||
def __init__(self, **kwargs):
|
||||
self.tool = object()
|
||||
|
||||
def run_forever(self, stop_flag):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(app_mod, "Scheduler", _Scheduler)
|
||||
monkeypatch.setattr(app_mod, "new_model_key", lambda user_id: f"mk-{user_id}")
|
||||
app = app_mod.create_app(str(cfg_path))
|
||||
|
||||
with TestClient(app) as c:
|
||||
# Create a user token
|
||||
admin_headers = {"authorization": "Bearer admin-token"}
|
||||
c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"})
|
||||
token = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"]
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
|
||||
r = c.post("/api/v2/serve/models", headers=headers, data=": bad\n")
|
||||
assert r.status_code == 400
|
||||
|
||||
r2 = c.post("/api/v2/serve/models", headers=headers, data="- 1\n- 2\n")
|
||||
assert r2.status_code == 400
|
||||
|
||||
|
||||
def test_serving_api_engine_kwargs_binary_rejected(tmp_path: Path, monkeypatch):
|
||||
"""
|
||||
yaml !!binary is parsed as bytes, which is not JSON-serializable.
|
||||
"""
|
||||
from argus.service import app as app_mod
|
||||
|
||||
cfg_path = _write_config(tmp_path)
|
||||
monkeypatch.setenv("MVP_INTERNAL_TOKEN", "admin-token")
|
||||
|
||||
class _Scheduler:
|
||||
def __init__(self, **kwargs):
|
||||
self.tool = object()
|
||||
|
||||
def run_forever(self, stop_flag):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(app_mod, "Scheduler", _Scheduler)
|
||||
monkeypatch.setattr(app_mod, "new_model_key", lambda user_id: f"mk-{user_id}")
|
||||
app = app_mod.create_app(str(cfg_path))
|
||||
|
||||
admin_headers = {"authorization": "Bearer admin-token"}
|
||||
with TestClient(app) as c:
|
||||
c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"})
|
||||
token = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"]
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
|
||||
spec_yaml = (
|
||||
"model_id: x\n"
|
||||
"model_source: $HOME/common/hf/x\n"
|
||||
"engine_kwargs:\n"
|
||||
" blob: !!binary \"AQID\"\n"
|
||||
)
|
||||
r = c.post("/api/v2/serve/models", headers=headers, data=spec_yaml)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_serving_api_list_include_deleted_and_forwarded_base_url(tmp_path: Path, monkeypatch):
|
||||
from argus.service import app as app_mod
|
||||
from argus.service.config import V2Config
|
||||
from argus.service.db import Db
|
||||
|
||||
cfg_path = _write_config(tmp_path)
|
||||
monkeypatch.setenv("MVP_INTERNAL_TOKEN", "admin-token")
|
||||
|
||||
class _Scheduler:
|
||||
def __init__(self, **kwargs):
|
||||
self.tool = object()
|
||||
|
||||
def run_forever(self, stop_flag):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(app_mod, "Scheduler", _Scheduler)
|
||||
keys = iter(["mk-alice-1", "mk-alice-2"])
|
||||
monkeypatch.setattr(app_mod, "new_model_key", lambda user_id: next(keys))
|
||||
|
||||
app = app_mod.create_app(str(cfg_path))
|
||||
|
||||
admin_headers = {"authorization": "Bearer admin-token"}
|
||||
with TestClient(app) as c:
|
||||
c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"})
|
||||
token = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"]
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
|
||||
spec_yaml = "model_id: x\nmodel_source: $HOME/common/hf/x\n"
|
||||
c.post("/api/v2/serve/models", headers=headers, data=spec_yaml)
|
||||
c.post("/api/v2/serve/models", headers=headers, data=spec_yaml)
|
||||
|
||||
# Mark one model as DELETED directly in DB (sets deleted_at).
|
||||
root = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
v2_cfg = V2Config.from_root_dict(root)
|
||||
db = Db(v2_cfg.sqlite.db_path)
|
||||
db.set_serve_model_state(model_key="mk-alice-2", state="DELETED")
|
||||
|
||||
r1 = c.get(
|
||||
"/api/v2/serve/models?limit=10&offset=0&include_deleted=0",
|
||||
headers={**headers, "x-forwarded-host": "example.com:8080", "x-forwarded-proto": "https"},
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["openai_base_url"] == "https://example.com:8000/v1"
|
||||
assert {m["model_key"] for m in r1.json()["items"]} == {"mk-alice-1"}
|
||||
|
||||
r2 = c.get("/api/v2/serve/models?include_deleted=1", headers=headers)
|
||||
assert r2.status_code == 200
|
||||
assert {m["model_key"] for m in r2.json()["items"]} == {"mk-alice-1", "mk-alice-2"}
|
||||
|
||||
|
||||
def test_serving_api_patch_invalid_num_replicas(tmp_path: Path, monkeypatch):
|
||||
from argus.service import app as app_mod
|
||||
|
||||
cfg_path = _write_config(tmp_path)
|
||||
monkeypatch.setenv("MVP_INTERNAL_TOKEN", "admin-token")
|
||||
|
||||
class _Scheduler:
|
||||
def __init__(self, **kwargs):
|
||||
self.tool = object()
|
||||
|
||||
def run_forever(self, stop_flag):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(app_mod, "Scheduler", _Scheduler)
|
||||
monkeypatch.setattr(app_mod, "new_model_key", lambda user_id: "mk-alice")
|
||||
|
||||
app = app_mod.create_app(str(cfg_path))
|
||||
|
||||
admin_headers = {"authorization": "Bearer admin-token"}
|
||||
with TestClient(app) as c:
|
||||
c.post("/api/v2/users", headers=admin_headers, json={"user_id": "alice"})
|
||||
token = c.post("/api/v2/users/alice/tokens", headers=admin_headers).json()["token"]
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
|
||||
c.post("/api/v2/serve/models", headers=headers, data="model_id: x\nmodel_source: $HOME/common/hf/x\n")
|
||||
r = c.patch("/api/v2/serve/models/mk-alice", headers=headers, json={"num_replicas": 0})
|
||||
assert r.status_code == 422
|
||||
79
src/mvp/py/tests/test_db_serving.py
Normal file
79
src/mvp/py/tests/test_db_serving.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_db_serving_model_crud_and_events(tmp_path: Path) -> None:
|
||||
from argus.service.db import Db
|
||||
|
||||
db = Db(str(tmp_path / "mvp.sqlite3"))
|
||||
db.init()
|
||||
|
||||
m1 = db.create_serve_model(
|
||||
model_key="svc-001",
|
||||
user_id="alice",
|
||||
model_id_suffix="qwen-0.5b",
|
||||
model_id_prefix="alice-202601061235",
|
||||
model_id="alice-202601061235-qwen-0.5b",
|
||||
model_source="/private/hf/hub/models--Qwen--Qwen2.5-0.5B-Instruct/snapshots/sha",
|
||||
num_replicas=1,
|
||||
gpus_per_replica=1,
|
||||
engine_kwargs_json=json.dumps({"max_model_len": 8192}),
|
||||
spec_yaml="model_id: qwen-0.5b\nmodel_source: $HOME/common/hf/...\n",
|
||||
resolved_spec_yaml="model_id: alice-202601061235-qwen-0.5b\nmodel_source: /private/hf/...\n",
|
||||
)
|
||||
assert m1["model_key"] == "svc-001"
|
||||
assert m1["state"] == "QUEUED"
|
||||
|
||||
# Same suffix may be created again; model_key is the identity.
|
||||
m2 = db.create_serve_model(
|
||||
model_key="svc-002",
|
||||
user_id="alice",
|
||||
model_id_suffix="qwen-0.5b",
|
||||
model_id_prefix="alice-202601061236",
|
||||
model_id="alice-202601061236-qwen-0.5b",
|
||||
model_source="/private/hf/hub/models--Qwen--Qwen2.5-0.5B-Instruct/snapshots/sha",
|
||||
num_replicas=1,
|
||||
gpus_per_replica=2,
|
||||
engine_kwargs_json=None,
|
||||
spec_yaml="model_id: qwen-0.5b\nmodel_source: $HOME/common/hf/...\n",
|
||||
resolved_spec_yaml="model_id: alice-202601061236-qwen-0.5b\nmodel_source: /private/hf/...\n",
|
||||
)
|
||||
assert m2["model_key"] == "svc-002"
|
||||
assert m2["model_id"] != m1["model_id"]
|
||||
|
||||
got = db.get_serve_model("svc-001")
|
||||
assert got is not None
|
||||
assert got["gpus_per_replica"] == 1
|
||||
|
||||
items = db.list_serve_models(user_id="alice")
|
||||
assert {i["model_key"] for i in items} == {"svc-001", "svc-002"}
|
||||
|
||||
# State transition writes a serve event.
|
||||
db.set_serve_model_state(model_key="svc-001", state="DEPLOYING")
|
||||
got2 = db.get_serve_model("svc-001")
|
||||
assert got2 is not None
|
||||
assert got2["state"] == "DEPLOYING"
|
||||
|
||||
events = db.list_serve_events("svc-001", limit=50)
|
||||
assert len(events) >= 2
|
||||
assert {e["event_type"] for e in events}.issuperset({"SERVE_MODEL_CREATED", "SERVE_STATE_UPDATE"})
|
||||
|
||||
# Reconciler pick: QUEUED/DELETING only.
|
||||
picked = db.pick_next_runnable_serve_change()
|
||||
assert picked is not None
|
||||
assert picked["state"] == "QUEUED"
|
||||
|
||||
db.set_serve_model_state(model_key="svc-002", state="DELETING")
|
||||
picked2 = db.pick_next_runnable_serve_change()
|
||||
assert picked2 is not None
|
||||
assert picked2["state"] in ("QUEUED", "DELETING")
|
||||
|
||||
# Deleted models are hidden unless include_deleted.
|
||||
db.set_serve_model_state(model_key="svc-002", state="DELETED")
|
||||
items2 = db.list_serve_models(user_id="alice", include_deleted=False)
|
||||
assert {i["model_key"] for i in items2} == {"svc-001"}
|
||||
items3 = db.list_serve_models(user_id="alice", include_deleted=True)
|
||||
assert {i["model_key"] for i in items3} == {"svc-001", "svc-002"}
|
||||
|
||||
@@ -44,3 +44,32 @@ def test_attempt_submission_id_format():
|
||||
|
||||
assert attempt_submission_id("t", 1) == "t--a01"
|
||||
assert attempt_submission_id("t", 12) == "t--a12"
|
||||
|
||||
|
||||
def test_new_model_key_includes_user(monkeypatch):
|
||||
import argus.core.ids as ids
|
||||
|
||||
class _FakeDatetime:
|
||||
@staticmethod
|
||||
def now():
|
||||
class _DT:
|
||||
def strftime(self, fmt: str) -> str:
|
||||
assert fmt == "%Y%m%d-%H%M%S"
|
||||
return "20250101-010203"
|
||||
|
||||
return _DT()
|
||||
|
||||
monkeypatch.setattr(ids, "datetime", _FakeDatetime)
|
||||
monkeypatch.setattr(ids.secrets, "token_hex", lambda n: "abcd")
|
||||
|
||||
assert ids.new_model_key(user_id="Alice_01") == "mvp2-alice_01-serve-20250101-010203-abcd"
|
||||
|
||||
|
||||
def test_new_model_key_requires_user_id():
|
||||
from argus.core.ids import new_model_key
|
||||
|
||||
try:
|
||||
new_model_key(user_id="")
|
||||
assert False, "expected ValueError"
|
||||
except ValueError as e:
|
||||
assert "user_id is required" in str(e)
|
||||
|
||||
78
src/mvp/py/tests/test_llm_config_builder.py
Normal file
78
src/mvp/py/tests/test_llm_config_builder.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_build_llm_config_dict_maps_tp_and_bundles():
|
||||
from argus.service.serve_llm_config import build_llm_config_dict
|
||||
from argus.service.serving_spec import ResolvedServingSpec
|
||||
|
||||
resolved = ResolvedServingSpec(
|
||||
user_id="alice",
|
||||
model_id_suffix="qwen-0.5b",
|
||||
model_id_prefix="alice-202601061235",
|
||||
model_id="alice-202601061235-qwen-0.5b",
|
||||
model_source="/private/hf/x",
|
||||
num_replicas=2,
|
||||
gpus_per_replica=4,
|
||||
engine_kwargs={"gpu_memory_utilization": 0.9},
|
||||
)
|
||||
|
||||
cfg = build_llm_config_dict(
|
||||
resolved,
|
||||
accelerator_type="H20",
|
||||
runtime_env_env_vars={"HF_ENDPOINT": "https://hf-mirror.com"},
|
||||
cpu_per_gpu=2.0,
|
||||
)
|
||||
assert cfg["model_loading_config"]["model_id"] == "alice-202601061235-qwen-0.5b"
|
||||
assert cfg["model_loading_config"]["model_source"] == "/private/hf/x"
|
||||
assert cfg["accelerator_type"] == "H20"
|
||||
assert cfg["deployment_config"]["num_replicas"] == 2
|
||||
|
||||
# gpus_per_replica -> tensor_parallel_size
|
||||
assert cfg["engine_kwargs"]["tensor_parallel_size"] == 4
|
||||
assert cfg["engine_kwargs"]["gpu_memory_utilization"] == 0.9
|
||||
|
||||
# resources_per_bundle reserves the full TP GPU set for each replica.
|
||||
bundle = cfg["resources_per_bundle"]
|
||||
assert bundle["GPU"] == 4.0
|
||||
assert bundle["CPU"] == 8.0
|
||||
|
||||
|
||||
def test_build_llm_config_dict_injects_hf_offline_defaults():
|
||||
from argus.service.serve_llm_config import build_llm_config_dict
|
||||
from argus.service.serving_spec import ResolvedServingSpec
|
||||
|
||||
resolved = ResolvedServingSpec(
|
||||
user_id="alice",
|
||||
model_id_suffix="x",
|
||||
model_id_prefix="alice-202601061235",
|
||||
model_id="alice-202601061235-x",
|
||||
model_source="/private/users/alice/models/x",
|
||||
num_replicas=1,
|
||||
gpus_per_replica=1,
|
||||
engine_kwargs=None,
|
||||
)
|
||||
cfg = build_llm_config_dict(resolved, accelerator_type="H20", runtime_env_env_vars={})
|
||||
env = cfg["runtime_env"]["env_vars"]
|
||||
assert env["HF_HUB_OFFLINE"] == "1"
|
||||
assert env["HF_HOME"] == "/private/hf"
|
||||
assert env["HUGGINGFACE_HUB_CACHE"].startswith("/private/hf/")
|
||||
|
||||
|
||||
def test_build_llm_config_dict_requires_accelerator_type():
|
||||
from argus.service.serve_llm_config import build_llm_config_dict
|
||||
from argus.service.serving_spec import ResolvedServingSpec
|
||||
|
||||
resolved = ResolvedServingSpec(
|
||||
user_id="alice",
|
||||
model_id_suffix="x",
|
||||
model_id_prefix="alice-202601061235",
|
||||
model_id="alice-202601061235-x",
|
||||
model_source="/private/hf/x",
|
||||
num_replicas=1,
|
||||
gpus_per_replica=1,
|
||||
engine_kwargs=None,
|
||||
)
|
||||
with pytest.raises(ValueError, match="accelerator_type is required"):
|
||||
build_llm_config_dict(resolved, accelerator_type="", runtime_env_env_vars={})
|
||||
55
src/mvp/py/tests/test_serve_client.py
Normal file
55
src/mvp/py/tests/test_serve_client.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def test_ray_serve_client_calls_start_run_status(monkeypatch):
|
||||
import ray # provided by conftest stub
|
||||
|
||||
calls: list[tuple[str, object]] = []
|
||||
|
||||
def _init(*args, **kwargs):
|
||||
calls.append(("ray.init", {"args": args, "kwargs": kwargs}))
|
||||
|
||||
monkeypatch.setattr(ray, "init", _init, raising=False)
|
||||
|
||||
serve = types.ModuleType("ray.serve")
|
||||
|
||||
def _start(**kwargs):
|
||||
calls.append(("serve.start", kwargs))
|
||||
return None
|
||||
|
||||
def _run(app, name=None, route_prefix=None):
|
||||
calls.append(("serve.run", {"app": app, "name": name, "route_prefix": route_prefix}))
|
||||
return {"deployed": True}
|
||||
|
||||
def _status():
|
||||
calls.append(("serve.status", None))
|
||||
return {"ok": True}
|
||||
|
||||
serve.start = _start # type: ignore[attr-defined]
|
||||
serve.run = _run # type: ignore[attr-defined]
|
||||
serve.status = _status # type: ignore[attr-defined]
|
||||
|
||||
sys.modules["ray.serve"] = serve
|
||||
ray.serve = serve # type: ignore[attr-defined]
|
||||
|
||||
from argus.service.serve_client import RayServeClient
|
||||
|
||||
client = RayServeClient(http_port=8000, proxy_location="HeadOnly", ray_init_address="auto")
|
||||
client.ensure_started()
|
||||
out = client.apply_app(app="APP", app_name="argus_llm_app", route_prefix="/")
|
||||
st = client.get_status()
|
||||
|
||||
assert out == {"deployed": True}
|
||||
assert st == {"ok": True}
|
||||
|
||||
# Verify call order and key args.
|
||||
assert calls[0][0] == "ray.init"
|
||||
assert calls[0][1]["kwargs"].get("ignore_reinit_error") is True
|
||||
assert calls[1][0] == "serve.start"
|
||||
assert calls[1][1]["http_options"]["port"] == 8000
|
||||
assert calls[2][0] == "serve.run"
|
||||
assert calls[2][1]["name"] == "argus_llm_app"
|
||||
assert calls[3][0] == "serve.status"
|
||||
@@ -23,6 +23,7 @@ def test_v2_config_from_root_dict_new_format_defaults():
|
||||
assert cfg.sqlite.db_path.endswith(".sqlite3")
|
||||
assert cfg.scheduler.max_running_tasks == 3
|
||||
assert cfg.tracking.wandb.enabled is False
|
||||
assert cfg.serving.enabled is False
|
||||
|
||||
|
||||
def test_v2_config_backward_compat_v2_section_and_default_db_path():
|
||||
@@ -57,6 +58,27 @@ def test_v2_config_requires_data_mappings():
|
||||
V2Config.from_root_dict({**base, "data": {"sftpgo": ["x"], "retention": {}}})
|
||||
|
||||
|
||||
def test_v2_config_requires_tracking_and_serving_mappings():
|
||||
from argus.service.config import V2Config
|
||||
|
||||
base = {
|
||||
"ray": {"shared_root": "/private"},
|
||||
"service": {"api": {}, "auth": {}, "sqlite": {}, "scheduler": {}},
|
||||
"data": {"sftpgo": {}, "retention": {}},
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="config\\.tracking must be a mapping"):
|
||||
V2Config.from_root_dict({**base, "tracking": ["nope"]})
|
||||
|
||||
with pytest.raises(ValueError, match="config\\.tracking\\.wandb must be a mapping"):
|
||||
V2Config.from_root_dict({**base, "tracking": {"wandb": ["nope"]}})
|
||||
|
||||
with pytest.raises(ValueError, match="config\\.serving must be a mapping"):
|
||||
V2Config.from_root_dict({**base, "serving": ["nope"]})
|
||||
|
||||
with pytest.raises(ValueError, match="config\\.serving\\.\\{serve,llm\\} must be mappings"):
|
||||
V2Config.from_root_dict({**base, "serving": {"serve": ["x"], "llm": {}}})
|
||||
|
||||
def test_tracking_wandb_defaults_disabled():
|
||||
from argus.service.config import V2Config
|
||||
|
||||
|
||||
23
src/mvp/py/tests/test_serving_model_id_prefix.py
Normal file
23
src/mvp/py/tests/test_serving_model_id_prefix.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from argus.service.serving_spec import make_model_id_prefix
|
||||
|
||||
|
||||
def test_make_model_id_prefix_uses_utc_minutes():
|
||||
dt = datetime(2026, 1, 6, 12, 35, 59, tzinfo=timezone.utc)
|
||||
assert make_model_id_prefix(user_id="alice", now_utc=dt) == "alice-202601061235"
|
||||
|
||||
|
||||
def test_make_model_id_prefix_rejects_empty_user_id():
|
||||
with pytest.raises(ValueError, match="user_id is required"):
|
||||
make_model_id_prefix(user_id="", now_utc=datetime.now(timezone.utc))
|
||||
|
||||
|
||||
def test_make_model_id_prefix_rejects_slash():
|
||||
with pytest.raises(ValueError, match="must not contain"):
|
||||
make_model_id_prefix(user_id="bad/user", now_utc=datetime.now(timezone.utc))
|
||||
|
||||
207
src/mvp/py/tests/test_serving_reconciler.py
Normal file
207
src/mvp/py/tests/test_serving_reconciler.py
Normal file
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class _FakeServeClient:
|
||||
def __init__(self):
|
||||
self.started = 0
|
||||
self.applied = []
|
||||
self.status_calls = 0
|
||||
self.fail_apply = False
|
||||
self.fail_status = False
|
||||
|
||||
def ensure_started(self) -> None:
|
||||
self.started += 1
|
||||
|
||||
def apply_app(self, *, app, app_name: str, route_prefix: str = "/"):
|
||||
if self.fail_apply:
|
||||
raise RuntimeError("boom")
|
||||
self.applied.append({"app": app, "app_name": app_name, "route_prefix": route_prefix})
|
||||
return {"ok": True}
|
||||
|
||||
def get_status(self):
|
||||
self.status_calls += 1
|
||||
if self.fail_status:
|
||||
raise RuntimeError("status boom")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _seed_model(db, *, model_key: str, user_id: str, state: str, num_replicas: int = 1, gpus_per_replica: int = 1):
|
||||
spec_yaml = "model_id: x\nmodel_source: $HOME/common/hf/x\n"
|
||||
resolved_yaml = f"user_id: {user_id}\nmodel_id: {user_id}-202601061235-x\n"
|
||||
db.create_serve_model(
|
||||
model_key=model_key,
|
||||
user_id=user_id,
|
||||
model_id_suffix="x",
|
||||
model_id_prefix=f"{user_id}-202601061235",
|
||||
model_id=f"{user_id}-202601061235-x",
|
||||
model_source="/private/hf/x",
|
||||
num_replicas=num_replicas,
|
||||
gpus_per_replica=gpus_per_replica,
|
||||
engine_kwargs_json=json.dumps({"gpu_memory_utilization": 0.9}),
|
||||
spec_yaml=spec_yaml,
|
||||
resolved_spec_yaml=resolved_yaml,
|
||||
)
|
||||
db.set_serve_model_state(model_key=model_key, state=state, event_type="TEST_SEED")
|
||||
|
||||
|
||||
def test_reconciler_skips_when_no_changes(tmp_path: Path):
|
||||
from argus.service.config import V2Config
|
||||
from argus.service.db import Db
|
||||
from argus.service.serving_reconciler import ServingReconciler
|
||||
|
||||
root = {
|
||||
"ray": {"shared_root": "/private"},
|
||||
"service": {"api": {}, "auth": {}, "sqlite": {"db_path": str(tmp_path / "mvp.sqlite3")}, "scheduler": {}},
|
||||
"data": {"sftpgo": {}, "retention": {}},
|
||||
"serving": {"serve": {"http_port": 8000}, "llm": {"accelerator_type": "H20"}},
|
||||
}
|
||||
cfg = V2Config.from_root_dict(root)
|
||||
db = Db(cfg.sqlite.db_path)
|
||||
db.init()
|
||||
|
||||
client = _FakeServeClient()
|
||||
rec = ServingReconciler(db=db, v2_cfg=cfg, ray_runtime_env_env_vars={}, serve_client=client, get_available_fn=lambda: type("A", (), {"total_available_gpus": 8, "total_available_npus": 0})())
|
||||
rec.tick()
|
||||
assert client.started == 0
|
||||
assert client.applied == []
|
||||
|
||||
|
||||
def test_reconciler_pending_resources_no_apply(tmp_path: Path):
|
||||
from argus.service.config import V2Config
|
||||
from argus.service.db import Db
|
||||
from argus.service.serving_reconciler import ServingReconciler
|
||||
|
||||
cfg = V2Config.from_root_dict(
|
||||
{
|
||||
"ray": {"shared_root": "/private"},
|
||||
"service": {"api": {}, "auth": {}, "sqlite": {"db_path": str(tmp_path / "mvp.sqlite3")}, "scheduler": {}},
|
||||
"data": {"sftpgo": {}, "retention": {}},
|
||||
"serving": {"serve": {"http_port": 8000}, "llm": {"accelerator_type": "H20"}},
|
||||
}
|
||||
)
|
||||
db = Db(cfg.sqlite.db_path)
|
||||
db.init()
|
||||
_seed_model(db, model_key="mk1", user_id="alice", state="QUEUED", num_replicas=2, gpus_per_replica=4)
|
||||
|
||||
client = _FakeServeClient()
|
||||
rec = ServingReconciler(
|
||||
db=db,
|
||||
v2_cfg=cfg,
|
||||
ray_runtime_env_env_vars={},
|
||||
serve_client=client,
|
||||
get_available_fn=lambda: type("A", (), {"total_available_gpus": 1, "total_available_npus": 0})(),
|
||||
)
|
||||
rec.tick()
|
||||
# Serve may be started even when resources are insufficient, but apply should not happen.
|
||||
assert client.started == 1
|
||||
assert client.applied == []
|
||||
# State remains QUEUED.
|
||||
row = db.get_serve_model("mk1")
|
||||
assert row and row["state"] == "QUEUED"
|
||||
ev = db.list_serve_events("mk1", limit=50)
|
||||
assert any(e["event_type"] == "SERVE_PENDING_RESOURCES" for e in ev)
|
||||
|
||||
|
||||
def test_reconciler_apply_success_marks_running(tmp_path: Path):
|
||||
from argus.service.config import V2Config
|
||||
from argus.service.db import Db
|
||||
from argus.service.serving_reconciler import ServingReconciler
|
||||
|
||||
cfg = V2Config.from_root_dict(
|
||||
{
|
||||
"ray": {"shared_root": "/private"},
|
||||
"service": {"api": {}, "auth": {}, "sqlite": {"db_path": str(tmp_path / "mvp.sqlite3")}, "scheduler": {}},
|
||||
"data": {"sftpgo": {}, "retention": {}},
|
||||
"serving": {"serve": {"http_port": 8000}, "llm": {"accelerator_type": "H20"}},
|
||||
}
|
||||
)
|
||||
db = Db(cfg.sqlite.db_path)
|
||||
db.init()
|
||||
_seed_model(db, model_key="mk1", user_id="alice", state="QUEUED", num_replicas=1, gpus_per_replica=1)
|
||||
|
||||
client = _FakeServeClient()
|
||||
rec = ServingReconciler(
|
||||
db=db,
|
||||
v2_cfg=cfg,
|
||||
ray_runtime_env_env_vars={"HF_ENDPOINT": "https://hf-mirror.com"},
|
||||
serve_client=client,
|
||||
get_available_fn=lambda: type("A", (), {"total_available_gpus": 8, "total_available_npus": 0})(),
|
||||
)
|
||||
rec.tick()
|
||||
assert client.started == 1
|
||||
assert len(client.applied) == 1
|
||||
applied = client.applied[0]["app"]["llm_configs"]
|
||||
assert applied[0]["engine_kwargs"]["tensor_parallel_size"] == 1
|
||||
assert applied[0]["runtime_env"]["env_vars"]["HF_HUB_OFFLINE"] == "1"
|
||||
row = db.get_serve_model("mk1")
|
||||
assert row and row["state"] == "RUNNING"
|
||||
|
||||
|
||||
def test_reconciler_delete_removes_and_marks_deleted(tmp_path: Path):
|
||||
from argus.service.config import V2Config
|
||||
from argus.service.db import Db
|
||||
from argus.service.serving_reconciler import ServingReconciler
|
||||
|
||||
cfg = V2Config.from_root_dict(
|
||||
{
|
||||
"ray": {"shared_root": "/private"},
|
||||
"service": {"api": {}, "auth": {}, "sqlite": {"db_path": str(tmp_path / "mvp.sqlite3")}, "scheduler": {}},
|
||||
"data": {"sftpgo": {}, "retention": {}},
|
||||
"serving": {"serve": {"http_port": 8000}, "llm": {"accelerator_type": "H20"}},
|
||||
}
|
||||
)
|
||||
db = Db(cfg.sqlite.db_path)
|
||||
db.init()
|
||||
_seed_model(db, model_key="keep", user_id="alice", state="RUNNING", num_replicas=1, gpus_per_replica=1)
|
||||
_seed_model(db, model_key="del", user_id="alice", state="DELETING", num_replicas=1, gpus_per_replica=1)
|
||||
|
||||
client = _FakeServeClient()
|
||||
rec = ServingReconciler(
|
||||
db=db,
|
||||
v2_cfg=cfg,
|
||||
ray_runtime_env_env_vars={},
|
||||
serve_client=client,
|
||||
get_available_fn=lambda: type("A", (), {"total_available_gpus": 8, "total_available_npus": 0})(),
|
||||
)
|
||||
rec.tick()
|
||||
assert len(client.applied) == 1
|
||||
cfgs = client.applied[0]["app"]["llm_configs"]
|
||||
assert {c["model_loading_config"]["model_id"] for c in cfgs} == {"alice-202601061235-x"} # only keep remains
|
||||
row = db.get_serve_model("del")
|
||||
assert row and row["state"] == "DELETED"
|
||||
assert row.get("deleted_at")
|
||||
|
||||
|
||||
def test_reconciler_apply_failure_marks_failed(tmp_path: Path):
|
||||
from argus.service.config import V2Config
|
||||
from argus.service.db import Db
|
||||
from argus.service.serving_reconciler import ServingReconciler
|
||||
|
||||
cfg = V2Config.from_root_dict(
|
||||
{
|
||||
"ray": {"shared_root": "/private"},
|
||||
"service": {"api": {}, "auth": {}, "sqlite": {"db_path": str(tmp_path / "mvp.sqlite3")}, "scheduler": {}},
|
||||
"data": {"sftpgo": {}, "retention": {}},
|
||||
"serving": {"serve": {"http_port": 8000}, "llm": {"accelerator_type": "H20"}},
|
||||
}
|
||||
)
|
||||
db = Db(cfg.sqlite.db_path)
|
||||
db.init()
|
||||
_seed_model(db, model_key="mk1", user_id="alice", state="QUEUED")
|
||||
|
||||
client = _FakeServeClient()
|
||||
client.fail_apply = True
|
||||
rec = ServingReconciler(
|
||||
db=db,
|
||||
v2_cfg=cfg,
|
||||
ray_runtime_env_env_vars={},
|
||||
serve_client=client,
|
||||
get_available_fn=lambda: type("A", (), {"total_available_gpus": 8, "total_available_npus": 0})(),
|
||||
)
|
||||
rec.tick()
|
||||
row = db.get_serve_model("mk1")
|
||||
assert row and row["state"] == "FAILED"
|
||||
assert row.get("error_summary")
|
||||
47
src/mvp/py/tests/test_serving_spec_paths.py
Normal file
47
src/mvp/py/tests/test_serving_spec_paths.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from argus.service.serving_spec import ServingSpec, resolve_serving_spec
|
||||
|
||||
|
||||
def test_expand_home_macro_and_validate_user_path_ok():
|
||||
spec = ServingSpec(
|
||||
model_id="qwen-0.5b",
|
||||
model_source="$HOME/models/my_model",
|
||||
num_replicas=1,
|
||||
gpus_per_replica=1,
|
||||
)
|
||||
r = resolve_serving_spec(spec=spec, user_id="alice", now_utc=datetime(2026, 1, 6, 12, 35, tzinfo=timezone.utc))
|
||||
assert r.model_source == "/private/users/alice/models/my_model"
|
||||
assert r.model_id == "alice-202601061235-qwen-0.5b"
|
||||
|
||||
|
||||
def test_expand_common_hf_macro_ok():
|
||||
spec = ServingSpec(
|
||||
model_id="qwen-0.5b",
|
||||
model_source="$HOME/common/hf/hub/models--Qwen--Qwen2.5/snapshots/abc",
|
||||
num_replicas=1,
|
||||
gpus_per_replica=1,
|
||||
)
|
||||
r = resolve_serving_spec(spec=spec, user_id="alice", now_utc=datetime(2026, 1, 6, 12, 35, tzinfo=timezone.utc))
|
||||
assert r.model_source.startswith("/private/hf/")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"src",
|
||||
[
|
||||
"/etc/passwd",
|
||||
"relative/path",
|
||||
"/private/users/bob/models/x",
|
||||
"/private/users/alice/../bob/x",
|
||||
"/private/common/hf/x",
|
||||
],
|
||||
)
|
||||
def test_model_source_path_rejected(src: str):
|
||||
spec = ServingSpec(model_id="qwen-0.5b", model_source=src, num_replicas=1, gpus_per_replica=1)
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
resolve_serving_spec(spec=spec, user_id="alice", now_utc=datetime(2026, 1, 6, 12, 35, tzinfo=timezone.utc))
|
||||
|
||||
72
src/mvp/py/tests/test_serving_spec_validation.py
Normal file
72
src/mvp/py/tests/test_serving_spec_validation.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from argus.service.serving_spec import ServingSpec, parse_serving_spec, validate_model_id_suffix
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"suffix",
|
||||
[
|
||||
"a",
|
||||
"qwen-0.5b",
|
||||
"Qwen2.5-0.5B",
|
||||
"a_b",
|
||||
"a.b-c",
|
||||
"a" * 64,
|
||||
],
|
||||
)
|
||||
def test_validate_model_id_suffix_accepts(suffix: str):
|
||||
validate_model_id_suffix(suffix)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"suffix",
|
||||
[
|
||||
"",
|
||||
" a",
|
||||
"a ",
|
||||
"-bad",
|
||||
".bad",
|
||||
"bad/",
|
||||
"bad..",
|
||||
"bad\n",
|
||||
"bad\t",
|
||||
"a" * 65,
|
||||
],
|
||||
)
|
||||
def test_validate_model_id_suffix_rejects(suffix: str):
|
||||
with pytest.raises(ValueError):
|
||||
validate_model_id_suffix(suffix)
|
||||
|
||||
|
||||
def test_parse_serving_spec_smoke_defaults():
|
||||
spec = parse_serving_spec(
|
||||
{
|
||||
"model_id": "qwen-0.5b",
|
||||
"model_source": "/private/hf/x",
|
||||
}
|
||||
)
|
||||
assert isinstance(spec, ServingSpec)
|
||||
assert spec.num_replicas == 1
|
||||
assert spec.gpus_per_replica == 1
|
||||
assert spec.engine_kwargs is None
|
||||
|
||||
|
||||
def test_parse_serving_spec_rejects_missing_fields():
|
||||
with pytest.raises(ValueError, match="missing required field: model_id"):
|
||||
parse_serving_spec({"model_source": "/private/hf/x"})
|
||||
with pytest.raises(ValueError, match="missing required field: model_source"):
|
||||
parse_serving_spec({"model_id": "x"})
|
||||
|
||||
|
||||
def test_parse_serving_spec_rejects_bad_types():
|
||||
with pytest.raises(ValueError, match="serving spec must be a mapping"):
|
||||
parse_serving_spec(["nope"])
|
||||
with pytest.raises(ValueError, match="num_replicas"):
|
||||
parse_serving_spec({"model_id": "x", "model_source": "/private/hf/x", "num_replicas": 0})
|
||||
with pytest.raises(ValueError, match="gpus_per_replica"):
|
||||
parse_serving_spec({"model_id": "x", "model_source": "/private/hf/x", "gpus_per_replica": 0})
|
||||
with pytest.raises(ValueError, match="engine_kwargs"):
|
||||
parse_serving_spec({"model_id": "x", "model_source": "/private/hf/x", "engine_kwargs": "nope"})
|
||||
|
||||
@@ -42,10 +42,13 @@ def test_ui_routes_render_200(tmp_path, monkeypatch):
|
||||
"/ui/login",
|
||||
"/ui/tasks",
|
||||
"/ui/tasks/new",
|
||||
"/ui/serving",
|
||||
"/ui/serving/new",
|
||||
"/ui/data",
|
||||
"/ui/admin",
|
||||
"/ui/tasks/any-task-id",
|
||||
"/ui/tasks/any-task-id/logs",
|
||||
"/ui/serving/any-model-key",
|
||||
):
|
||||
r = c.get(path, allow_redirects=True)
|
||||
assert r.status_code == 200
|
||||
@@ -60,7 +63,7 @@ def test_ui_contains_sidebar_links(tmp_path, monkeypatch):
|
||||
|
||||
r = c.get("/ui/tasks")
|
||||
assert r.status_code == 200
|
||||
for link in ("/ui/tasks", "/ui/tasks/new", "/ui/data", "/ui/login", "/ui/admin"):
|
||||
for link in ("/ui/tasks", "/ui/tasks/new", "/ui/serving", "/ui/data", "/ui/login", "/ui/admin"):
|
||||
assert link in r.text
|
||||
assert "Ray Dashboard" in r.text
|
||||
|
||||
|
||||
56
src/mvp/py/tests/test_ui_serving.py
Normal file
56
src/mvp/py/tests/test_ui_serving.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from argus.service.app import create_app
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path) -> Path:
|
||||
p = tmp_path / "cfg.yaml"
|
||||
p.write_text(
|
||||
"""
|
||||
ray:
|
||||
address: "http://127.0.0.1:8265"
|
||||
shared_root: "/private"
|
||||
entrypoint_num_cpus: 1
|
||||
entrypoint_resources: { worker_node: 1 }
|
||||
runtime_env: { env_vars: { PYTHONUNBUFFERED: "1" } }
|
||||
service:
|
||||
api: { host: "127.0.0.1", port: 8080 }
|
||||
auth: { token_env: "MVP_INTERNAL_TOKEN" }
|
||||
sqlite: { db_path: "%(db)s" }
|
||||
data:
|
||||
user_root: "%(users)s"
|
||||
sftpgo: { enabled: false }
|
||||
retention: { jobs_trash_after_days: 3, jobs_purge_after_days: 7, janitor_interval_s: 3600 }
|
||||
serving: {}
|
||||
"""
|
||||
% {"db": str(tmp_path / "mvp.sqlite3"), "users": str(tmp_path / "users")}
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def test_ui_serving_pages_render(tmp_path, monkeypatch):
|
||||
cfg = _write_config(tmp_path)
|
||||
monkeypatch.setenv("MVP_INTERNAL_TOKEN", "admin-token")
|
||||
app = create_app(str(cfg))
|
||||
c = TestClient(app)
|
||||
|
||||
for path in ("/ui/serving", "/ui/serving/new", "/ui/serving/any-model-key"):
|
||||
r = c.get(path)
|
||||
assert r.status_code == 200
|
||||
assert "<html" in r.text.lower()
|
||||
|
||||
|
||||
def test_ui_serving_contains_openai_port_8000(tmp_path, monkeypatch):
|
||||
cfg = _write_config(tmp_path)
|
||||
monkeypatch.setenv("MVP_INTERNAL_TOKEN", "admin-token")
|
||||
app = create_app(str(cfg))
|
||||
c = TestClient(app)
|
||||
|
||||
r = c.get("/ui/serving")
|
||||
assert r.status_code == 200
|
||||
assert "curOriginWithPort(8000)" in r.text
|
||||
assert "/v1/models" in r.text
|
||||
@@ -11,10 +11,11 @@ fi
|
||||
|
||||
echo "[host] docker compose up -d (mvp)"
|
||||
BUILD="${BUILD:-0}"
|
||||
RAY_NODE_IMAGE="${RAY_NODE_IMAGE:-argus/argus-ray-node:vllm011.latest}"
|
||||
|
||||
# If the image isn't present locally, force build once.
|
||||
if [[ "${BUILD}" != "1" ]]; then
|
||||
if ! docker image inspect argus/argus-ray-node:v2.5 >/dev/null 2>&1; then
|
||||
if ! docker image inspect "${RAY_NODE_IMAGE}" >/dev/null 2>&1; then
|
||||
BUILD="1"
|
||||
fi
|
||||
fi
|
||||
|
||||
18
src/mvp/scripts/debug_serve_llm_smoke.sh
Normal file
18
src/mvp/scripts/debug_serve_llm_smoke.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
container="${MVP_HEAD_CONTAINER:-argus-ray-head}"
|
||||
model_source="${MODEL_SOURCE:-}"
|
||||
if [[ -n "${1:-}" ]]; then
|
||||
model_source="$1"
|
||||
fi
|
||||
|
||||
argv=(python3 /workspace/mvp/scripts/serve_llm_smoke.py)
|
||||
if [[ -n "${model_source}" ]]; then
|
||||
argv+=(--model-source "${model_source}")
|
||||
fi
|
||||
argv+=(--accelerator-type "${ARGUS_ACCELERATOR_TYPE:-H20}")
|
||||
|
||||
echo "[host] run Ray Serve LLM smoke test in container: ${container}" >&2
|
||||
docker exec -it "${container}" bash -lc "$(printf '%q ' "${argv[@]}")"
|
||||
|
||||
193
src/mvp/scripts/run_all_v38_serving.sh
Executable file
193
src/mvp/scripts/run_all_v38_serving.sh
Executable file
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib.sh
|
||||
source "${SCRIPT_DIR}/lib.sh"
|
||||
|
||||
API_ADDR="${API_ADDR:-http://127.0.0.1:8080}"
|
||||
OPENAI_BASE_URL="${OPENAI_BASE_URL:-http://127.0.0.1:8000/v1}"
|
||||
ADMIN_TOKEN="${MVP_INTERNAL_TOKEN:-}"
|
||||
USER_ID="${USER_ID:-alice}"
|
||||
EXPECTED_RAY_NODES="${EXPECTED_RAY_NODES:-3}" # head + 2 workers
|
||||
|
||||
CONFIG_IN_CONTAINER="${CONFIG_IN_CONTAINER:-/workspace/mvp/configs/dev.yaml}"
|
||||
SFTPGO_ADMIN_PASSWORD="${SFTPGO_ADMIN_PASSWORD:-my-dev-sftpgo-admin}"
|
||||
export SFTPGO_ADMIN_PASSWORD
|
||||
|
||||
if [[ -z "${ADMIN_TOKEN}" ]]; then
|
||||
echo "ERROR: MVP_INTERNAL_TOKEN must be set in host env (admin token)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
api_curl_admin() {
|
||||
curl -sS -H "Authorization: Bearer ${ADMIN_TOKEN}" "$@"
|
||||
}
|
||||
|
||||
api_wait_ready() {
|
||||
local tries="${1:-60}"
|
||||
for i in $(seq 1 "${tries}"); do
|
||||
if curl -sS -m 2 "${API_ADDR}/docs" >/dev/null 2>&1; then
|
||||
echo "[host] api_ready: ${API_ADDR}"
|
||||
return 0
|
||||
fi
|
||||
echo "[host] waiting api... (${i}/${tries})"
|
||||
sleep 2
|
||||
done
|
||||
echo "ERROR: api not ready: ${API_ADDR}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
ray_wait_ready() {
|
||||
local tries="${1:-60}"
|
||||
for i in $(seq 1 "${tries}"); do
|
||||
if curl -sS -m 2 "${RAY_DASHBOARD_ADDR}/api/version" >/dev/null 2>&1; then
|
||||
echo "[host] ray_dashboard_ready: ${RAY_DASHBOARD_ADDR}"
|
||||
return 0
|
||||
fi
|
||||
echo "[host] waiting ray dashboard... (${i}/${tries})"
|
||||
sleep 2
|
||||
done
|
||||
echo "ERROR: ray dashboard not ready: ${RAY_DASHBOARD_ADDR}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
ray_wait_nodes() {
|
||||
local want="${1:-3}"
|
||||
local tries="${2:-60}"
|
||||
for i in $(seq 1 "${tries}"); do
|
||||
local out n
|
||||
out="$(docker exec -i "${HEAD_CONTAINER}" python3 -c "import ray; ray.init(address='auto', ignore_reinit_error=True, log_to_driver=False, logging_level='ERROR'); print(sum(1 for n in ray.nodes() if n.get('Alive')))" 2>/dev/null || true)"
|
||||
n="$(printf '%s\n' "${out}" | tail -n 1 | tr -cd '0-9' || true)"
|
||||
if [[ "${n}" =~ ^[0-9]+$ ]]; then
|
||||
echo "[host] ray_nodes_alive=${n} (want>=${want})"
|
||||
if [[ "${n}" -ge "${want}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
echo "[host] waiting ray nodes... (${i}/${tries})"
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "ERROR: ray nodes not ready (want>=${want})" >&2
|
||||
docker exec -i "${HEAD_CONTAINER}" bash -lc "ray status || true" >&2 || true
|
||||
return 1
|
||||
}
|
||||
|
||||
openai_wait_ready() {
|
||||
local tries="${1:-120}"
|
||||
for i in $(seq 1 "${tries}"); do
|
||||
if curl -sS -m 2 "${OPENAI_BASE_URL}/models" >/dev/null 2>&1; then
|
||||
echo "[host] openai_ready: ${OPENAI_BASE_URL}"
|
||||
return 0
|
||||
fi
|
||||
echo "[host] waiting openai... (${i}/${tries})"
|
||||
sleep 2
|
||||
done
|
||||
echo "ERROR: openai not ready: ${OPENAI_BASE_URL}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_model_state() {
|
||||
local token="$1"
|
||||
local model_key="$2"
|
||||
local want="$3"
|
||||
local tries="${4:-120}"
|
||||
for i in $(seq 1 "${tries}"); do
|
||||
local body state
|
||||
body="$(curl -sS -H "Authorization: Bearer ${token}" "${API_ADDR}/api/v2/serve/models/${model_key}")"
|
||||
state="$(printf '%s' "${body}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["model"]["state"])' 2>/dev/null || true)"
|
||||
echo "[host] model ${model_key}: ${state}"
|
||||
if [[ "${state}" == "${want}" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ "${state}" == "FAILED" ]]; then
|
||||
echo "[host] model failed; detail:" >&2
|
||||
printf '%s\n' "${body}" | python3 -m json.tool >&2 || true
|
||||
return 1
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
echo "ERROR: model not in state ${want} after timeout" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "[host] ===== run_all_v38_serving.sh begin ====="
|
||||
|
||||
"${SCRIPT_DIR}/00_prereq_check.sh"
|
||||
"${SCRIPT_DIR}/03_cleanup_v1_legacy.sh"
|
||||
"${SCRIPT_DIR}/04_cleanup_v2_legacy.sh"
|
||||
|
||||
echo "[host] bring down existing containers (best-effort)"
|
||||
"${SCRIPT_DIR}/02_down.sh" || true
|
||||
|
||||
echo "[host] (re)create containers (Ray + SFTPGo + W&B)"
|
||||
# For v3.8, we need the latest ray-node image (ray[llm] deps). Force build once.
|
||||
BUILD="${BUILD:-1}" "${SCRIPT_DIR}/01_up.sh"
|
||||
|
||||
echo "[host] wait ray ready"
|
||||
ray_wait_ready 60
|
||||
ray_wait_nodes "${EXPECTED_RAY_NODES}" 120
|
||||
|
||||
echo "[host] prepare data/model (best-effort; uses shared caches)"
|
||||
"${SCRIPT_DIR}/30_prepare_data_and_model.sh" || true
|
||||
|
||||
echo "[host] start api"
|
||||
CONFIG_IN_CONTAINER="${CONFIG_IN_CONTAINER}" MVP_INTERNAL_TOKEN="${ADMIN_TOKEN}" "${SCRIPT_DIR}/60_start_api.sh"
|
||||
api_wait_ready 60
|
||||
|
||||
echo "[host] create user (idempotent)"
|
||||
api_curl_admin -X POST "${API_ADDR}/api/v2/users" -H "Content-Type: application/json" --data-binary "{\"user_id\":\"${USER_ID}\"}" >/dev/null || true
|
||||
|
||||
echo "[host] issue user token"
|
||||
USER_TOKEN="$(api_curl_admin -X POST "${API_ADDR}/api/v2/users/${USER_ID}/tokens" | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')"
|
||||
|
||||
echo "[host] resolve local model snapshot path (offline)"
|
||||
LOCAL_MODEL_PATH="$(dexec "${HEAD_CONTAINER}" bash -lc "python3 -c \"import os; from huggingface_hub import snapshot_download; os.environ.setdefault('HF_HOME','/private/hf'); print(snapshot_download(repo_id='Qwen/Qwen2.5-0.5B-Instruct', local_files_only=True))\" " | tail -n 1)"
|
||||
if [[ -z "${LOCAL_MODEL_PATH}" || "${LOCAL_MODEL_PATH}" != /* ]]; then
|
||||
echo "ERROR: failed to resolve LOCAL_MODEL_PATH: ${LOCAL_MODEL_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[host] local_model_path: ${LOCAL_MODEL_PATH}"
|
||||
|
||||
echo "[host] submit serving model via API"
|
||||
SERVE_SPEC=$'model_id: qwen-0.5b\nmodel_source: '"${LOCAL_MODEL_PATH}"$'\nnum_replicas: 1\ngpus_per_replica: 1\n'
|
||||
CREATE_RESP="$(curl -sS -H "Authorization: Bearer ${USER_TOKEN}" -H "Content-Type: application/yaml" --data-binary "${SERVE_SPEC}" "${API_ADDR}/api/v2/serve/models")"
|
||||
echo "[host] create_model_resp: ${CREATE_RESP}"
|
||||
MODEL_KEY="$(printf '%s' "${CREATE_RESP}" | python3 -c 'import sys,json; print(json.load(sys.stdin)["model_key"])')"
|
||||
|
||||
echo "[host] wait model RUNNING"
|
||||
wait_model_state "${USER_TOKEN}" "${MODEL_KEY}" "RUNNING" 300
|
||||
|
||||
echo "[host] wait OpenAI ingress ready"
|
||||
openai_wait_ready 120
|
||||
|
||||
echo "[host] verify /v1/models contains model"
|
||||
MODEL_ID="$(
|
||||
curl -sS "${OPENAI_BASE_URL}/models" \
|
||||
| python3 -c 'import sys,json; obj=json.load(sys.stdin); print("\n".join([m.get("id","") for m in obj.get("data",[]) if isinstance(m,dict)]))' \
|
||||
| grep -E "^${USER_ID}-[0-9]{12}-qwen-0\\.5b$" \
|
||||
| head -n1 \
|
||||
|| true
|
||||
)"
|
||||
if [[ -z "${MODEL_ID}" ]]; then
|
||||
echo "ERROR: model id not found in /v1/models" >&2
|
||||
curl -sS "${OPENAI_BASE_URL}/models" | python3 -m json.tool >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "[host] model_id: ${MODEL_ID}"
|
||||
|
||||
echo "[host] chat completion (best-effort)"
|
||||
CHAT_RESP="$(curl -sS -H "Content-Type: application/json" -H "Authorization: Bearer FAKE_KEY" -X POST "${OPENAI_BASE_URL}/chat/completions" --data-binary "{\"model\":\"${MODEL_ID}\",\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}],\"max_tokens\":16,\"stream\":false}")"
|
||||
printf '%s\n' "${CHAT_RESP}" | python3 -m json.tool >/dev/null 2>&1 || {
|
||||
echo "ERROR: invalid chat response" >&2
|
||||
printf '%s\n' "${CHAT_RESP}" >&2
|
||||
exit 1
|
||||
}
|
||||
echo "[host] chat_ok"
|
||||
|
||||
echo "[host] delete model"
|
||||
curl -sS -H "Authorization: Bearer ${USER_TOKEN}" -X DELETE "${API_ADDR}/api/v2/serve/models/${MODEL_KEY}" >/dev/null
|
||||
wait_model_state "${USER_TOKEN}" "${MODEL_KEY}" "DELETED" 300
|
||||
|
||||
echo "[host] ===== run_all_v38_serving.sh done ====="
|
||||
102
src/mvp/scripts/serve_llm_smoke.py
Normal file
102
src/mvp/scripts/serve_llm_smoke.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _pick_qwen_snapshot() -> str | None:
|
||||
base = Path("/private/hf/hub/models--Qwen--Qwen2.5-0.5B-Instruct/snapshots")
|
||||
if not base.exists():
|
||||
return None
|
||||
snaps = sorted([p for p in base.iterdir() if p.is_dir()], reverse=True)
|
||||
return str(snaps[0]) if snaps else None
|
||||
|
||||
|
||||
def _http_get_json(url: str) -> Any:
|
||||
with urllib.request.urlopen(url, timeout=10) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
def _wait_http_json(url: str, *, timeout_s: int) -> Any:
|
||||
deadline = time.time() + float(timeout_s)
|
||||
last_err: Exception | None = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
return _http_get_json(url)
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
time.sleep(2)
|
||||
raise RuntimeError(f"timeout waiting for {url}: {last_err!r}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
ap = argparse.ArgumentParser(description="Ray Serve LLM smoke test (deploy + /v1/models probe).")
|
||||
ap.add_argument("--ray-address", default="auto")
|
||||
ap.add_argument("--http-port", type=int, default=8000)
|
||||
ap.add_argument("--app-name", default="argus_llm_smoke")
|
||||
ap.add_argument("--route-prefix", default="/")
|
||||
ap.add_argument("--accelerator-type", default=os.environ.get("ARGUS_ACCELERATOR_TYPE") or "H20")
|
||||
ap.add_argument("--model-id", default="smoke-qwen-0.5b")
|
||||
ap.add_argument("--model-source", default=None, help="Local path or HF id. Default: cached Qwen snapshot under /private/hf.")
|
||||
ap.add_argument("--tensor-parallel-size", type=int, default=1)
|
||||
ap.add_argument("--num-replicas", type=int, default=1)
|
||||
ap.add_argument("--wait-s", type=int, default=600)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
model_source = str(args.model_source or _pick_qwen_snapshot() or "")
|
||||
if not model_source:
|
||||
raise SystemExit("missing --model-source and no cached Qwen snapshot found under /private/hf")
|
||||
|
||||
# Force offline HF behavior for the smoke test.
|
||||
os.environ.setdefault("HF_HOME", "/private/hf")
|
||||
os.environ.setdefault("HUGGINGFACE_HUB_CACHE", "/private/hf/hub")
|
||||
os.environ.setdefault("TRANSFORMERS_CACHE", "/private/hf/transformers")
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
|
||||
import ray
|
||||
|
||||
ray.init(address=str(args.ray_address), ignore_reinit_error=True, log_to_driver=False)
|
||||
|
||||
from ray import serve
|
||||
|
||||
try:
|
||||
serve.start(proxy_location="HeadOnly", http_options={"host": "0.0.0.0", "port": int(args.http_port)})
|
||||
except Exception:
|
||||
# Best-effort: Serve may already be running in the container (e.g., started by the MVP API scheduler).
|
||||
pass
|
||||
|
||||
from ray.serve.llm import LLMConfig, build_openai_app
|
||||
|
||||
# Build a config dict and filter by the current Ray's LLMConfig schema, since fields
|
||||
# may differ between Ray versions.
|
||||
cfg_dict: dict[str, Any] = {
|
||||
"model_loading_config": {"model_id": str(args.model_id), "model_source": model_source},
|
||||
"accelerator_type": str(args.accelerator_type),
|
||||
"deployment_config": {"num_replicas": int(args.num_replicas)},
|
||||
"engine_kwargs": {"tensor_parallel_size": int(args.tensor_parallel_size)},
|
||||
"runtime_env": {"env_vars": {"HF_HUB_OFFLINE": "1", "HF_HOME": "/private/hf"}},
|
||||
}
|
||||
allowed = set(getattr(LLMConfig, "model_fields", {}).keys())
|
||||
if allowed:
|
||||
cfg_dict = {k: v for k, v in cfg_dict.items() if k in allowed}
|
||||
|
||||
llm_cfg = LLMConfig(**cfg_dict)
|
||||
app = build_openai_app({"llm_configs": [llm_cfg]})
|
||||
|
||||
serve.run(app, name=str(args.app_name), route_prefix=str(args.route_prefix))
|
||||
|
||||
models_url = f"http://127.0.0.1:{int(args.http_port)}/v1/models"
|
||||
payload = _wait_http_json(models_url, timeout_s=int(args.wait_s))
|
||||
print(json.dumps(payload, indent=2, sort_keys=True))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user