Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a353328bb0 | ||
|
|
82aa9006cf |
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Tests for configuration validation.
|
||||
|
||||
Verifies that config validation catches invalid parameter combinations.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_test_env():
|
||||
"""Set up environment for each test, restoring original values after."""
|
||||
from hindsight_api.config import clear_config_cache
|
||||
|
||||
# Save original environment values
|
||||
env_vars_to_save = [
|
||||
"HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS",
|
||||
"HINDSIGHT_API_RETAIN_CHUNK_SIZE",
|
||||
"HINDSIGHT_API_LLM_PROVIDER",
|
||||
"HINDSIGHT_API_LLM_MODEL",
|
||||
]
|
||||
|
||||
# Save original values
|
||||
original_values = {}
|
||||
for key in env_vars_to_save:
|
||||
original_values[key] = os.environ.get(key)
|
||||
|
||||
clear_config_cache()
|
||||
|
||||
yield
|
||||
|
||||
# Restore original environment
|
||||
for key, original_value in original_values.items():
|
||||
if original_value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = original_value
|
||||
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
def test_retain_max_completion_tokens_must_be_greater_than_chunk_size():
|
||||
"""Test that RETAIN_MAX_COMPLETION_TOKENS > RETAIN_CHUNK_SIZE validation works."""
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
# Set invalid config: max_completion_tokens <= chunk_size
|
||||
os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "1000"
|
||||
os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "2000"
|
||||
os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock"
|
||||
|
||||
# Should raise ValueError with helpful message
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
HindsightConfig.from_env()
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
|
||||
# Verify error message contains helpful information
|
||||
assert "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS" in error_message
|
||||
assert "1000" in error_message
|
||||
assert "HINDSIGHT_API_RETAIN_CHUNK_SIZE" in error_message
|
||||
assert "2000" in error_message
|
||||
assert "must be greater than" in error_message
|
||||
assert "You have two options to fix this:" in error_message
|
||||
assert "Increase HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS" in error_message
|
||||
assert "Use a model that supports" in error_message
|
||||
|
||||
|
||||
def test_retain_max_completion_tokens_equal_to_chunk_size_fails():
|
||||
"""Test that RETAIN_MAX_COMPLETION_TOKENS == RETAIN_CHUNK_SIZE also fails."""
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
# Set invalid config: max_completion_tokens == chunk_size
|
||||
os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "3000"
|
||||
os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "3000"
|
||||
os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock"
|
||||
|
||||
# Should raise ValueError
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
HindsightConfig.from_env()
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "must be greater than" in error_message
|
||||
|
||||
|
||||
def test_valid_retain_config_succeeds():
|
||||
"""Test that valid config with max_completion_tokens > chunk_size works."""
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
# Set valid config: max_completion_tokens > chunk_size
|
||||
os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "64000"
|
||||
os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "3000"
|
||||
os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock"
|
||||
|
||||
# Should not raise
|
||||
config = HindsightConfig.from_env()
|
||||
assert config.retain_max_completion_tokens == 64000
|
||||
assert config.retain_chunk_size == 3000
|
||||
|
||||
|
||||
# Note: The BadRequestError wrapping is implemented in fact_extraction.py
|
||||
# but requires a complex integration test setup. The functionality is
|
||||
# straightforward: when a BadRequestError containing keywords like
|
||||
# "max_tokens", "max_completion_tokens", or "maximum context" is caught,
|
||||
# it's wrapped in a ValueError with helpful guidance.
|
||||
#
|
||||
# The config validation tests above ensure users get early feedback
|
||||
# about invalid configurations before runtime errors occur.
|
||||
@@ -738,12 +738,20 @@ class TestMentalModelRefreshTagSecurity:
|
||||
"Refreshed model should access memories/models with matching tags (user:alice)"
|
||||
|
||||
# MUST NOT include Bob's content (security violation)
|
||||
assert "bob" not in refreshed_content and "python" not in refreshed_content and "tea" not in refreshed_content, \
|
||||
f"SECURITY VIOLATION: Refreshed model accessed memories/models with different tags (user:bob). Content: {refreshed_content}"
|
||||
# Use word boundary matching to avoid false positives (e.g., "team" contains "tea")
|
||||
import re
|
||||
def contains_word(text: str, word: str) -> bool:
|
||||
"""Check if text contains word as a whole word (not substring)."""
|
||||
return bool(re.search(rf'\b{re.escape(word)}\b', text, re.IGNORECASE))
|
||||
|
||||
assert not contains_word(refreshed_content, "bob") and \
|
||||
not contains_word(refreshed_content, "python") and \
|
||||
not contains_word(refreshed_content, "tea"), \
|
||||
f"SECURITY VIOLATION: Refreshed model accessed memories/models with different tags (user:bob). Content: {refreshed['content']}"
|
||||
|
||||
# MUST NOT include untagged content (security violation)
|
||||
assert "100 employees" not in refreshed_content and "growing fast" not in refreshed_content, \
|
||||
f"SECURITY VIOLATION: Refreshed model accessed untagged memories/models. Content: {refreshed_content}"
|
||||
f"SECURITY VIOLATION: Refreshed model accessed untagged memories/models. Content: {refreshed['content']}"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@@ -86,21 +86,34 @@ def setup_logging(verbose: bool = False):
|
||||
|
||||
|
||||
def load_config_file():
|
||||
"""Load configuration from file if it exists."""
|
||||
# Check both config file locations
|
||||
config_files = [CONFIG_FILE, CONFIG_FILE_ALT]
|
||||
for config_path in config_files:
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
# Handle 'export VAR=value' format
|
||||
if line.startswith("export "):
|
||||
line = line[7:]
|
||||
key, value = line.split("=", 1)
|
||||
if key not in os.environ: # Don't override env vars
|
||||
os.environ[key] = value
|
||||
"""Load configuration from the active profile's file if it exists.
|
||||
|
||||
IMPORTANT: Only loads from the active profile, never from default if a specific profile is set.
|
||||
Uses dynamic path resolution to support testing with temporary HOME directories.
|
||||
"""
|
||||
from .profile_manager import ProfileManager, resolve_active_profile
|
||||
|
||||
# Resolve which profile to use (respects --profile flag, env vars, active_profile file)
|
||||
active_profile = resolve_active_profile()
|
||||
|
||||
# Get the config file path for this profile
|
||||
# Use ProfileManager which resolves paths dynamically
|
||||
pm = ProfileManager()
|
||||
paths = pm.resolve_profile_paths(active_profile)
|
||||
config_path = paths.config
|
||||
|
||||
# Load ONLY this profile's config, never fall back to default
|
||||
if config_path.exists():
|
||||
with open(config_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
# Handle 'export VAR=value' format
|
||||
if line.startswith("export "):
|
||||
line = line[7:]
|
||||
key, value = line.split("=", 1)
|
||||
if key not in os.environ: # Don't override env vars
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def get_config():
|
||||
@@ -1156,6 +1169,10 @@ def main():
|
||||
if global_profile == "default":
|
||||
global_profile = None
|
||||
|
||||
# Set the CLI profile override so it's available to resolve_active_profile()
|
||||
# This must happen BEFORE any config loading (load_config_file, get_config, etc.)
|
||||
set_cli_profile_override(global_profile)
|
||||
|
||||
# Check for built-in commands first
|
||||
# Find the first non-flag argument (the actual command)
|
||||
command = None
|
||||
|
||||
@@ -108,6 +108,13 @@ class DaemonEmbedManager(EmbedManager):
|
||||
daemon_log = paths.log
|
||||
port = paths.port
|
||||
|
||||
# Load profile's .env file and merge with provided config
|
||||
# This fixes issue #305 where profile env vars were ignored
|
||||
profile_config = self._profile_manager.load_profile_config(profile)
|
||||
# Merge: profile config first, then override with explicitly provided config
|
||||
merged_config = {**profile_config, **config}
|
||||
config = merged_config
|
||||
|
||||
# Build environment with LLM config
|
||||
# Support both formats: simple keys ("llm_api_key") and env var format ("HINDSIGHT_API_LLM_API_KEY")
|
||||
env = os.environ.copy()
|
||||
|
||||
@@ -65,9 +65,25 @@ class ProfileManager:
|
||||
"""Initialize the profile manager."""
|
||||
self._ensure_directories()
|
||||
|
||||
def _get_config_dir(self) -> Path:
|
||||
"""Get config directory path dynamically (supports testing with temp HOME)."""
|
||||
return Path.home() / ".hindsight"
|
||||
|
||||
def _get_profiles_dir(self) -> Path:
|
||||
"""Get profiles directory path dynamically."""
|
||||
return self._get_config_dir() / "profiles"
|
||||
|
||||
def _get_metadata_file(self) -> Path:
|
||||
"""Get metadata file path dynamically."""
|
||||
return self._get_profiles_dir() / "metadata.json"
|
||||
|
||||
def _get_active_profile_file(self) -> Path:
|
||||
"""Get active profile file path dynamically."""
|
||||
return self._get_config_dir() / "active_profile"
|
||||
|
||||
def _ensure_directories(self):
|
||||
"""Ensure profile directories exist."""
|
||||
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
self._get_profiles_dir().mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def list_profiles(self) -> list[ProfileInfo]:
|
||||
"""List all profiles with their status.
|
||||
@@ -80,7 +96,7 @@ class ProfileManager:
|
||||
profiles = []
|
||||
|
||||
# Add default profile if config exists
|
||||
default_config = CONFIG_DIR / "embed"
|
||||
default_config = self._get_config_dir() / "embed"
|
||||
if default_config.exists():
|
||||
profiles.append(
|
||||
ProfileInfo(
|
||||
@@ -119,10 +135,10 @@ class ProfileManager:
|
||||
"""
|
||||
if not name:
|
||||
# Default profile exists if config file exists
|
||||
return (CONFIG_DIR / "embed").exists()
|
||||
return (self._get_config_dir() / "embed").exists()
|
||||
|
||||
# Named profile exists if config file exists
|
||||
config_path = PROFILES_DIR / f"{name}.env"
|
||||
config_path = self._get_profiles_dir() / f"{name}.env"
|
||||
return config_path.exists()
|
||||
|
||||
def get_profile(self, name: str) -> Optional[ProfileInfo]:
|
||||
@@ -186,7 +202,7 @@ class ProfileManager:
|
||||
port = self._allocate_port(name)
|
||||
|
||||
# Write config file
|
||||
config_path = PROFILES_DIR / f"{name}.env"
|
||||
config_path = self._get_profiles_dir() / f"{name}.env"
|
||||
config_lines = [f"{key}={value}" for key, value in config.items()]
|
||||
config_path.write_text("\n".join(config_lines) + "\n")
|
||||
|
||||
@@ -223,17 +239,17 @@ class ProfileManager:
|
||||
raise ValueError(f"Profile '{name}' does not exist")
|
||||
|
||||
# Remove config file
|
||||
config_path = PROFILES_DIR / f"{name}.env"
|
||||
config_path = self._get_profiles_dir() / f"{name}.env"
|
||||
if config_path.exists():
|
||||
config_path.unlink()
|
||||
|
||||
# Remove lock file
|
||||
lock_path = PROFILES_DIR / f"{name}.lock"
|
||||
lock_path = self._get_profiles_dir() / f"{name}.lock"
|
||||
if lock_path.exists():
|
||||
lock_path.unlink()
|
||||
|
||||
# Remove log file
|
||||
log_path = PROFILES_DIR / f"{name}.log"
|
||||
log_path = self._get_profiles_dir() / f"{name}.log"
|
||||
if log_path.exists():
|
||||
log_path.unlink()
|
||||
|
||||
@@ -259,12 +275,13 @@ class ProfileManager:
|
||||
if name and not self.profile_exists(name):
|
||||
raise ValueError(f"Profile '{name}' does not exist")
|
||||
|
||||
active_file = self._get_active_profile_file()
|
||||
if name:
|
||||
ACTIVE_PROFILE_FILE.write_text(name)
|
||||
active_file.write_text(name)
|
||||
else:
|
||||
# Clear active profile
|
||||
if ACTIVE_PROFILE_FILE.exists():
|
||||
ACTIVE_PROFILE_FILE.unlink()
|
||||
if active_file.exists():
|
||||
active_file.unlink()
|
||||
|
||||
def get_active_profile(self) -> str:
|
||||
"""Get the currently active profile name.
|
||||
@@ -272,8 +289,9 @@ class ProfileManager:
|
||||
Returns:
|
||||
Profile name, or empty string if no active profile.
|
||||
"""
|
||||
if ACTIVE_PROFILE_FILE.exists():
|
||||
return ACTIVE_PROFILE_FILE.read_text().strip()
|
||||
active_file = self._get_active_profile_file()
|
||||
if active_file.exists():
|
||||
return active_file.read_text().strip()
|
||||
return ""
|
||||
|
||||
def resolve_profile_paths(self, name: str) -> ProfilePaths:
|
||||
@@ -285,12 +303,16 @@ class ProfileManager:
|
||||
Returns:
|
||||
ProfilePaths with config, lock, log, and port.
|
||||
"""
|
||||
# Use dynamic path resolution to support testing with temporary HOME directories
|
||||
config_dir = Path.home() / ".hindsight"
|
||||
profiles_dir = config_dir / "profiles"
|
||||
|
||||
if not name:
|
||||
# Default profile
|
||||
return ProfilePaths(
|
||||
config=CONFIG_DIR / "embed",
|
||||
lock=CONFIG_DIR / "daemon.lock",
|
||||
log=CONFIG_DIR / "daemon.log",
|
||||
config=config_dir / "embed",
|
||||
lock=config_dir / "daemon.lock",
|
||||
log=config_dir / "daemon.log",
|
||||
port=DEFAULT_PORT,
|
||||
)
|
||||
|
||||
@@ -299,12 +321,60 @@ class ProfileManager:
|
||||
port = metadata.profiles.get(name, {}).get("port", self._allocate_port(name))
|
||||
|
||||
return ProfilePaths(
|
||||
config=PROFILES_DIR / f"{name}.env",
|
||||
lock=PROFILES_DIR / f"{name}.lock",
|
||||
log=PROFILES_DIR / f"{name}.log",
|
||||
config=profiles_dir / f"{name}.env",
|
||||
lock=profiles_dir / f"{name}.lock",
|
||||
log=profiles_dir / f"{name}.log",
|
||||
port=port,
|
||||
)
|
||||
|
||||
def load_profile_config(self, name: str) -> dict[str, str]:
|
||||
"""Load configuration from a profile's .env file.
|
||||
|
||||
Args:
|
||||
name: Profile name (empty string for default).
|
||||
|
||||
Returns:
|
||||
Dictionary of environment variable key-value pairs from the profile's .env file.
|
||||
Also includes simple key aliases (e.g., 'idle_timeout' for 'HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT').
|
||||
"""
|
||||
paths = self.resolve_profile_paths(name)
|
||||
config = {}
|
||||
|
||||
if not paths.config.exists():
|
||||
return config
|
||||
|
||||
# Parse .env file
|
||||
with open(paths.config) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Skip comments and empty lines
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# Handle 'export VAR=value' format
|
||||
if line.startswith("export "):
|
||||
line = line[7:]
|
||||
# Parse KEY=VALUE
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
config[key.strip()] = value.strip()
|
||||
|
||||
# Add simple key aliases for backward compatibility
|
||||
# Some code checks config.get("idle_timeout") instead of the full env var name
|
||||
key_aliases = {
|
||||
"HINDSIGHT_API_LLM_API_KEY": "llm_api_key",
|
||||
"HINDSIGHT_API_LLM_PROVIDER": "llm_provider",
|
||||
"HINDSIGHT_API_LLM_MODEL": "llm_model",
|
||||
"HINDSIGHT_API_LLM_BASE_URL": "llm_base_url",
|
||||
"HINDSIGHT_API_LOG_LEVEL": "log_level",
|
||||
"HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT": "idle_timeout",
|
||||
}
|
||||
|
||||
for env_key, simple_key in key_aliases.items():
|
||||
if env_key in config and simple_key not in config:
|
||||
config[simple_key] = config[env_key]
|
||||
|
||||
return config
|
||||
|
||||
def _allocate_port(self, name: str) -> int:
|
||||
"""Allocate a port for a profile using hash-based strategy.
|
||||
|
||||
@@ -359,11 +429,12 @@ class ProfileManager:
|
||||
Returns:
|
||||
ProfileMetadata object.
|
||||
"""
|
||||
if not METADATA_FILE.exists():
|
||||
metadata_file = self._get_metadata_file()
|
||||
if not metadata_file.exists():
|
||||
return ProfileMetadata()
|
||||
|
||||
try:
|
||||
with open(METADATA_FILE) as f:
|
||||
with open(metadata_file) as f:
|
||||
data = json.load(f)
|
||||
return ProfileMetadata(version=data.get("version", 1), profiles=data.get("profiles", {}))
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
@@ -372,9 +443,9 @@ class ProfileManager:
|
||||
file=sys.stderr,
|
||||
)
|
||||
# Backup corrupted metadata
|
||||
backup_path = METADATA_FILE.with_suffix(".json.bak")
|
||||
if METADATA_FILE.exists():
|
||||
METADATA_FILE.rename(backup_path)
|
||||
backup_path = metadata_file.with_suffix(".json.bak")
|
||||
if metadata_file.exists():
|
||||
metadata_file.rename(backup_path)
|
||||
return ProfileMetadata()
|
||||
|
||||
def _save_metadata(self, metadata: ProfileMetadata):
|
||||
@@ -386,7 +457,8 @@ class ProfileManager:
|
||||
self._ensure_directories()
|
||||
|
||||
# Use atomic write with temp file
|
||||
temp_file = METADATA_FILE.with_suffix(".json.tmp")
|
||||
metadata_file = self._get_metadata_file()
|
||||
temp_file = metadata_file.with_suffix(".json.tmp")
|
||||
|
||||
with open(temp_file, "w") as f:
|
||||
# Acquire exclusive lock
|
||||
@@ -403,7 +475,7 @@ class ProfileManager:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
# Atomic rename
|
||||
temp_file.rename(METADATA_FILE)
|
||||
temp_file.rename(metadata_file)
|
||||
|
||||
|
||||
def resolve_active_profile() -> str:
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Test for validating that profile environment variables are loaded correctly when starting daemon.
|
||||
|
||||
This is a regression test for issue #305 where profile .env files were not loaded
|
||||
before daemon startup, causing environment variables to be ignored.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_home(tmp_path, monkeypatch):
|
||||
"""Create a temporary home directory."""
|
||||
temp_home = tmp_path / "home"
|
||||
temp_home.mkdir()
|
||||
monkeypatch.setenv("HOME", str(temp_home))
|
||||
return temp_home
|
||||
|
||||
|
||||
def test_profile_config_is_loaded_for_daemon(temp_home):
|
||||
"""Test that profile .env config is loaded when preparing daemon startup.
|
||||
|
||||
Before the fix, the daemon would ignore all values from the profile's .env file,
|
||||
using only os.environ or hardcoded defaults. This caused HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT
|
||||
and other profile-specific settings to be silently ignored.
|
||||
"""
|
||||
# Create a profile with custom configuration
|
||||
profile_dir = temp_home / ".hindsight" / "profiles"
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
profile_name = "test-timeout"
|
||||
profile_env_path = profile_dir / f"{profile_name}.env"
|
||||
|
||||
# Write profile config with custom values
|
||||
profile_env_path.write_text(
|
||||
"HINDSIGHT_API_LLM_PROVIDER=openai\n"
|
||||
"HINDSIGHT_API_LLM_API_KEY=sk-test-fake-key\n"
|
||||
"HINDSIGHT_API_LLM_MODEL=gpt-4o-mini\n"
|
||||
"HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT=0\n"
|
||||
"HINDSIGHT_API_LOG_LEVEL=debug\n"
|
||||
)
|
||||
|
||||
# Create metadata to register the profile with a port
|
||||
metadata_path = profile_dir / "metadata.json"
|
||||
metadata = {
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
profile_name: {
|
||||
"port": 9876,
|
||||
"created_at": "2024-01-01T00:00:00+00:00",
|
||||
"last_used": "2024-01-01T00:00:00+00:00",
|
||||
}
|
||||
},
|
||||
}
|
||||
metadata_path.write_text(json.dumps(metadata, indent=2))
|
||||
|
||||
# Verify that ProfileManager can load the profile config
|
||||
from hindsight_embed.profile_manager import ProfileManager
|
||||
|
||||
pm = ProfileManager()
|
||||
|
||||
# Verify profile exists
|
||||
assert pm.profile_exists(profile_name)
|
||||
|
||||
# Get profile paths
|
||||
paths = pm.resolve_profile_paths(profile_name)
|
||||
assert paths.config.exists()
|
||||
assert paths.config == profile_env_path
|
||||
assert paths.port == 9876
|
||||
|
||||
# Load profile config (this simulates what the fix should do)
|
||||
profile_config = pm.load_profile_config(profile_name)
|
||||
|
||||
# Verify config was loaded correctly
|
||||
assert profile_config["HINDSIGHT_API_LLM_PROVIDER"] == "openai"
|
||||
assert profile_config["HINDSIGHT_API_LLM_API_KEY"] == "sk-test-fake-key"
|
||||
assert profile_config["HINDSIGHT_API_LLM_MODEL"] == "gpt-4o-mini"
|
||||
assert profile_config["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] == "0"
|
||||
assert profile_config["HINDSIGHT_API_LOG_LEVEL"] == "debug"
|
||||
|
||||
# Verify that idle_timeout simple key is also available for backward compat
|
||||
# (some code checks config.get("idle_timeout"))
|
||||
assert profile_config.get("idle_timeout") == "0"
|
||||
|
||||
|
||||
def test_load_config_file_uses_correct_profile(temp_home, monkeypatch):
|
||||
"""Test that load_config_file() loads the correct profile and not default.
|
||||
|
||||
This is the core fix for issue #305 - when a profile is specified, we should
|
||||
ONLY load that profile's .env, never the default profile's config.
|
||||
"""
|
||||
import os
|
||||
|
||||
# Clear any existing profile override state
|
||||
from hindsight_embed.cli import set_cli_profile_override
|
||||
|
||||
set_cli_profile_override(None)
|
||||
|
||||
# Must clear HINDSIGHT_EMBED_PROFILE env var to ensure test isolation
|
||||
monkeypatch.delenv("HINDSIGHT_EMBED_PROFILE", raising=False)
|
||||
|
||||
from hindsight_embed.cli import load_config_file
|
||||
from hindsight_embed.profile_manager import ProfileManager
|
||||
|
||||
# Create default profile with one provider
|
||||
default_config_dir = temp_home / ".hindsight"
|
||||
default_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
(default_config_dir / "embed").write_text(
|
||||
"HINDSIGHT_API_LLM_PROVIDER=openai\n" "HINDSIGHT_API_LLM_MODEL=gpt-4o-mini\n"
|
||||
)
|
||||
|
||||
# Create a named profile with a DIFFERENT provider
|
||||
profile_dir = temp_home / ".hindsight" / "profiles"
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
profile_name = "myapp"
|
||||
profile_env_path = profile_dir / f"{profile_name}.env"
|
||||
profile_env_path.write_text("HINDSIGHT_API_LLM_PROVIDER=groq\n" "HINDSIGHT_API_LLM_MODEL=llama-3.1-70b\n")
|
||||
|
||||
# Create metadata
|
||||
import json
|
||||
|
||||
metadata_path = profile_dir / "metadata.json"
|
||||
metadata = {
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
profile_name: {
|
||||
"port": 9876,
|
||||
"created_at": "2024-01-01T00:00:00+00:00",
|
||||
"last_used": "2024-01-01T00:00:00+00:00",
|
||||
}
|
||||
},
|
||||
}
|
||||
metadata_path.write_text(json.dumps(metadata, indent=2))
|
||||
|
||||
# Clear env to ensure we're testing file loading
|
||||
monkeypatch.delenv("HINDSIGHT_API_LLM_PROVIDER", raising=False)
|
||||
monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL", raising=False)
|
||||
|
||||
# Test 1: Load default profile (no profile specified)
|
||||
set_cli_profile_override(None)
|
||||
load_config_file()
|
||||
|
||||
assert os.environ.get("HINDSIGHT_API_LLM_PROVIDER") == "openai"
|
||||
assert os.environ.get("HINDSIGHT_API_LLM_MODEL") == "gpt-4o-mini"
|
||||
|
||||
# Clear env
|
||||
monkeypatch.delenv("HINDSIGHT_API_LLM_PROVIDER")
|
||||
monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL")
|
||||
|
||||
# Test 2: Load named profile - should load ONLY that profile, not default
|
||||
set_cli_profile_override(profile_name)
|
||||
load_config_file()
|
||||
|
||||
# Should have loaded from the named profile
|
||||
assert os.environ.get("HINDSIGHT_API_LLM_PROVIDER") == "groq", "Should load from named profile, not default"
|
||||
assert os.environ.get("HINDSIGHT_API_LLM_MODEL") == "llama-3.1-70b", "Should load from named profile, not default"
|
||||
|
||||
|
||||
def test_get_config_respects_profile(temp_home, monkeypatch):
|
||||
"""Test that get_config() returns profile-specific values."""
|
||||
import os
|
||||
|
||||
from hindsight_embed.cli import get_config, set_cli_profile_override
|
||||
from hindsight_embed.profile_manager import ProfileManager
|
||||
|
||||
# Create default profile
|
||||
default_config_dir = temp_home / ".hindsight"
|
||||
default_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
(default_config_dir / "embed").write_text(
|
||||
"HINDSIGHT_API_LLM_PROVIDER=openai\n"
|
||||
"HINDSIGHT_API_LLM_MODEL=gpt-4o-mini\n"
|
||||
"HINDSIGHT_API_LLM_API_KEY=sk-default-key\n"
|
||||
"HINDSIGHT_EMBED_BANK_ID=default-bank\n"
|
||||
)
|
||||
|
||||
# Create named profile
|
||||
profile_dir = temp_home / ".hindsight" / "profiles"
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
profile_name = "production"
|
||||
profile_env_path = profile_dir / f"{profile_name}.env"
|
||||
profile_env_path.write_text(
|
||||
"HINDSIGHT_API_LLM_PROVIDER=anthropic\n"
|
||||
"HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514\n"
|
||||
"HINDSIGHT_API_LLM_API_KEY=sk-ant-production\n"
|
||||
"HINDSIGHT_EMBED_BANK_ID=production-bank\n"
|
||||
)
|
||||
|
||||
# Create metadata
|
||||
import json
|
||||
|
||||
metadata_path = profile_dir / "metadata.json"
|
||||
metadata = {
|
||||
"version": 1,
|
||||
"profiles": {
|
||||
profile_name: {
|
||||
"port": 9900,
|
||||
"created_at": "2024-01-01T00:00:00+00:00",
|
||||
"last_used": "2024-01-01T00:00:00+00:00",
|
||||
}
|
||||
},
|
||||
}
|
||||
metadata_path.write_text(json.dumps(metadata, indent=2))
|
||||
|
||||
# Clear env
|
||||
monkeypatch.delenv("HINDSIGHT_API_LLM_PROVIDER", raising=False)
|
||||
monkeypatch.delenv("HINDSIGHT_API_LLM_MODEL", raising=False)
|
||||
monkeypatch.delenv("HINDSIGHT_API_LLM_API_KEY", raising=False)
|
||||
monkeypatch.delenv("HINDSIGHT_EMBED_BANK_ID", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
|
||||
# Test with named profile
|
||||
set_cli_profile_override(profile_name)
|
||||
config = get_config()
|
||||
|
||||
# Should have loaded from production profile, NOT default
|
||||
assert config["llm_provider"] == "anthropic", "Should use profile's provider"
|
||||
assert config["llm_model"] == "claude-sonnet-4-20250514", "Should use profile's model"
|
||||
assert config["llm_api_key"] == "sk-ant-production", "Should use profile's API key"
|
||||
assert config["bank_id"] == "production-bank", "Should use profile's bank_id"
|
||||
@@ -8,8 +8,6 @@ from unittest.mock import Mock, patch
|
||||
import pytest
|
||||
|
||||
from hindsight_embed.profile_manager import (
|
||||
CONFIG_DIR,
|
||||
PROFILES_DIR,
|
||||
ProfileInfo,
|
||||
ProfileManager,
|
||||
ProfilePaths,
|
||||
@@ -20,15 +18,25 @@ from hindsight_embed.profile_manager import (
|
||||
|
||||
@pytest.fixture
|
||||
def temp_hindsight_dir(tmp_path, monkeypatch):
|
||||
"""Create a temporary hindsight directory for tests."""
|
||||
temp_config = tmp_path / ".hindsight"
|
||||
"""Create a temporary hindsight directory for tests.
|
||||
|
||||
Uses HOME environment variable to make Path.home() return the temp directory.
|
||||
This works with the dynamic path resolution in ProfileManager.
|
||||
"""
|
||||
# Clear any CLI profile override for test isolation
|
||||
from hindsight_embed.cli import set_cli_profile_override
|
||||
|
||||
set_cli_profile_override(None)
|
||||
|
||||
# Clear HINDSIGHT_EMBED_PROFILE env var
|
||||
monkeypatch.delenv("HINDSIGHT_EMBED_PROFILE", raising=False)
|
||||
|
||||
temp_home = tmp_path / "home"
|
||||
temp_home.mkdir()
|
||||
monkeypatch.setenv("HOME", str(temp_home))
|
||||
|
||||
temp_config = temp_home / ".hindsight"
|
||||
temp_config.mkdir()
|
||||
monkeypatch.setattr("hindsight_embed.profile_manager.CONFIG_DIR", temp_config)
|
||||
monkeypatch.setattr("hindsight_embed.profile_manager.PROFILES_DIR", temp_config / "profiles")
|
||||
monkeypatch.setattr(
|
||||
"hindsight_embed.profile_manager.METADATA_FILE", temp_config / "profiles" / "metadata.json"
|
||||
)
|
||||
monkeypatch.setattr("hindsight_embed.profile_manager.ACTIVE_PROFILE_FILE", temp_config / "active_profile")
|
||||
return temp_config
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user