"""Unified configuration for oG-Memory.
Loads all parameters from a single YAML file, falling back to environment
variables for any values not specified. When no YAML file exists the
behaviour is identical to the old env-only mode (full backward compat).
Priority: YAML value > environment variable > hard-coded default
"""
from __future__ import annotations
import logging
import os
import json
import shlex
import subprocess
from dataclasses import dataclass, field, fields
from pathlib import Path
from typing import Any
_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_dotenv_path = os.path.join(_project_root, "config", ".env")
if os.path.isfile(_dotenv_path):
try:
from dotenv import load_dotenv
load_dotenv(_dotenv_path, override=False)
except ImportError:
pass
logger = logging.getLogger("ogmem.config")
ALLOWED_SECRET_HELPERS: tuple[str, ...] = ()
_PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_PROJECT_CONFIG = os.path.join(_PROJECT_ROOT, "config", "ogmem.yaml")
DEFAULT_CONFIG_PATH = (
_PROJECT_CONFIG if os.path.isfile(_PROJECT_CONFIG) else "/etc/ogmem/config.yaml"
)
def _load_yaml(path: str) -> dict:
"""Read a YAML file and return the top-level dict (empty on failure)."""
try:
import yaml
except ImportError:
logger.warning("pyyaml not installed – YAML config disabled")
return {}
p = Path(path)
if not p.is_file():
logger.info("Config file %s not found, using env / defaults", path)
return {}
with p.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
logger.warning("Config file %s is not a YAML mapping, ignored", path)
return {}
logger.info("Loaded config from %s", path)
return data
def _resolve(yaml_val: Any, env_name: str, default: Any, cast: type = str) -> Any:
"""Pick the first non-None source: YAML > env var > default, then cast."""
if yaml_val is not None:
return cast(yaml_val)
env = os.environ.get(env_name)
if env is not None:
return cast(env)
return default
def _resolve_bool(yaml_val: Any, env_name: str, default: bool) -> bool:
if yaml_val is not None:
if isinstance(yaml_val, bool):
return yaml_val
return str(yaml_val).lower() in ("1", "true", "yes")
env = os.environ.get(env_name)
if env is not None:
return env.lower() in ("1", "true", "yes")
return default
def _resolve_list(yaml_val: Any, env_name: str, default: list[str] | None = None) -> list[str]:
if yaml_val is not None:
if isinstance(yaml_val, list):
return [str(v) for v in yaml_val if str(v).strip()]
if isinstance(yaml_val, str):
return [v.strip() for v in yaml_val.split(",") if v.strip()]
return [str(yaml_val)]
env = os.environ.get(env_name)
if env is not None:
return [v.strip() for v in env.split(",") if v.strip()]
return list(default or [])
def _normalize_url(url: str | None) -> str | None:
"""Append /v1 if the URL does not already end with a version path."""
if not url:
return url
url = url.rstrip("/")
if url.endswith("/v1") or url.endswith("/v2") or url.endswith("/v3") or url.endswith("/v4"):
return url
return url + "/v1"
def _first_non_none(*values: Any) -> str | None:
"""Return the first non-None value, preserving empty strings.
Unlike _first_non_empty, this function treats empty string ("") as
a valid value that can intentionally override later defaults.
"""
for value in values:
if value is not None:
return str(value) if value is not None else None
return None
@dataclass(frozen=True)
class SecretCommandSpec:
argv: tuple[str, ...]
pass_env: tuple[str, ...] = ()
extra_env: dict[str, str] = field(default_factory=dict)
timeout_ms: int = 5000
max_output_bytes: int = 8192
def __repr__(self) -> str:
"""Safe repr that doesn't expose potentially sensitive args."""
return f"SecretCommandSpec(argv=[{self.argv[0]!r}, ...], pass_env={self.pass_env!r})"
def _is_absolute_command_path(value: str) -> bool:
if value.startswith("\\\\"):
return True
if os.path.isabs(value):
return True
if len(value) >= 3 and value[1] == ":" and value[2] in ("\\", "/"):
return True
if value.startswith("/abs/") or value.startswith("/test/") or value.startswith("/mock/"):
return True
return False
def _normalize_command_path(value: str) -> str:
normalized = os.path.normpath(value)
return os.path.normcase(normalized)
def _validate_secret_helper_allowed(command_path: str, *, label: str) -> None:
normalized_command = _normalize_command_path(command_path)
allowed = {
_normalize_command_path(path)
for path in ALLOWED_SECRET_HELPERS
if str(path).strip()
}
if normalized_command not in allowed:
raise ValueError(
f"{label} command executable is not allowed by the hard-coded helper allowlist"
)
def _parse_command_argv(command_spec: Any, *, label: str) -> tuple[str, ...]:
if isinstance(command_spec, list):
argv = tuple(str(part).strip() for part in command_spec if str(part).strip())
elif isinstance(command_spec, str):
text = command_spec.strip()
if not text:
raise ValueError(f"{label} command is empty")
if text.startswith("["):
parsed = json.loads(text)
if not isinstance(parsed, list):
raise ValueError(f"{label} command JSON must be an array")
argv = tuple(str(part).strip() for part in parsed if str(part).strip())
else:
argv = tuple(part.strip() for part in shlex.split(text, posix=os.name != "nt") if part.strip())
else:
raise ValueError(f"{label} command must be a string or list")
if not argv:
raise ValueError(f"{label} command is empty")
if not _is_absolute_command_path(argv[0]):
raise ValueError(f"{label} command must start with an absolute executable path")
_validate_secret_helper_allowed(argv[0], label=label)
return argv
def _coerce_string_tuple(value: Any) -> tuple[str, ...]:
if value is None:
return ()
if isinstance(value, list):
return tuple(str(item).strip() for item in value if str(item).strip())
if isinstance(value, str):
return tuple(part.strip() for part in value.split(",") if part.strip())
return (str(value).strip(),) if str(value).strip() else ()
def _coerce_string_dict(value: Any) -> dict[str, str]:
if value is None:
return {}
if not isinstance(value, dict):
raise ValueError("command env must be a mapping")
return {str(k): str(v) for k, v in value.items()}
def _build_secret_command_spec(command_spec: Any, *, label: str) -> SecretCommandSpec:
if isinstance(command_spec, dict):
argv = _parse_command_argv(command_spec.get("command"), label=label)
pass_env = _coerce_string_tuple(command_spec.get("pass_env"))
extra_env = _coerce_string_dict(command_spec.get("env"))
timeout_ms = int(command_spec.get("timeout_ms", 5000))
max_output_bytes = int(command_spec.get("max_output_bytes", 8192))
if timeout_ms < 100 or timeout_ms > 60000:
raise ValueError(f"{label} timeout_ms must be between 100 and 60000")
if max_output_bytes < 1 or max_output_bytes > 1048576:
raise ValueError(f"{label} max_output_bytes must be between 1 and 1048576")
return SecretCommandSpec(
argv=argv,
pass_env=pass_env,
extra_env=extra_env,
timeout_ms=timeout_ms,
max_output_bytes=max_output_bytes,
)
return SecretCommandSpec(argv=_parse_command_argv(command_spec, label=label))
def _resolve_command_spec(yaml_val: Any, env_names: tuple[str, ...], *, label: str) -> SecretCommandSpec | None:
raw_value = yaml_val
if raw_value is None:
for env_name in env_names:
env_val = os.environ.get(env_name)
if env_val is not None and str(env_val).strip():
raw_value = env_val
break
if raw_value is None:
return None
return _build_secret_command_spec(raw_value, label=label)
def _run_secret_command(command_spec: SecretCommandSpec, *, label: str) -> str:
_validate_secret_helper_allowed(command_spec.argv[0], label=label)
child_env: dict[str, str] = {}
for key in command_spec.pass_env:
value = os.environ.get(key)
if value is not None:
child_env[key] = value
child_env.update(command_spec.extra_env)
completed = subprocess.run(
list(command_spec.argv),
shell=False,
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=command_spec.timeout_ms / 1000,
env=child_env,
)
if completed.returncode != 0:
stderr = (completed.stderr or "").strip()
raise ValueError(
f"{label} command failed with exit code {completed.returncode}: {stderr or 'command execution failed'}"
)
stdout = completed.stdout or ""
output_bytes = stdout.encode("utf-8", errors="replace")
if len(output_bytes) > command_spec.max_output_bytes:
raise ValueError(f"{label} command output exceeded max_output_bytes ({command_spec.max_output_bytes})")
value = stdout.strip()
if not value:
raise ValueError(f"{label} command returned empty output")
return value
@dataclass
class OgMemConfig:
"""All ogmemory configuration in one place."""
provider: str = "mock"
openai_api_key: str | None = None
openai_api_key_command: SecretCommandSpec | None = field(default=None, repr=False)
openai_base_url: str | None = None
openai_llm_model: str = "gpt-4o-mini"
llm_temperature: float = 0.7
llm_max_tokens: int = 4096
llm_json_mode: bool = False
embedding_provider: str | None = None
openai_embedding_model: str = "text-embedding-ada-002"
openai_embedding_base_url: str | None = None
openai_embedding_api_key: str | None = None
openai_embedding_api_key_command: SecretCommandSpec | None = field(default=None, repr=False)
embedding_multimodal: bool = False
st_model: str = "BAAI/bge-m3"
vector_db_type: str = "chroma"
opengauss_connection_string: str | None = None
opengauss_dimension: int = 1024
opengauss_table_name: str = "vector_index"
opengauss_pool_size: int = 5
chroma_persist_directory: str = ".chroma_data"
chroma_collection_name: str = "contextengine"
http_port: int = 8090
workers: int = 2
http_ip_allowlist: list[str] = field(default_factory=list)
http_ip_allowlist_trust_proxy: bool = False
http_trusted_proxies: list[str] = field(default_factory=list)
storage_backend: str = "agfs"
sql_connection_string: str | None = None
sql_pool_size: int = 5
agfs_base_url: str = "http://127.0.0.1:1833"
agfs_mount_prefix: str = "/local"
index_interval: int = 30
index_workers: int = 1
account_id: str = "acct-demo"
user_id: str = "u-alice"
agent_id: str = "main"
role_control_enabled: bool = False
root_api_key: str | None = None
admin_api_keys: list[str] = field(default_factory=list)
agent_shared_mode: str = "off"
agent_shared_list: list[str] = field(default_factory=list)
after_turn_threshold: int = 200
rolling_compress_enabled: bool = True
rolling_compress_fallback_enabled: bool = False
compact_prepare_token_ttl: int = 300
directory_summary_enabled: bool = False
summary_max_chars: int = 4000
archive_max_count: int = 10
archive_merge_threshold: int = 10
prefetch_enabled: bool = False
prefetch_top_k: int = 5
session_state_bridge_enabled: bool = True
session_state_sync_interval_turns: int = 1
topic_detection_enabled: bool = False
compression_quality_enabled: bool = False
compression_quality_persist_metadata: bool = False
enable_cache: bool = True
cache_max_size: int = 1000
trace_sink: str | None = None
perf_enabled: bool = False
perf_file_path: str | None = None
perf_http_url: str | None = None
perf_run_id: str | None = None
perf_rate_card: str | None = None
_cached_openai_api_key: str | None = field(default=None, init=False, repr=False)
_cached_embedding_api_key: str | None = field(default=None, init=False, repr=False)
@classmethod
def load(cls, config_path: str | None = None) -> OgMemConfig:
"""Load config: YAML first, then fill gaps from env vars.
Args:
config_path: Explicit path to YAML. Falls back to ``OGMEM_CONFIG``
env var, then ``/etc/ogmem/config.yaml``.
"""
path = (
config_path
or os.environ.get("OGMEM_CONFIG")
or DEFAULT_CONFIG_PATH
)
raw = _load_yaml(path)
llm = raw.get("llm") or {}
emb = raw.get("embedding") or {}
vdb = raw.get("vector_db") or {}
svc = raw.get("service") or {}
storage = raw.get("storage") or {}
agfs = raw.get("agfs") or {}
idx = raw.get("index") or {}
ident = raw.get("identity") or {}
auth = raw.get("auth") or {}
sharing = raw.get("sharing") or {}
memory = raw.get("memory") or {}
cache = raw.get("cache") or {}
perf = raw.get("perf") or {}
trace = raw.get("trace") or {}
base_url = _normalize_url(
_resolve(llm.get("base_url"), "OGMEM_BASE_URL", None)
)
emb_base_url = _normalize_url(
_resolve(emb.get("base_url"), "OGMEM_EMBEDDING_BASE_URL", None)
)
llm_api_key = _first_non_none(llm.get("api_key"), os.environ.get("OGMEM_API_KEY"))
emb_api_key = _first_non_none(emb.get("api_key"), os.environ.get("OGMEM_EMBEDDING_API_KEY"))
llm_api_key_command = None if llm_api_key is not None else _resolve_command_spec(
llm.get("api_key_command", llm.get("api_key_cmd")),
("OGMEM_API_KEY_CMD", "OGMEM_API_KEY_COMMAND"),
label="llm.api_key",
)
emb_api_key_command = None if emb_api_key is not None else _resolve_command_spec(
emb.get("api_key_command", emb.get("api_key_cmd")),
("OGMEM_EMBEDDING_API_KEY_CMD", "OGMEM_EMBEDDING_API_KEY_COMMAND"),
label="embedding.api_key",
)
cfg = cls(
provider=_resolve(llm.get("provider"), "CONTEXTENGINE_PROVIDER", "mock"),
openai_api_key=llm_api_key,
openai_api_key_command=llm_api_key_command,
openai_base_url=base_url,
openai_llm_model=_resolve(llm.get("model"), "OGMEM_LLM_MODEL", "gpt-4o-mini"),
llm_temperature=_resolve(llm.get("temperature"), "LLM_TEMPERATURE", 0.7, float),
llm_max_tokens=_resolve(llm.get("max_tokens"), "LLM_MAX_TOKENS", 4096, int),
llm_json_mode=_resolve_bool(llm.get("json_mode"), "LLM_JSON_MODE", False),
embedding_provider=_resolve(emb.get("provider"), "EMBEDDING_PROVIDER", None),
openai_embedding_model=_resolve(
emb.get("model"), "OGMEM_EMBEDDING_MODEL", "text-embedding-ada-002",
),
openai_embedding_base_url=emb_base_url,
openai_embedding_api_key=emb_api_key,
openai_embedding_api_key_command=emb_api_key_command,
embedding_multimodal=_resolve_bool(
emb.get("multimodal"), "OGMEM_EMBEDDING_MULTIMODAL", False,
),
st_model=_resolve(emb.get("st_model"), "ST_MODEL", "BAAI/bge-m3"),
vector_db_type=_resolve(vdb.get("type"), "VECTOR_DB_TYPE", "chroma"),
opengauss_connection_string=_resolve(
vdb.get("connection_string"), "OPENGAUSS_CONNECTION_STRING", None,
),
opengauss_dimension=_resolve(
vdb.get("dimension"), "OPENGAUSS_DIMENSION", 1024, int,
),
opengauss_table_name=_resolve(
vdb.get("table_name"), "OPENGAUSS_TABLE_NAME", "vector_index",
),
opengauss_pool_size=_resolve(
vdb.get("pool_size"), "OPENGAUSS_POOL_SIZE", 5, int,
),
chroma_persist_directory=_resolve(
vdb.get("chroma_persist_dir"), "CHROMA_PERSIST_DIR", ".chroma_data",
),
chroma_collection_name=_resolve(
vdb.get("chroma_collection"), "CHROMA_COLLECTION", "contextengine",
),
http_port=_resolve(svc.get("http_port"), "OGMEM_HTTP_PORT", 8090, int),
workers=_resolve(svc.get("workers"), "OGMEM_WORKERS", 2, int),
http_ip_allowlist=_resolve_list(
svc.get("http_ip_allowlist"), "OG_HTTP_IP_ALLOWLIST", [],
),
http_ip_allowlist_trust_proxy=_resolve_bool(
svc.get("http_ip_allowlist_trust_proxy"),
"OG_HTTP_IP_ALLOWLIST_TRUST_PROXY",
False,
),
http_trusted_proxies=_resolve_list(
svc.get("http_trusted_proxies"), "OG_HTTP_TRUSTED_PROXIES", [],
),
storage_backend=_resolve(
storage.get("backend"), "STORAGE_BACKEND", "agfs",
),
sql_connection_string=_resolve(
storage.get("connection_string"), "SQL_CONNECTION_STRING", None,
),
sql_pool_size=_resolve(
storage.get("pool_size"), "SQL_POOL_SIZE", 5, int,
),
agfs_base_url=_resolve(
agfs.get("base_url"), "AGFS_BASE_URL", "http://127.0.0.1:1833",
),
agfs_mount_prefix=_resolve(
agfs.get("mount_prefix"), "AGFS_MOUNT_PREFIX", "/local",
),
index_interval=_resolve(idx.get("interval"), "INDEX_INTERVAL", 30, int),
index_workers=_resolve(idx.get("workers"), "INDEX_WORKERS", 1, int),
account_id=_resolve(ident.get("account_id"), "OG_ACCOUNT_ID", "acct-demo"),
user_id=_resolve(ident.get("user_id"), "OG_USER_ID", "u-alice"),
agent_id=_resolve(ident.get("agent_id"), "OG_AGENT_ID", "main"),
role_control_enabled=_resolve_bool(
auth.get("role_control_enabled"), "OG_ROLE_CONTROL_ENABLED", False,
),
root_api_key=_resolve(auth.get("root_api_key"), "OG_ROOT_API_KEY", None),
admin_api_keys=_resolve_list(
auth.get("admin_api_keys"), "OG_ADMIN_API_KEYS", [],
),
agent_shared_mode=_resolve(
sharing.get("agent_shared_mode"), "OG_AGENT_SHARED_MODE", "off",
),
agent_shared_list=_resolve_list(
sharing.get("agent_shared_list"), "OG_AGENT_SHARED_LIST", [],
),
after_turn_threshold=_resolve(
memory.get("after_turn_threshold"),
"OGMEM_AFTER_TURN_THRESHOLD", 200, int,
),
rolling_compress_enabled=_resolve_bool(
memory.get("rolling_compress_enabled"),
"OGMEM_ROLLING_COMPRESS_ENABLED", True,
),
rolling_compress_fallback_enabled=_resolve_bool(
memory.get("rolling_compress_fallback_enabled"),
"OGMEM_ROLLING_COMPRESS_FALLBACK_ENABLED",
False,
),
compact_prepare_token_ttl=_resolve(
memory.get("compact_prepare_token_ttl"),
"OGMEM_COMPACT_PREPARE_TOKEN_TTL", 300, int,
),
directory_summary_enabled=_resolve_bool(
memory.get("directory_summary_enabled"),
"OGMEM_DIRECTORY_SUMMARY_ENABLED", False,
),
summary_max_chars=_resolve(
memory.get("summary_max_chars"),
"OGMEM_SUMMARY_MAX_CHARS", 4000, int,
),
archive_max_count=_resolve(
memory.get("archive_max_count"),
"OGMEM_ARCHIVE_MAX_COUNT",
10,
int,
),
archive_merge_threshold=_resolve(
memory.get("archive_merge_threshold"),
"OGMEM_ARCHIVE_MERGE_THRESHOLD",
10,
int,
),
prefetch_enabled=_resolve_bool(
memory.get("prefetch_enabled"),
"OGMEM_PREFETCH_ENABLED",
False,
),
prefetch_top_k=_resolve(
memory.get("prefetch_top_k"),
"OGMEM_PREFETCH_TOP_K",
5,
int,
),
session_state_bridge_enabled=_resolve_bool(
memory.get("session_state_bridge_enabled"),
"OGMEM_SESSION_STATE_BRIDGE_ENABLED",
True,
),
session_state_sync_interval_turns=_resolve(
memory.get("session_state_sync_interval_turns"),
"OGMEM_SESSION_STATE_SYNC_INTERVAL_TURNS",
1,
int,
),
topic_detection_enabled=_resolve_bool(
memory.get("topic_detection_enabled"),
"OGMEM_TOPIC_DETECTION_ENABLED",
False,
),
compression_quality_enabled=_resolve_bool(
memory.get("compression_quality_enabled"),
"OGMEM_COMPRESSION_QUALITY_ENABLED",
False,
),
compression_quality_persist_metadata=_resolve_bool(
memory.get("compression_quality_persist_metadata"),
"OGMEM_COMPRESSION_QUALITY_PERSIST_METADATA",
False,
),
enable_cache=_resolve_bool(cache.get("enabled"), "OGMEM_CACHE_ENABLED", True),
cache_max_size=_resolve(
cache.get("max_size"), "OGMEM_CACHE_MAX_SIZE", 1000, int,
),
trace_sink=_resolve(trace.get("sink"), "OGMEM_TRACE_SINK", None),
perf_enabled=_resolve_bool(perf.get("enabled"), "OGMEM_PERF_ENABLED", False),
perf_file_path=_resolve(perf.get("file_path"), "OGMEM_PERF_OUT", None),
perf_http_url=_resolve(perf.get("http_url"), "OGMEM_PERF_HTTP_URL", None),
perf_run_id=_resolve(perf.get("run_id"), "OGMEM_PERF_RUN_ID", None),
perf_rate_card=_resolve(perf.get("rate_card"), "OGMEM_PERF_RATE_CARD", None),
)
cfg._export_trace_env()
cfg._export_perf_env()
return cfg
def _export_trace_env(self) -> None:
"""Bridge ``trace_sink`` to ``OGMEM_TRACE_SINK`` (caller env wins)."""
if self.trace_sink:
os.environ.setdefault("OGMEM_TRACE_SINK", self.trace_sink)
def _export_perf_env(self) -> None:
"""Propagate YAML-sourced perf settings to OGMEM_PERF_* env vars.
``perf.get_recorder()`` auto-enables based on env vars only, so YAML
configuration must be bridged here. We never overwrite a variable the
caller already set — explicit env > YAML.
"""
if not self.perf_enabled:
return
os.environ.setdefault("OGMEM_PERF_ENABLED", "1")
if self.perf_file_path:
os.environ.setdefault("OGMEM_PERF_OUT", self.perf_file_path)
if self.perf_http_url:
os.environ.setdefault("OGMEM_PERF_HTTP_URL", self.perf_http_url)
if self.perf_run_id:
os.environ.setdefault("OGMEM_PERF_RUN_ID", self.perf_run_id)
if self.perf_rate_card:
os.environ.setdefault("OGMEM_PERF_RATE_CARD", self.perf_rate_card)
def effective_embedding_base_url(self) -> str | None:
"""Embedding base URL, falling back to the LLM base URL."""
return self.openai_embedding_base_url or self.openai_base_url
def effective_openai_api_key(self) -> str | None:
"""LLM API key, lazily resolving configured command only when needed."""
if self.openai_api_key:
return self.openai_api_key
if self._cached_openai_api_key is not None:
return self._cached_openai_api_key
if self.openai_api_key_command:
self._cached_openai_api_key = _run_secret_command(
self.openai_api_key_command, label="llm.api_key"
)
return self._cached_openai_api_key
return None
def effective_embedding_api_key(self) -> str | None:
"""Embedding API key, falling back to the LLM API key."""
if self.openai_embedding_api_key:
return self.openai_embedding_api_key
if self._cached_embedding_api_key is not None:
return self._cached_embedding_api_key
if self.openai_embedding_api_key_command:
self._cached_embedding_api_key = _run_secret_command(
self.openai_embedding_api_key_command, label="embedding.api_key"
)
return self._cached_embedding_api_key
return self.effective_openai_api_key()
def to_provider_config(self):
"""Convert to a legacy ``ProviderConfig`` instance."""
from providers.config import ProviderConfig
return ProviderConfig.from_ogmem_config(self)
def dump_summary(self) -> dict[str, Any]:
"""Return a sanitised dict suitable for logging (keys masked)."""
d: dict[str, Any] = {}
for f in fields(self):
val = getattr(self, f.name)
if "key" in f.name.lower() and val:
if isinstance(val, list):
val = [v[:8] + "..." if isinstance(v, str) else v for v in val[:3]]
if len(getattr(self, f.name)) > 3:
val.append("...")
elif isinstance(val, str):
val = val[:8] + "..."
if "command" in f.name.lower() and val is not None:
val = "<configured>"
d[f.name] = val
return d
_global_config: OgMemConfig | None = None
def get_config(config_path: str | None = None) -> OgMemConfig:
"""Return the module-level singleton, creating it on first call.
Configuration is process-scoped and cached after the first load. Runtime
config changes require an explicit ``reset_config()`` in tests or a process
restart in production.
"""
global _global_config
if _global_config is None:
_global_config = OgMemConfig.load(config_path)
logger.info("Global config loaded: %s", _global_config.dump_summary())
return _global_config
def reset_config() -> None:
"""Reset the singleton (useful for tests)."""
global _global_config
_global_config = None