From c1b12db47915e91a5a9d2f143fc11b14ae262944 Mon Sep 17 00:00:00 2001 From: Dave Fobare Date: Fri, 15 May 2026 01:31:48 -0400 Subject: [PATCH] Support local hardware profile overlays --- .gitignore | 1 + README.md | 34 +++++++++ SourceCode/shared_tools/hardware_profiles.py | 42 +++++++++-- start_oathweaver_web.sh | 36 +++++++-- tests/test_hardware_profiles.py | 78 ++++++++++++++++++++ 5 files changed, 179 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 39134dc..ef54ac5 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ Runtime/learning/* # Environment variants (.env.local, .env.production, etc.) .env.* +SourceCode/configs/*.local.json # Key / certificate files *.pem diff --git a/README.md b/README.md index 023ef4c..c83723f 100755 --- a/README.md +++ b/README.md @@ -429,6 +429,40 @@ Oathweaver also consumes external MCP servers (filesystem, fetch) via [SourceCod - Configure host/port via `OATHWEAVER_WEB_HOST` and `OATHWEAVER_WEB_PORT`. - Set `OATHWEAVER_WEB_PASSWORD` when exposing beyond localhost. +## Local Hardware Profiles + +Shared defaults live in `SourceCode/configs/hardware_profiles.json`. Host-specific tuning belongs in +`SourceCode/configs/hardware_profiles.local.json`, which is ignored by git and merged over the shared config +at startup. Select a profile with `OATHWEAVER_HARDWARE_PROFILE`. + +```json +{ + "default_profile": "local_8gb_vram_48gb_ram_cuda", + "profiles": { + "local_8gb_vram_48gb_ram_cuda": { + "name": "local_8gb_vram_48gb_ram_cuda", + "display_name": "Local: 48GB RAM / 8GB CUDA", + "hardware": { + "system_ram_gb": 48, + "gpu_backend": "cuda", + "gpu_vram_gb": 8, + "unified_memory": false + }, + "scheduler": { + "max_context_tokens": 12288, + "warning_context_tokens": 8192, + "max_stage_context_tokens": 2400, + "max_parallel_models": 1, + "max_active_model_calls": 1, + "on_deck_depth": 1, + "warm_depth": 0, + "allow_neural_prefetch": false + } + } + } +} +``` + --- ## Repository Layout diff --git a/SourceCode/shared_tools/hardware_profiles.py b/SourceCode/shared_tools/hardware_profiles.py index 9f7b992..c745b76 100644 --- a/SourceCode/shared_tools/hardware_profiles.py +++ b/SourceCode/shared_tools/hardware_profiles.py @@ -10,6 +10,7 @@ ENV_HARDWARE_PROFILE = "OATHWEAVER_HARDWARE_PROFILE" CONFIG_RELATIVE_PATH = Path("SourceCode") / "configs" / "hardware_profiles.json" +LOCAL_CONFIG_RELATIVE_PATH = Path("SourceCode") / "configs" / "hardware_profiles.local.json" def _coerce_int(value: Any, default: int) -> int: @@ -82,23 +83,50 @@ def _builtin_config() -> dict[str, Any]: } -def load_hardware_profiles(repo_root: Path) -> dict[str, Any]: - """Load structured hardware profiles, falling back to the legacy default.""" - config_path = Path(repo_root) / CONFIG_RELATIVE_PATH +def _load_profile_payload(config_path: Path) -> dict[str, Any] | None: if not config_path.exists(): - return _builtin_config() + return None try: payload = json.loads(config_path.read_text(encoding="utf-8")) except Exception: - return _builtin_config() + return None if not isinstance(payload, dict): - return _builtin_config() + return None profiles = payload.get("profiles") if not isinstance(profiles, dict) or not profiles: - return _builtin_config() + return None return payload +def _merge_profile_payload(base: dict[str, Any], overlay: dict[str, Any] | None) -> dict[str, Any]: + merged = deepcopy(base) + if not isinstance(overlay, dict): + return merged + + default_profile = str(overlay.get("default_profile", "")).strip() + if default_profile: + merged["default_profile"] = default_profile + + base_profiles = merged.get("profiles") + if not isinstance(base_profiles, dict): + base_profiles = {} + merged["profiles"] = base_profiles + overlay_profiles = overlay.get("profiles") + if isinstance(overlay_profiles, dict): + for name, profile in overlay_profiles.items(): + if isinstance(profile, dict): + base_profiles[str(name)] = deepcopy(profile) + return merged + + +def load_hardware_profiles(repo_root: Path) -> dict[str, Any]: + """Load structured hardware profiles, then apply optional local-only overrides.""" + root = Path(repo_root) + base = _load_profile_payload(root / CONFIG_RELATIVE_PATH) or _builtin_config() + local = _load_profile_payload(root / LOCAL_CONFIG_RELATIVE_PATH) + return _merge_profile_payload(base, local) + + def resolve_active_hardware_profile(repo_root: Path, name: str | None = None) -> dict[str, Any]: """Resolve the active hardware profile by explicit name, env var, then config default.""" payload = load_hardware_profiles(repo_root) diff --git a/start_oathweaver_web.sh b/start_oathweaver_web.sh index 3dbdad9..ea69077 100755 --- a/start_oathweaver_web.sh +++ b/start_oathweaver_web.sh @@ -92,19 +92,41 @@ export OATHWEAVER_WEB_HOST="$WEB_HOST" export OATHWEAVER_WEB_PORT="$WEB_PORT" [[ -n "$WEB_PASSWORD" ]] && export OATHWEAVER_WEB_PASSWORD="$WEB_PASSWORD" -# --- Restart Ollama --- +# --- Manage or reuse Ollama --- if [[ "$NO_RESTART_OLLAMA" -eq 0 ]]; then pkill -x ollama 2>/dev/null || true sleep 1 + nohup "$OLLAMA_EXE" serve > /dev/null 2>&1 & +else + echo "Using existing Ollama process (--no-restart-ollama)." fi -nohup "$OLLAMA_EXE" serve > /dev/null 2>&1 & +ollama_ready_url() { + local raw="$1" + local scheme="http" + local hostport="$raw" + if [[ "$hostport" == http://* ]]; then + hostport="${hostport#http://}" + elif [[ "$hostport" == https://* ]]; then + scheme="https" + hostport="${hostport#https://}" + fi + hostport="${hostport%%/*}" + if [[ "$hostport" == 0.0.0.0:* ]]; then + hostport="127.0.0.1:${hostport#*:}" + elif [[ "$hostport" == "[::]:"* ]]; then + hostport="127.0.0.1:${hostport##*:}" + fi + echo "${scheme}://${hostport}/api/tags" +} + +OLLAMA_READY_URL="$(ollama_ready_url "$OLLAMA_HOST_VAL")" # --- Wait for Ollama ready --- echo "Waiting for Ollama to be ready..." READY=0 for i in $(seq 1 30); do - if curl -sf --max-time 2 "http://127.0.0.1:11434/api/tags" > /dev/null 2>&1; then + if curl -sf --max-time 2 "$OLLAMA_READY_URL" > /dev/null 2>&1; then READY=1 break fi @@ -112,11 +134,15 @@ for i in $(seq 1 30); do done if [[ "$READY" -eq 0 ]]; then - echo "ERROR: Ollama did not become ready on http://127.0.0.1:11434" + echo "ERROR: Ollama did not become ready on ${OLLAMA_READY_URL}" exit 1 fi -echo "Ollama started from: $OLLAMA_EXE" +if [[ "$NO_RESTART_OLLAMA" -eq 0 ]]; then + echo "Ollama started from: $OLLAMA_EXE" +else + echo "Ollama is reachable at: $OLLAMA_READY_URL" +fi echo "OLLAMA_MODELS: $OLLAMA_MODELS" echo "OLLAMA_HOST: $OLLAMA_HOST" echo "OATHWEAVER_WEB_PASSWORD set: $([ -n "${OATHWEAVER_WEB_PASSWORD:-}" ] && echo true || echo false)" diff --git a/tests/test_hardware_profiles.py b/tests/test_hardware_profiles.py index 9e208bd..6f65230 100644 --- a/tests/test_hardware_profiles.py +++ b/tests/test_hardware_profiles.py @@ -12,6 +12,7 @@ from shared_tools.hardware_profiles import ( CONFIG_RELATIVE_PATH, ENV_HARDWARE_PROFILE, + LOCAL_CONFIG_RELATIVE_PATH, hardware_profile_to_router_policy, hardware_profile_to_scheduler, resolve_active_hardware_profile, @@ -139,6 +140,83 @@ def test_active_profile_resolver_returns_named_profile_from_env(self) -> None: self.assertEqual(profile["hardware"]["system_ram_gb"], 24) self.assertEqual(profile["scheduler"]["warm_depth"], 0) + def test_local_profile_file_adds_host_specific_profiles(self) -> None: + config_path = self.repo_root / LOCAL_CONFIG_RELATIVE_PATH + config_path.write_text( + json.dumps( + { + "profiles": { + "host_cuda_48gb": { + "name": "host_cuda_48gb", + "hardware": { + "system_ram_gb": 48, + "gpu_backend": "cuda", + "gpu_vram_gb": 8, + "unified_memory": False, + }, + "scheduler": { + "max_context_tokens": 12288, + "warning_context_tokens": 8192, + "max_stage_context_tokens": 2400, + "max_parallel_models": 1, + "max_active_model_calls": 1, + "on_deck_depth": 1, + "warm_depth": 0, + "allow_neural_prefetch": False, + }, + } + } + }, + indent=2, + ), + encoding="utf-8", + ) + + profile = resolve_active_hardware_profile(self.repo_root, "host_cuda_48gb") + + self.assertEqual(profile["name"], "host_cuda_48gb") + self.assertEqual(profile["hardware"]["system_ram_gb"], 48) + self.assertEqual(profile["scheduler"]["max_context_tokens"], 12288) + + def test_local_profile_file_can_override_default_profile(self) -> None: + config_path = self.repo_root / LOCAL_CONFIG_RELATIVE_PATH + config_path.write_text( + json.dumps( + { + "default_profile": CUSTOM_PROFILE_NAME, + "profiles": { + CUSTOM_PROFILE_NAME: { + "name": CUSTOM_PROFILE_NAME, + "hardware": { + "system_ram_gb": 32, + "gpu_backend": "cuda", + "gpu_vram_gb": 8, + "unified_memory": False, + }, + "scheduler": { + "max_context_tokens": 8192, + "warning_context_tokens": 6144, + "max_stage_context_tokens": 2000, + "max_parallel_models": 1, + "max_active_model_calls": 1, + "on_deck_depth": 1, + "warm_depth": 0, + "allow_neural_prefetch": False, + }, + } + }, + }, + indent=2, + ), + encoding="utf-8", + ) + + profile = resolve_active_hardware_profile(self.repo_root) + + self.assertEqual(profile["name"], CUSTOM_PROFILE_NAME) + self.assertEqual(profile["hardware"]["system_ram_gb"], 32) + self.assertEqual(profile["scheduler"]["max_context_tokens"], 8192) + def test_unknown_profile_falls_back_to_default_with_warning(self) -> None: profile = resolve_active_hardware_profile(self.repo_root, "not_a_real_profile")