v3.8 model serving 部署成功
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user