forked from enderofwings/NexusOS
Reconciles 17 commits of this session's work (self-alteration tools, vendored Curry, slash-command dispatch, Windows toolchain/gate fixes) against origin/main's v1.2.0 sync (Projects/RAG scoping, a new modules/ system for mail and network, in-app updates, the standalone memory microservice folded into an in-process curator, KDE desktop theme overhaul). Nine real conflicts, each resolved by hand after reading both sides' actual diffs rather than picking one side wholesale: - synapse/tools.py, tests/test_tools.py: origin/main's diff here was small and clean (read_file/list_files, two new tests) despite git's diff3 flagging the whole file as one conflict blob -- reset to this branch's version and hand-spliced their addition in at the same points they used, rather than trying to reconcile a false 800-line conflict. Found and fixed a real bug while verifying: _list_files returned backslash-separated paths on Windows, which don't match the forward-slash glob patterns the tool's own schema documents. - synapse/main.py: kept this branch's cue-based standing advertisement of render_preview/run_snippet (independent of any playbook granting them) AND adopted origin/main's fix for routed reference playbooks not bringing their own tools along -- dropping either would have been a real regression, not just a style difference. Also: the standalone memory service (port 8001) is gone upstream, so its dead CORS/kill- target entries were removed; NEXUS_BACKEND_PORT parameterization and the manage_ollama-conditional kill logic (this branch's remote-Ollama support) were kept over origin/main's hardcoded equivalents. - synapse/memory/store.py: kept this branch's _delete_message_vectors helper (already reused elsewhere, batches to stay under SQLite's variable limit) over origin/main's inline duplicate of the same fix. - synapse/nexus_config.py, nexusos_cli/ncp.py: dropped the now-dead memory-service port/service entries; kept NEXUS_BACKEND_PORT env override and the manage_ollama-conditional kill-target list. - CLAUDE.md, README.md: merged both sides' additions, no real conflict. Found and fixed three more issues while independently verifying the merged tree, none of them mine or origin/main's alone -- only visible once both sides actually ran together: - modules/ (the new mail+network package) was never added to pyproject.toml's wheel `packages` list OR the sdist's `include` allowlist, so `from modules.registry import ROUTERS` in main.py would ImportError on any wheel install. Fixed both; bin/check.sh's packaging gate now asserts modules/ actually ships. tests/ test_packaging_deps.py's FIRST_PARTY/SHIPPED_PACKAGES sets were updated to recognize the new package. - tests/test_mail_creds.py's 0600-mode assertions are POSIX-only -- NTFS has no equivalent permission bits, so os.open(path, 0o600) on Windows just creates a normal file and stat.S_IMODE reports 0o666 regardless. Made the assertions platform-aware rather than skip real coverage (the temp-file-cleanup and password round-trip checks in the same test still run on Windows) or paper over a genuine OS limitation with a fake pass. - tests/test_kde_theme.py used bare Path.read_text() in fifteen places; Windows' default locale encoding (cp1252, not UTF-8) can't decode a real UTF-8 byte in the QML it reads, and did fail on one of the fifteen. Fixed all fifteen, not just the one that happened to trip today, since the other fourteen were equally fragile. Verified: full bin/check.sh reports OK end-to-end on this Windows checkout -- pytest (tests + management): 295 passed, 0 failed, 9 skipped; eslint clean; frontend node:test 57/57; PowerShell/shell parse clean; wheel + sdist pass twine check and now correctly carry modules/ (60 files, up from 52 pre-merge). synapse.main:app builds with 74 routes (up from 54 pre-merge, matching the new Projects/mail/ network endpoints).
442 lines
18 KiB
Python
442 lines
18 KiB
Python
# config.py
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
from importlib import metadata
|
|
from pathlib import Path
|
|
from typing import Dict, Any
|
|
|
|
# --- ENV ---
|
|
try:
|
|
from dotenv import load_dotenv # optional
|
|
load_dotenv()
|
|
except Exception:
|
|
pass
|
|
|
|
# --- INSTALL / RESOURCE LAYOUT ---
|
|
PACKAGE_DIR = Path(__file__).resolve().parent
|
|
_CHECKOUT_ROOT = PACKAGE_DIR.parent
|
|
SOURCE_CHECKOUT = (
|
|
(_CHECKOUT_ROOT / "VERSION").is_file()
|
|
and (_CHECKOUT_ROOT / "interface" / "web" / "package.json").is_file()
|
|
)
|
|
|
|
# PROJECT_ROOT remains the source checkout for developer installs. In a wheel it
|
|
# is the installed package directory; writable state is deliberately elsewhere.
|
|
PROJECT_ROOT = Path(os.getenv("NEXUS_PROJECT_ROOT", "")).expanduser() if os.getenv(
|
|
"NEXUS_PROJECT_ROOT"
|
|
) else (_CHECKOUT_ROOT if SOURCE_CHECKOUT else PACKAGE_DIR)
|
|
PROJECT_ROOT = PROJECT_ROOT.resolve()
|
|
RESOURCE_ROOT = PROJECT_ROOT if SOURCE_CHECKOUT else PACKAGE_DIR / "_resources"
|
|
|
|
|
|
def _user_dir(env_name: str, windows_leaf: str, xdg_name: str, xdg_fallback: str) -> Path:
|
|
# os.getenv's default only applies when a variable is *unset*. An exported
|
|
# but empty XDG_DATA_HOME / LOCALAPPDATA would otherwise give Path("") ==
|
|
# ".", scattering state through whatever the cwd happened to be. The XDG
|
|
# spec says to treat an empty value as unset, so `or` - not a default arg.
|
|
override = os.getenv(env_name, "").strip()
|
|
if override:
|
|
return Path(override).expanduser().resolve()
|
|
if os.name == "nt":
|
|
base = Path(os.getenv("LOCALAPPDATA", "").strip() or Path.home() / "AppData" / "Local")
|
|
return (base / windows_leaf).expanduser().resolve()
|
|
base = Path(os.getenv(xdg_name, "").strip() or Path.home() / xdg_fallback).expanduser()
|
|
return (base / "nexusos").resolve()
|
|
|
|
|
|
CONFIG_DIR = _user_dir("NEXUS_CONFIG_DIR", "NexusOS", "XDG_CONFIG_HOME", ".config")
|
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
|
|
|
|
def read_user_config() -> dict[str, Any]:
|
|
try:
|
|
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
|
|
return data if isinstance(data, dict) else {}
|
|
except (OSError, ValueError, TypeError):
|
|
return {}
|
|
|
|
|
|
def write_user_config(values: dict[str, Any]) -> None:
|
|
"""Atomically persist CLI-managed configuration."""
|
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
tmp = CONFIG_FILE.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(values, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
tmp.replace(CONFIG_FILE)
|
|
|
|
|
|
USER_CONFIG = read_user_config()
|
|
|
|
|
|
def _value(key: str, env_name: str, default: Any) -> Any:
|
|
raw = os.getenv(env_name)
|
|
return raw if raw not in (None, "") else USER_CONFIG.get(key, default)
|
|
|
|
|
|
def _int_value(key: str, env_name: str, default: int) -> int:
|
|
try:
|
|
return int(_value(key, env_name, default))
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _configured_path(key: str, env_name: str, default: Path) -> Path:
|
|
return Path(str(_value(key, env_name, default))).expanduser().resolve()
|
|
|
|
|
|
# --- VERSION (repo file in a checkout; distribution metadata in a wheel) ---
|
|
try:
|
|
if SOURCE_CHECKOUT:
|
|
VERSION = (PROJECT_ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
|
else:
|
|
VERSION = metadata.version("nexusos-ai")
|
|
except Exception:
|
|
try:
|
|
VERSION = (RESOURCE_ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
|
except Exception:
|
|
VERSION = "0.0.0"
|
|
|
|
# --- MODEL DEFAULTS ---
|
|
# Single source of truth for the three models NexusOS ships with. The installers
|
|
# pull and pin these, the runtime falls back to them; keeping them in one place
|
|
# is what stops installer and backend from drifting apart.
|
|
#
|
|
# Chat: llama3.1:8b - a strong non-reasoning instruct model (~4.9 GB; overflows a
|
|
# 4 GB GPU into CPU/RAM). Chosen over Qwen3 because Qwen3 is a reasoning model:
|
|
# smart only with its slow <think> step, weak without it.
|
|
# Memory: the curator that extracts facts and titles conversations. It is the
|
|
# CHAT model on purpose, not a second one: the chat model is already resident in
|
|
# VRAM and warm, so extraction costs no extra load. A distinct curator (mistral)
|
|
# did not fit alongside it and had to be pinned to CPU (num_gpu=0), which made
|
|
# every extraction a slow prompt-eval on a model too small to follow the
|
|
# curator prompt's negative rules reliably.
|
|
# Embed: nomic-embed-text - powers semantic recall of past conversations
|
|
# (OllamaManager.embed / store.semantic_search_conversations). Without it,
|
|
# recall silently degrades to lexical substring matching.
|
|
DEFAULT_CHAT_MODEL = "llama3.1:8b"
|
|
DEFAULT_MEMORY_MODEL = DEFAULT_CHAT_MODEL
|
|
DEFAULT_EMBED_MODEL = "nomic-embed-text"
|
|
|
|
# --- CORE DIRECTORIES ---
|
|
_DEFAULT_STATE = PROJECT_ROOT if SOURCE_CHECKOUT else _user_dir(
|
|
"NEXUS_HOME", "NexusOS", "XDG_DATA_HOME", ".local/share"
|
|
)
|
|
STATE_DIR = _configured_path("home", "NEXUS_HOME", _DEFAULT_STATE)
|
|
_USE_CHECKOUT_STATE = SOURCE_CHECKOUT and not os.getenv("NEXUS_HOME", "").strip()
|
|
DATA_DIR = _configured_path("data_dir", "NEXUS_DATA_DIR", (
|
|
PROJECT_ROOT / "data" if _USE_CHECKOUT_STATE else STATE_DIR / "data"
|
|
))
|
|
MODELS_DIR = _configured_path("models_dir", "NEXUS_MODELS_DIR", (
|
|
PROJECT_ROOT / "models" if _USE_CHECKOUT_STATE else STATE_DIR / "models"
|
|
))
|
|
RUNTIME_DIR = _configured_path("runtime_dir", "NEXUS_RUNTIME_DIR", (
|
|
PROJECT_ROOT / "runtime" if _USE_CHECKOUT_STATE else STATE_DIR / "runtime"
|
|
))
|
|
|
|
MEMORY_DIR = _configured_path("memory_dir", "NEXUS_MEMORY_DIR", (
|
|
PROJECT_ROOT / "synapse" / "memory" if _USE_CHECKOUT_STATE else DATA_DIR
|
|
))
|
|
|
|
LOGS_DIR = RUNTIME_DIR / "logs"
|
|
CACHE_DIR = RUNTIME_DIR / "cache"
|
|
TEMP_DIR = RUNTIME_DIR / "tmp"
|
|
|
|
# --- APPLICATION SUBSYSTEM DIRECTORIES ---
|
|
PLAYBOOK_DIR = DATA_DIR / "playbooks" # YAML playbook files (PlaybookFileStore)
|
|
UPLOADS_DIR = DATA_DIR / "uploads"
|
|
EXPORTS_DIR = DATA_DIR / "exports"
|
|
WEB_DIST_DIR = (
|
|
PROJECT_ROOT / "interface" / "web" / "dist"
|
|
if SOURCE_CHECKOUT else RESOURCE_ROOT / "web"
|
|
)
|
|
FRONTEND_SOURCE_DIR = PROJECT_ROOT / "interface" / "web"
|
|
ASSETS_DIR = PROJECT_ROOT / "assets" if SOURCE_CHECKOUT else RESOURCE_ROOT / "assets"
|
|
SEED_PLAYBOOK_DIR = (
|
|
PROJECT_ROOT / "data" / "playbooks"
|
|
if SOURCE_CHECKOUT else RESOURCE_ROOT / "playbooks"
|
|
)
|
|
|
|
# --- DATABASE / STORAGE FILES (match your repo) ---
|
|
MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db")
|
|
# Vendored Curry (synapse/curry_core.py) database: immutable versioned
|
|
# constants/functions/models + inference provenance. Separate file from
|
|
# MEMORY_DB on purpose - Curry's schema and lifecycle are independent of the
|
|
# memory/conversation store.
|
|
CURRY_DB = _configured_path("curry_db", "NEXUS_CURRY_DB", DATA_DIR / "curry.db")
|
|
|
|
# --- LOG FILES ---
|
|
BACKEND_LOG = RUNTIME_DIR / "backend.log"
|
|
OLLAMA_LOG = LOGS_DIR / "ollama.log"
|
|
CHAT_LOG = LOGS_DIR / "chat.log"
|
|
|
|
# --- ENSURE REQUIRED DIRECTORIES EXIST ---
|
|
_REQUIRED_DIRS = (
|
|
STATE_DIR,
|
|
DATA_DIR,
|
|
MODELS_DIR,
|
|
RUNTIME_DIR,
|
|
LOGS_DIR,
|
|
MEMORY_DIR,
|
|
CACHE_DIR,
|
|
TEMP_DIR,
|
|
PLAYBOOK_DIR,
|
|
UPLOADS_DIR,
|
|
EXPORTS_DIR,
|
|
MEMORY_DB.parent,
|
|
CURRY_DB.parent,
|
|
)
|
|
|
|
|
|
def init_state() -> list[Path]:
|
|
"""Create writable state and seed playbooks on a first wheel install."""
|
|
for directory in _REQUIRED_DIRS:
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
copied: list[Path] = []
|
|
if SEED_PLAYBOOK_DIR.resolve() != PLAYBOOK_DIR.resolve() and SEED_PLAYBOOK_DIR.is_dir():
|
|
for source in SEED_PLAYBOOK_DIR.glob("*.yaml"):
|
|
target = PLAYBOOK_DIR / source.name
|
|
if not target.exists():
|
|
shutil.copy2(source, target)
|
|
copied.append(target)
|
|
return copied
|
|
|
|
|
|
INITIALIZED_FILES = init_state()
|
|
|
|
# --- PATH ACCESSOR (fail-fast) ---
|
|
def path(name: str) -> Path:
|
|
"""
|
|
Return a Path for a known name. Raises KeyError if name is unknown.
|
|
"""
|
|
mapping = {
|
|
"root": PROJECT_ROOT,
|
|
"resources": RESOURCE_ROOT,
|
|
"state": STATE_DIR,
|
|
"config": CONFIG_FILE,
|
|
"data": DATA_DIR,
|
|
"models": MODELS_DIR,
|
|
"runtime": RUNTIME_DIR,
|
|
"logs": LOGS_DIR,
|
|
"memory": MEMORY_DIR,
|
|
"cache": CACHE_DIR,
|
|
"tmp": TEMP_DIR,
|
|
"playbooks": PLAYBOOK_DIR,
|
|
"uploads": UPLOADS_DIR,
|
|
"exports": EXPORTS_DIR,
|
|
"web": WEB_DIST_DIR,
|
|
"assets": ASSETS_DIR,
|
|
"memory_db": MEMORY_DB,
|
|
"backend_log": BACKEND_LOG,
|
|
"ollama_log": OLLAMA_LOG,
|
|
"chat_log": CHAT_LOG,
|
|
}
|
|
try:
|
|
return mapping[name]
|
|
except KeyError:
|
|
raise KeyError(f"Unknown config path name: {name}")
|
|
|
|
# --- Settings class and exported instance ---
|
|
def _normalize_ollama_host(raw: str) -> str:
|
|
"""Turn an OLLAMA_HOST value into a URL a client can actually connect to.
|
|
|
|
OLLAMA_HOST is Ollama's *server bind* variable, and the common way to expose
|
|
Ollama on a LAN is `OLLAMA_HOST=0.0.0.0:11434`. Taken literally as a client
|
|
base URL that is unusable twice over: 0.0.0.0 means "every local interface"
|
|
to a listener but is not a destination, and there is no scheme for httpx to
|
|
parse. The result was a silent empty model list, because list_models()
|
|
catches everything and returns [].
|
|
|
|
So: supply the scheme when it's missing, and rewrite wildcard binds to
|
|
loopback. An explicit host is left alone — someone pointing at a real remote
|
|
Ollama means it.
|
|
"""
|
|
host = (raw or "").strip().rstrip("/")
|
|
if not host:
|
|
return "http://127.0.0.1:11434"
|
|
if "://" not in host:
|
|
host = f"http://{host}"
|
|
scheme, _, rest = host.partition("://")
|
|
hostport = rest.split("/", 1)[0]
|
|
name, sep, port = hostport.rpartition(":")
|
|
if not sep: # no port given
|
|
name, port = hostport, ""
|
|
# 0.0.0.0 and :: are bind-any; from a client they mean "this machine".
|
|
if name.strip("[]") in ("0.0.0.0", "::", ""):
|
|
name = "127.0.0.1"
|
|
return f"{scheme}://{name}:{port}" if port else f"{scheme}://{name}"
|
|
|
|
|
|
class Settings:
|
|
"""
|
|
Lightweight settings container. Use `settings` instance for runtime access,
|
|
or `Settings` class for typing/tests.
|
|
"""
|
|
def __init__(self) -> None:
|
|
self.version: str = VERSION
|
|
self.source_checkout: bool = SOURCE_CHECKOUT
|
|
self.project_root: Path = PROJECT_ROOT
|
|
self.resource_root: Path = RESOURCE_ROOT
|
|
self.state_dir: Path = STATE_DIR
|
|
self.config_file: Path = CONFIG_FILE
|
|
self.data_dir: Path = DATA_DIR
|
|
self.models_dir: Path = MODELS_DIR
|
|
self.runtime_dir: Path = RUNTIME_DIR
|
|
self.memory_dir: Path = MEMORY_DIR
|
|
self.logs_dir: Path = LOGS_DIR
|
|
self.web_dist_dir: Path = WEB_DIST_DIR
|
|
self.frontend_source_dir: Path = FRONTEND_SOURCE_DIR
|
|
self.assets_dir: Path = ASSETS_DIR
|
|
|
|
# DB files
|
|
self.memory_db: Path = MEMORY_DB
|
|
|
|
# Logs
|
|
self.backend_log: Path = BACKEND_LOG
|
|
self.ollama_log: Path = OLLAMA_LOG
|
|
self.chat_log: Path = CHAT_LOG
|
|
|
|
# Env overrides. Two values from one variable, because OLLAMA_HOST means
|
|
# two different things: where a server should LISTEN, and where a client
|
|
# should CONNECT. `ollama_bind` keeps the user's literal intent for a
|
|
# serve we spawn (0.0.0.0 to expose it on the LAN); `ollama_host` is the
|
|
# connectable form for our own requests.
|
|
self.provider: str = str(_value("provider", "NEXUS_PROVIDER", "ollama"))
|
|
# A Nexus provider setting is more specific than the legacy Ollama bind
|
|
# variable. This matters on a desktop that has OLLAMA_HOST globally set
|
|
# but configures NexusOS to use a different remote inference machine.
|
|
provider_url = str(_value("provider_url", "NEXUS_PROVIDER_URL", "")).strip()
|
|
configured_host = (
|
|
provider_url
|
|
or os.getenv("OLLAMA_HOST", "").strip()
|
|
or "http://127.0.0.1:11434"
|
|
)
|
|
self.ollama_bind: str = configured_host or "127.0.0.1:11434"
|
|
self.ollama_host: str = _normalize_ollama_host(
|
|
configured_host
|
|
)
|
|
self.provider_url: str = self.ollama_host
|
|
self.manage_ollama: bool = self.provider == "ollama"
|
|
self.ollama_timeout: int = _int_value("provider_timeout", "OLLAMA_TIMEOUT", 120)
|
|
self.bind_host: str = str(_value(
|
|
"bind_host", "NEXUS_BIND_HOST", "127.0.0.1"
|
|
))
|
|
self.backend_port: int = _int_value("backend_port", "NEXUS_BACKEND_PORT", 8000)
|
|
self.memory_port: int = _int_value("memory_port", "NEXUS_MEMORY_PORT", 8001)
|
|
self.api_url: str = str(_value(
|
|
"api_url", "NEXUS_API", f"http://127.0.0.1:{self.backend_port}"
|
|
)).rstrip("/")
|
|
self.memory_url: str = str(_value(
|
|
"memory_url", "NEXUS_MEMORY_URL", f"http://127.0.0.1:{self.memory_port}"
|
|
)).rstrip("/")
|
|
|
|
def as_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"version": self.version,
|
|
"source_checkout": self.source_checkout,
|
|
"project_root": str(self.project_root),
|
|
"resource_root": str(self.resource_root),
|
|
"state_dir": str(self.state_dir),
|
|
"config_file": str(self.config_file),
|
|
"data_dir": str(self.data_dir),
|
|
"models_dir": str(self.models_dir),
|
|
"runtime_dir": str(self.runtime_dir),
|
|
"memory_dir": str(self.memory_dir),
|
|
"memory_db": str(self.memory_db),
|
|
"web_dist_dir": str(self.web_dist_dir),
|
|
"provider": self.provider,
|
|
"ollama_host": self.ollama_host,
|
|
"ollama_timeout": self.ollama_timeout,
|
|
"api_url": self.api_url,
|
|
"bind_host": self.bind_host,
|
|
"backend_port": self.backend_port,
|
|
"memory_port": self.memory_port,
|
|
"memory_url": self.memory_url,
|
|
}
|
|
|
|
# --- local-access allowlists (shared by the backend + memory FastAPI apps) ---
|
|
# The REST APIs are unauthenticated, so they are meant to be reached only from
|
|
# this machine. Two independent browser-side defenses depend on these lists:
|
|
# * ALLOWED_ORIGINS drives CORS — blocks a malicious page from *reading*
|
|
# responses cross-origin (was previously "*", which let any site read them).
|
|
# * ALLOWED_HOSTS drives TrustedHostMiddleware — rejects a foreign Host header,
|
|
# which is what stops DNS-rebinding (same-origin from the browser's view, so
|
|
# CORS can't help there).
|
|
# Both accept a comma-separated env override for the intentional-LAN case, to be
|
|
# paired with real auth. NEXUS_ALLOWED_HOSTS=* disables the Host check.
|
|
def _csv_env(name: str, default: list) -> list:
|
|
raw = os.getenv(name, "").strip()
|
|
return [p.strip() for p in raw.split(",") if p.strip()] if raw else list(default)
|
|
|
|
_LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1", "testserver"]
|
|
_LOCAL_ORIGINS = [
|
|
f"http://{h}:{p}"
|
|
for h in ("localhost", "127.0.0.1")
|
|
for p in (_int_value("backend_port", "NEXUS_BACKEND_PORT", 8000), 5173)
|
|
]
|
|
_LOCAL_ORIGINS.extend(["capacitor://localhost", "https://localhost"])
|
|
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
|
|
ALLOWED_ORIGINS = _csv_env("NEXUS_ALLOWED_ORIGINS", _LOCAL_ORIGINS)
|
|
|
|
|
|
# --- resource limits (DoS guardrails for the unauthenticated local APIs) ---
|
|
# Even local-only, an unbounded base64 upload or a flood of concurrent inference
|
|
# requests can exhaust RAM/CPU. These are generous defaults for single-user use,
|
|
# all env-overridable.
|
|
def _int_env(name: str, default: int) -> int:
|
|
try:
|
|
return int((os.getenv(name) or "").strip() or default)
|
|
except ValueError:
|
|
return default
|
|
|
|
MAX_REQUEST_BYTES = _int_env("NEXUS_MAX_REQUEST_MB", 32) * 1024 * 1024
|
|
MAX_UPLOAD_BYTES = _int_env("NEXUS_MAX_UPLOAD_MB", 20) * 1024 * 1024
|
|
MAX_PDF_PAGES = _int_env("NEXUS_MAX_PDF_PAGES", 500)
|
|
MAX_CONCURRENT_CHATS = _int_env("NEXUS_MAX_CONCURRENT_CHATS", 4)
|
|
MAX_CONCURRENT_UPLOADS = _int_env("NEXUS_MAX_CONCURRENT_UPLOADS", 2)
|
|
|
|
# Opt-in allowlist for /models/pull. Empty (default) = unrestricted, preserving
|
|
# current behaviour. Set NEXUS_MODEL_ALLOWLIST=mistral,llama3 to bound which
|
|
# models can be downloaded; a bare repo name (before the ':tag') matches all of
|
|
# its tags, so "mistral" permits "mistral:latest", "mistral:7b", etc.
|
|
MODEL_ALLOWLIST = _csv_env("NEXUS_MODEL_ALLOWLIST", [])
|
|
|
|
|
|
def model_pull_allowed(name: str) -> bool:
|
|
"""True if `name` may be pulled: always when no allowlist is configured,
|
|
otherwise when the full name or its repo part (before the first ':') is
|
|
listed. Case-insensitive."""
|
|
if not MODEL_ALLOWLIST:
|
|
return True
|
|
n = (name or "").strip().lower()
|
|
if not n:
|
|
return False
|
|
allow = {a.lower() for a in MODEL_ALLOWLIST}
|
|
return n in allow or n.split(":", 1)[0] in allow
|
|
|
|
# exported instance
|
|
settings = Settings()
|
|
|
|
# explicit exports for static checkers and IDEs
|
|
__all__ = ["Settings", "settings", "path", "VERSION",
|
|
"DEFAULT_CHAT_MODEL", "DEFAULT_MEMORY_MODEL", "DEFAULT_EMBED_MODEL",
|
|
"PACKAGE_DIR", "PROJECT_ROOT", "RESOURCE_ROOT", "SOURCE_CHECKOUT",
|
|
"STATE_DIR", "CONFIG_DIR", "CONFIG_FILE", "USER_CONFIG",
|
|
"read_user_config", "write_user_config", "init_state", "INITIALIZED_FILES",
|
|
"DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
|
|
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
|
|
"EXPORTS_DIR", "MEMORY_DB", "CURRY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
|
|
"ASSETS_DIR", "SEED_PLAYBOOK_DIR",
|
|
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
|
|
"ALLOWED_HOSTS", "ALLOWED_ORIGINS",
|
|
"MAX_REQUEST_BYTES", "MAX_UPLOAD_BYTES", "MAX_PDF_PAGES",
|
|
"MAX_CONCURRENT_CHATS", "MAX_CONCURRENT_UPLOADS",
|
|
"MODEL_ALLOWLIST", "model_pull_allowed"]
|
|
|
|
# --- quick runtime sanity check when run directly (no side effects on import) ---
|
|
if __name__ == "__main__":
|
|
print("Config paths:")
|
|
for key in ("root", "data", "models", "runtime", "memory", "memory_db"):
|
|
print(f" {key}: {path(key)}")
|