v3.8 model serving 部署成功

This commit is contained in:
2026-01-06 22:43:29 +08:00
parent 63963eba29
commit 686739fea2
39 changed files with 6772 additions and 1830 deletions

View File

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