"""Load nodes_registry and env.""" import os from pathlib import Path from typing import Any, Dict try: import yaml except ImportError: yaml = None # In Docker: set CONFIG_DIR=/app/config. Else: repo root / config if os.getenv("CONFIG_DIR"): _CONFIG_DIR = Path(os.getenv("CONFIG_DIR")).resolve() else: _REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent _CONFIG_DIR = _REPO_ROOT / "config" if not _CONFIG_DIR.exists(): _CONFIG_DIR = Path("config") _NODES_PATH = _CONFIG_DIR / "nodes_registry.yml" _NODES_ALT = Path("config/nodes_registry.yml") def get_nodes_registry_path() -> Path: for p in (_NODES_PATH, _NODES_ALT, Path("config/nodes_registry.yml")): if p.exists(): return p return _NODES_PATH def load_nodes_registry() -> Dict[str, Any]: for p in (_NODES_PATH, _NODES_ALT, Path("config/nodes_registry.yml")): if p.exists() and yaml: try: with open(p) as f: return yaml.safe_load(f) or {} except Exception: pass return {"nodes": {}, "defaults": {"health_timeout_sec": 10, "tools_timeout_sec": 30}} def save_nodes_registry(data: Dict[str, Any]) -> Path: if not yaml: raise RuntimeError("PyYAML is not available") path = get_nodes_registry_path() path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as f: yaml.safe_dump(data, f, allow_unicode=True, sort_keys=False) return path def get_gateway_url(node_id: str) -> str: """Gateway URL for node; env override: NODES__GATEWAY_URL.""" env_key = f"NODES_{node_id}_GATEWAY_URL" if os.getenv(env_key): return os.getenv(env_key) reg = load_nodes_registry() nodes = reg.get("nodes", {}) return (nodes.get(node_id) or {}).get("gateway_url", "") def get_node_policy(node_id: str) -> Dict[str, Any]: """Return operational policy for a node (timeouts, role, retry).""" reg = load_nodes_registry() defaults = reg.get("defaults", {}) node_cfg = (reg.get("nodes", {}).get(node_id) or {}) return { "node_role": node_cfg.get("node_role", "prod"), "gateway_timeout_ms": int(node_cfg.get( "gateway_timeout_ms", defaults.get("gateway_timeout_ms", 2500), )), "apply_timeout_ms": int(node_cfg.get( "apply_timeout_ms", defaults.get("apply_timeout_ms", 10000), )), "get_retry": int(node_cfg.get("get_retry", defaults.get("get_retry", 1))), "post_retry": int(node_cfg.get("post_retry", defaults.get("post_retry", 0))), "enabled": node_cfg.get("enabled", True), } def get_router_url(node_id: str) -> str: """Router URL for node. Priority: 1) NODES__ROUTER_URL 2) ROUTER_URL for current NODE_ID (single-node/local dev fallback) 3) nodes_registry.yml value 4) hardcoded localhost fallback """ env_key = f"NODES_{node_id}_ROUTER_URL" if os.getenv(env_key): return os.getenv(env_key) # Local fallback: when running console outside Docker, NODE_ID may be absent # while only ROUTER_URL is configured (without per-node env override). current_node = os.getenv("NODE_ID", "").strip().upper() router_url = os.getenv("ROUTER_URL", "").strip() target_node = str(node_id).strip().upper() if router_url and current_node and current_node == target_node: return router_url # Compatibility fallback for legacy local startup scripts that target NODA2 # but do not export NODE_ID. if router_url and not current_node and target_node == "NODA2": return router_url reg = load_nodes_registry() nodes = reg.get("nodes", {}) return (nodes.get(node_id) or {}).get("router_url", "http://localhost:8000") def get_node_ssh_profile(node_id: str) -> Dict[str, Any]: """SSH profile for node with env overrides. Env overrides: NODES__SSH_HOST NODES__SSH_PORT NODES__SSH_USER NODES__SSH_PASSWORD NODES__SSH_PRIVATE_KEY """ reg = load_nodes_registry() nodes = reg.get("nodes", {}) node = nodes.get(node_id, {}) or {} ssh = dict(node.get("ssh") or {}) auth = dict(ssh.get("auth") or {}) prefix = f"NODES_{node_id}_SSH_" host = os.getenv(f"{prefix}HOST", ssh.get("host", "")).strip() user = os.getenv(f"{prefix}USER", ssh.get("user", "")).strip() private_key = os.getenv(f"{prefix}PRIVATE_KEY", auth.get("private_key", "")).strip() password_env = (auth.get("password_env") or f"{prefix}PASSWORD").strip() password = os.getenv(f"{prefix}PASSWORD", os.getenv(password_env, "")).strip() try: port = int(os.getenv(f"{prefix}PORT", str(ssh.get("port", 22)))) except Exception: port = 22 return { "configured": bool(host and user), "host": host, "ipv6": ssh.get("ipv6", ""), "port": port, "user": user, "host_keys": ssh.get("host_keys", []), "auth": { "password_env": password_env, "password_set": bool(password), "private_key_set": bool(private_key), }, } def get_memory_service_url() -> str: """Memory-service URL; env override: MEMORY_SERVICE_URL.""" if os.getenv("MEMORY_SERVICE_URL"): return os.getenv("MEMORY_SERVICE_URL").rstrip("/") reg = load_nodes_registry() defaults = reg.get("defaults", {}) if defaults.get("memory_service_url"): return defaults["memory_service_url"].rstrip("/") return "http://localhost:8000" def get_ollama_url() -> str: """Ollama URL; env override: OLLAMA_URL.""" return os.getenv("OLLAMA_URL", "http://localhost:11434").rstrip("/") def is_voice_ha_enabled() -> bool: """Voice HA feature flag. Set VOICE_HA_ENABLED=true to opt-in. When enabled, /api/voice/tts and /api/voice/chat/stream use Router /v1/capability/voice_tts and /v1/capability/voice_llm endpoints for multi-node failover instead of calling memory-service directly. Default: False (safe for existing deployments). """ return os.getenv("VOICE_HA_ENABLED", "false").lower() in ("1", "true", "yes") def get_voice_ha_router_url(node_id: str = "NODA2") -> str: """Router URL used for Voice HA offload. Defaults to same router as LLM.""" override = os.getenv("VOICE_HA_ROUTER_URL") if override: return override.rstrip("/") return get_router_url(node_id).rstrip("/")