multi-engine: live /v1/models on config change + accept gguf-stem ids

Two bugs made a freshly-configured model unusable until a full restart on a
multi-engine node:

1. Name mismatch: list_models advertises a gguf's filename WITHOUT .gguf as an
   id, but get_all_allowed_identifiers only allowed the name WITH .gguf, so a
   request using the id from /v1/models was 404'd as "not an allowed model".
   Now the .gguf-stripped stem is allowed too.

2. Stale per-engine assignment: each engine's /v1/models is filtered by the
   assignment set fixed at startup, and secondary engines never re-read
   models.json — so an added/removed model didn't show up or route until
   restart. The front now watches models.json mtime, recomputes the
   assignment, updates its router, and pushes it to every engine via a new
   internal POST /internal/reload-config (re-reads models.json +
   set_assigned_models). /v1/models and routing now reflect add/remove live.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent f0dcf7eb
......@@ -309,6 +309,40 @@ async def internal_engine_state():
"vram": vram, "tasks": tasks, "cooling": cooling}
@app.post("/internal/reload-config", include_in_schema=False)
async def internal_reload_config(request: Request):
"""Live config refresh pushed by the front when models.json changes: re-read
models.json and update this engine's assigned-model set, so /v1/models and
request validation reflect added/removed models without a restart. Weights are
untouched — (re)loading still happens lazily on the next request."""
import json as _json
try:
body = await request.body()
data = _json.loads(body or b"{}") or {}
except Exception:
data = {}
n = 0
try:
from codai.admin.routes import config_manager
if config_manager is not None and getattr(config_manager, "models_path", None):
with open(config_manager.models_path) as _f:
config_manager.models_data = _json.load(_f)
_cats = ("text_models", "gguf_models", "vision_models", "image_models",
"audio_models", "tts_models", "video_models", "audio_gen_models",
"embedding_models", "spatial_models")
n = sum(len(config_manager.models_data.get(c, []) or []) for c in _cats)
except Exception as e:
return {"ok": False, "error": str(e)}
try:
from codai.models.manager import multi_model_manager
assigned = data.get("assigned")
if assigned is not None:
multi_model_manager.set_assigned_models(set(assigned))
except Exception:
pass
return {"ok": True, "models": n}
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
if _favicon_path.exists():
......
......@@ -129,6 +129,7 @@ class EngineSupervisor:
self.internal_token = internal_token # shared secret stamped on engine calls
self.debug = debug # --debug-engine: verbose engine lifecycle
self._health = {} # engine_id -> last healthy bool (for debug)
self._assign_mtime = 0.0 # models.json mtime → live re-assignment
self._stopped = threading.Event()
self._poll_thread = None
self._logs = {} # engine_id -> deque tail
......@@ -431,12 +432,52 @@ class EngineSupervisor:
self._poll_thread.start()
atexit.register(self.stop_all)
def _push_assignment_if_changed(self, client) -> None:
"""When models.json changes (admin add/remove model), re-compute the
per-engine assignment and push it live to every engine + the front's
router, so /v1/models and routing reflect the change without a restart."""
if not self.models_path or len(self.registry.all()) < 2:
return
try:
mtime = os.path.getmtime(self.models_path)
except OSError:
return
if mtime == self._assign_mtime:
return
self._assign_mtime = mtime
try:
from codai.frontproxy.assignment import compute_assignment
default_engine = getattr(self.config.server, "default_engine", None)
ds4 = getattr(self.config, "ds4", None)
assignment = compute_assignment(self.registry.all(), self.models_path,
default_engine, ds4)
except Exception as exc:
print(f"[front] live reassignment skipped: {exc}", flush=True)
return
for e in self.registry.all():
owned = assignment.get(e.name, [])
e.assigned_models = set(owned) # update the front's router
try:
client.post(e.url + "/internal/reload-config", json={"assigned": owned})
except Exception as exc:
print(f"[front] reload-config push to '{e.name}' failed: {exc}",
flush=True)
print(f"[front] models.json changed — re-pushed assignment "
f"({', '.join(f'{e.name}:{len(assignment.get(e.name, []))}' for e in self.registry.all())})",
flush=True)
def _poll_loop(self) -> None:
_auth = ({"x-coderai-internal": self.internal_token}
if self.internal_token else {})
client = httpx.Client(timeout=self.config.server.proxy_status_timeout,
headers=_auth)
try:
self._assign_mtime = (os.path.getmtime(self.models_path)
if self.models_path else 0.0)
except OSError:
self._assign_mtime = 0.0
while not self._stopped.is_set():
self._push_assignment_if_changed(client)
for engine in self.registry.all():
# Respawn engines whose process has exited.
if engine.proc is not None and engine.proc.poll() is not None:
......
......@@ -1600,6 +1600,12 @@ class MultiModelManager:
allowed.add(val)
short = val.split("/")[-1] if "/" in val else val
allowed.add(short)
# list_models advertises a gguf's filename WITHOUT
# the .gguf extension as an id (its auto-alias), so
# accept that name too — otherwise a request using
# the id from /v1/models is rejected as not allowed.
if short.lower().endswith(".gguf"):
allowed.add(short[:-5])
except Exception:
pass
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment