Files
tatlock/src/core/config.py
T
jpmschweitzerandClaude Fable 5 65debb6e44 fix(config): default service hosts to docker container names
The homelab is retiring *.schweitz.internal and will rebind host
ports to loopback; container-to-container traffic must use container
names on docker-dataplane.

- SEARXNG_HOST: http://localhost:8087 -> http://searxng:8080
  (SearXNG's internal port is 8080; 8087 was the host-published port)
- LIBRARY_DESK_HOST: http://localhost:8089 -> http://library-desk:8089
- CORE_API_HOST: http://localhost:8090 -> http://core-api:8083
  (8090 is the Scheduler's host port; Core-API serves 8083 internally,
  confirmed by the housekeeper client and test suite hitting :8083)
- scripts/test_housekeeper.sh: reach Core-API via localhost:8083
  instead of the LAN IP, which will refuse after loopback rebinding

Local development against host-published ports keeps working via .env
overrides (.env.example unchanged; localhost stays valid on the host).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:21:40 +02:00

330 lines
10 KiB
Python

"""
Global application configuration.
Following best practice of splitting config across domains.
"""
from enum import Enum
from functools import lru_cache
from pathlib import Path
from pydantic import Field, HttpUrl, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Tenant isolation constants (see docs: tenant-based isolation, no separate
# test infrastructure). The production tenant owns real data in the shared
# services (Qdrant/Neo4j/Wiki.js/Redis); everything non-production must run
# under the reserved test tenant or an explicit test_-prefixed namespace.
PRODUCTION_TENANT = "jpmschweitzer"
TEST_TENANT = "llm_tester"
TEST_TENANT_PREFIX = "test_"
def _get_version_from_pyproject() -> str:
"""
Load version from pyproject.toml.
Falls back to "unknown" if file cannot be read.
"""
try:
# Find pyproject.toml relative to this file
config_dir = Path(__file__).parent
pyproject_path = config_dir.parent.parent / "pyproject.toml"
if pyproject_path.exists():
content = pyproject_path.read_text()
for line in content.splitlines():
if line.strip().startswith("version"):
# Parse: version = "1.0.0"
return line.split("=", 1)[1].strip().strip('"').strip("'")
except Exception:
pass
return "unknown"
class Environment(str, Enum):
"""Application environment."""
DEVELOPMENT = "development"
PRODUCTION = "production"
TESTING = "testing"
class Config(BaseSettings):
"""
Global application configuration.
Loads from environment variables and .env file.
Domain-specific configs should be in their respective modules.
"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=True,
extra="ignore",
)
# Application
APP_NAME: str = "OpenAI-Compatible API"
APP_VERSION: str = Field(default_factory=_get_version_from_pyproject)
ENVIRONMENT: Environment = Environment.DEVELOPMENT
DEBUG: bool = Field(default=False, description="Debug mode")
# API Configuration
API_HOST: str = Field(default="0.0.0.0", description="API host")
API_PORT: int = Field(default=8000, description="API port")
API_PREFIX: str = Field(default="/v1", description="API route prefix")
# Anthropic Configuration (Claude - cloud fallback)
ANTHROPIC_API_KEY: str | None = Field(
default=None,
description="Anthropic API key for the Claude fallback backend"
)
ANTHROPIC_MODEL: str = Field(
default="claude-sonnet-5",
description="Claude model for the fallback backend"
)
PREFER_CLOUD_BACKEND: bool = Field(
default=False,
description="Prefer Claude over Ollama (default: local-first)"
)
# Ollama Configuration (local - primary backend)
OLLAMA_HOST: HttpUrl = Field(
default="http://localhost:11434",
description="Ollama server URL"
)
OLLAMA_DEFAULT_MODEL: str = Field(
default="gemma4:e2b",
description="Default Ollama model"
)
OLLAMA_TIMEOUT: int = Field(
default=120,
description="Ollama request timeout in seconds"
)
STEWARD_TIMEOUT: int = Field(
default=60,
description="Steward analysis timeout in seconds (gemma4 needs ~35s warm)"
)
STREAM_TIMEOUT: int = Field(
default=20,
description="Timeout for each streaming turn in seconds"
)
# SearXNG Configuration
SEARXNG_HOST: HttpUrl = Field(
default="http://searxng:8080",
description="SearXNG server URL (container name; internal port 8080)"
)
SEARXNG_TIMEOUT: int = Field(
default=30,
description="SearXNG request timeout in seconds"
)
# Redis Configuration
REDIS_HOST: str = Field(
default="localhost",
description="Redis server host"
)
REDIS_PORT: int = Field(
default=6379,
description="Redis server port"
)
REDIS_TIMEOUT: int = Field(
default=5,
description="Redis connection timeout in seconds"
)
# Library-Desk Configuration (The Librarian backend)
LIBRARIAN_TIMEOUT: int = Field(
default=180,
description="Total time budget for a librarian delegation in seconds"
)
LIBRARY_DESK_HOST: HttpUrl = Field(
default="http://library-desk:8089",
description="Library-Desk API URL (container name; internal port 8089)"
)
LIBRARY_DESK_API_KEY: str = Field(
default="",
description="API key for Library-Desk authentication"
)
LIBRARY_DESK_TIMEOUT: int = Field(
default=60,
description="Library-Desk request timeout in seconds"
)
# Core-API Configuration (The Housekeeper backend)
CORE_API_HOST: HttpUrl = Field(
default="http://core-api:8083",
description="Core-API URL for Home Assistant integration (container name; internal port 8083)"
)
CORE_API_KEY: str = Field(
default="",
description="API key for Core-API authentication"
)
CORE_API_TIMEOUT: int = Field(
default=30,
description="Core-API request timeout in seconds"
)
# Qdrant Configuration (Memory vector storage)
QDRANT_HOST: str = Field(
default="localhost",
description="Qdrant server host"
)
QDRANT_PORT: int = Field(
default=6333,
description="Qdrant server port"
)
QDRANT_EMBEDDING_DIM: int = Field(
default=768,
description="Embedding dimension (768 for nomic-embed-text)"
)
# Ollama Embedding Configuration
OLLAMA_EMBEDDING_MODEL: str = Field(
default="nomic-embed-text",
description="Ollama model for embeddings"
)
# Redis Memory Database
REDIS_MEMORY_DB: int = Field(
default=1,
description="Redis database number for memory cache"
)
REDIS_MEMORY_TTL_HOURS: int = Field(
default=24,
description="TTL for session context in hours"
)
# Logging
LOG_LEVEL: str | None = Field(
default=None,
description="Logging level (auto-set based on environment if not specified)"
)
# User Configuration
DEFAULT_USER: str | None = Field(
default=None,
description="Default user for single-user setup (auto-set based on environment if not specified)"
)
# CORS
CORS_ORIGINS: list[str] = Field(
default=["*"],
description="Allowed CORS origins"
)
CORS_ALLOW_CREDENTIALS: bool = True
CORS_ALLOW_METHODS: list[str] = ["*"]
CORS_ALLOW_HEADERS: list[str] = ["*"]
@model_validator(mode="after")
def _refuse_production_tenant_outside_production(self) -> "Config":
"""
Refuse startup when a non-production environment is explicitly
configured with the production tenant.
This is the hard stop of the tenant isolation guard: a dev/test
instance must never be able to read or write the production
tenant's data in the shared services.
The comparison is on the sanitized form: namespaces are derived
through sanitize_user_id(), so variants like "JPMSchweitzer" or
"jpmschweitzer." collide with the production namespaces and are
refused just as loudly.
"""
from src.core.multi_tenancy import sanitize_user_id
if (
self.ENVIRONMENT != Environment.PRODUCTION
and self.DEFAULT_USER is not None
and sanitize_user_id(self.DEFAULT_USER)
== sanitize_user_id(PRODUCTION_TENANT)
):
raise ValueError(
f"Refusing to start: ENVIRONMENT={self.ENVIRONMENT.value} is "
f"explicitly configured with the production tenant "
f"'{PRODUCTION_TENANT}'. Non-production environments must use "
f"'{TEST_TENANT}' or a '{TEST_TENANT_PREFIX}'-prefixed tenant. "
f"Unset DEFAULT_USER or set ENVIRONMENT=production."
)
return self
@property
def redis_memory_url(self) -> str:
"""Construct Redis connection URL for memory cache."""
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_MEMORY_DB}"
@property
def qdrant_url(self) -> str:
"""Construct Qdrant server URL."""
return f"http://{self.QDRANT_HOST}:{self.QDRANT_PORT}"
@property
def log_format(self) -> str:
"""
Determine log format based on environment.
- production: JSON format for machine parsing
- development/testing: Console format for human readability
"""
return "json" if self.ENVIRONMENT == Environment.PRODUCTION else "console"
@property
def effective_log_level(self) -> str:
"""
Get effective log level, auto-determining from environment if not set.
- development: DEBUG (maximum verbosity)
- production: WARNING (minimal noise)
- testing: INFO
"""
if self.LOG_LEVEL is not None:
return self.LOG_LEVEL
if self.ENVIRONMENT == Environment.DEVELOPMENT:
return "DEBUG"
if self.ENVIRONMENT == Environment.PRODUCTION:
return "WARNING"
return "INFO"
@property
def effective_default_user(self) -> str:
"""
Get effective default user (tenant), enforcing tenant isolation.
- production: DEFAULT_USER if set, else the production tenant
- development/testing: FORCED to the reserved test tenant
("llm_tester") - the only accepted overrides are the test tenant
itself or a "test_"-prefixed namespace. Any other DEFAULT_USER
value is treated as misconfiguration and ignored.
"""
if self.ENVIRONMENT == Environment.PRODUCTION:
return self.DEFAULT_USER or PRODUCTION_TENANT
if self.DEFAULT_USER is not None and (
self.DEFAULT_USER == TEST_TENANT
or self.DEFAULT_USER.startswith(TEST_TENANT_PREFIX)
):
return self.DEFAULT_USER
return TEST_TENANT
@property
def tenant_forced(self) -> bool:
"""Whether the tenant guard overrode a misconfigured DEFAULT_USER."""
return (
self.ENVIRONMENT != Environment.PRODUCTION
and self.DEFAULT_USER is not None
and self.effective_default_user != self.DEFAULT_USER
)
@lru_cache
def get_config() -> Config:
"""
Get cached configuration instance.
Uses lru_cache to ensure config is loaded once and reused.
"""
return Config()
# Global config instance
config = get_config()