front: config authority — re-read config.json after a save (Phase 2)

The front is the authority for config READS (settings GET, status, queue capacity).
A settings SAVE stays on the engine because it applies live runtime changes
(thermal, RAM monitor, broker runtime) that only exist there and then persists
config.json. The front now detects the config.json mtime change and re-reads it
into self.config IN PLACE (so the supervisor/admin_data references see it), keeping
everything it serves current without a restart. Triggered from status() and the
settings GET handler (via an on_config_read callback) and lazily in the capacity
helper.

This is the pragmatic Phase 2: front owns/serves config; engine applies+persists;
front stays consistent. (Fully relocating the 362-line apply logic to the front
isn't sound — the live application must run on the engine.)

Verified: front reflects an external config.json change (queue_max 6→11) with no
restart.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DDv7BchtZQWsnPG6Jm49m
parent fae9b6f9
...@@ -22,7 +22,8 @@ from fastapi import FastAPI, Request ...@@ -22,7 +22,8 @@ from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
def register_admin_data(app: FastAPI, config_dir, config=None) -> bool: def register_admin_data(app: FastAPI, config_dir, config=None,
on_config_read=None) -> bool:
if not config_dir: if not config_dir:
return False return False
try: try:
...@@ -169,6 +170,11 @@ def register_admin_data(app: FastAPI, config_dir, config=None) -> bool: ...@@ -169,6 +170,11 @@ def register_admin_data(app: FastAPI, config_dir, config=None) -> bool:
_u, err = _admin(request) _u, err = _admin(request)
if err: if err:
return err return err
if on_config_read is not None:
try:
on_config_read() # re-read config.json if a save changed it
except Exception:
pass
try: try:
from codai.admin.routes import build_settings_dict from codai.admin.routes import build_settings_dict
from codai.frontproxy.gpu_detect import gpu_cards as _gpu_cards from codai.frontproxy.gpu_detect import gpu_cards as _gpu_cards
......
...@@ -75,6 +75,15 @@ class FrontProxy: ...@@ -75,6 +75,15 @@ class FrontProxy:
self._models_path = os.path.join(config_dir, "models.json") if config_dir else None self._models_path = os.path.join(config_dir, "models.json") if config_dir else None
self._pins: dict = {} self._pins: dict = {}
self._pins_mtime: float = -1.0 self._pins_mtime: float = -1.0
# The front owns config as the authority for reads (settings GET, status,
# capacity). A settings SAVE still runs on the engine (it applies live
# runtime changes — thermal, RAM monitor — that only exist there) and
# persists config.json; the front re-reads it on mtime change so everything
# it serves stays current. self.config is mutated in place so components
# holding a reference (admin_data, supervisor) see the refresh.
self._config_dir = config_dir
self._config_path = os.path.join(config_dir, "config.json") if config_dir else None
self._config_mtime: float = -1.0
# Last-good /v1/models list per engine. An engine that's mid-load is # Last-good /v1/models list per engine. An engine that's mid-load is
# GIL-blocked and misses health polls; without this its models (incl. a # GIL-blocked and misses health polls; without this its models (incl. a
# freshly-added one) would flicker out of the aggregated /v1/models while # freshly-added one) would flicker out of the aggregated /v1/models while
...@@ -424,8 +433,38 @@ class FrontProxy: ...@@ -424,8 +433,38 @@ class FrontProxy:
return max(1, int(getattr(_models, "max_model_instances", 1) or 1)) return max(1, int(getattr(_models, "max_model_instances", 1) or 1))
def _queue_max_waiting(self) -> int: def _queue_max_waiting(self) -> int:
self._refresh_config_if_changed()
return max(0, int(getattr(self.config.server, "queue_max_size", 6) or 0)) return max(0, int(getattr(self.config.server, "queue_max_size", 6) or 0))
def _refresh_config_if_changed(self) -> None:
"""Re-read config.json into self.config (in place) when it changes, so the
front's config-derived endpoints reflect a settings save (which the engine
persists) without a restart. Cheap: only acts on an mtime change."""
if not self._config_path:
return
import os
try:
mtime = os.path.getmtime(self._config_path)
except OSError:
return
if mtime == self._config_mtime:
return
self._config_mtime = mtime
try:
from codai.config import ConfigManager
cm = ConfigManager(self._config_dir)
cm.load()
new = cm.config
for f in ("server", "backend", "models", "offload", "vulkan", "image",
"whisper", "archive", "thermal", "jobs", "enhance", "ds4",
"compaction", "broker", "system_prompt", "tools_closer_prompt",
"grammar_guided", "parser", "tmp_dir"):
if hasattr(new, f):
setattr(self.config, f, getattr(new, f))
self.default_engine = getattr(self.config.server, "default_engine", None)
except Exception:
pass
def _required_cap(self, path: str, model: Optional[str]) -> Optional[str]: def _required_cap(self, path: str, model: Optional[str]) -> Optional[str]:
ds4 = getattr(self.config, "ds4", None) ds4 = getattr(self.config, "ds4", None)
info = self._model_info(model) info = self._model_info(model)
...@@ -1192,6 +1231,7 @@ class FrontProxy: ...@@ -1192,6 +1231,7 @@ class FrontProxy:
# keeps Overview instant and correct regardless of engine load. # keeps Overview instant and correct regardless of engine load.
if not self._has_cred(request): if not self._has_cred(request):
return JSONResponse({"detail": "Unauthorized"}, status_code=401) return JSONResponse({"detail": "Unauthorized"}, status_code=401)
self._refresh_config_if_changed()
return JSONResponse(self._native_status()) return JSONResponse(self._native_status())
def _enabled_models_and_aliases(self): def _enabled_models_and_aliases(self):
...@@ -1445,7 +1485,8 @@ def build_app(config, config_dir=None) -> FastAPI: ...@@ -1445,7 +1485,8 @@ def build_app(config, config_dir=None) -> FastAPI:
# — answered from disk so they stay live while the engine is busy generating. # — answered from disk so they stay live while the engine is busy generating.
try: try:
from codai.frontproxy.admin_data import register_admin_data from codai.frontproxy.admin_data import register_admin_data
register_admin_data(app, config_dir, config=config) register_admin_data(app, config_dir, config=config,
on_config_read=front._refresh_config_if_changed)
except Exception as _exc: except Exception as _exc:
print(f"[front] could not register local admin-data endpoints ({_exc}); " print(f"[front] could not register local admin-data endpoints ({_exc}); "
f"they will be proxied to the engine", flush=True) f"they will be proxied to the engine", flush=True)
......
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