Compare commits

...
Author SHA1 Message Date
Nicolò Boschi a823169cf5 fix tests 2026-01-08 12:29:31 +01:00
Nicolò Boschi 3dfb3ef68a feat: support different provider/models per operation 2026-01-08 12:13:07 +01:00
6 changed files with 534 additions and 17 deletions
+40 -1
View File
@@ -25,6 +25,17 @@ ENV_LLM_MAX_CONCURRENT = "HINDSIGHT_API_LLM_MAX_CONCURRENT"
ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
# Per-operation LLM configuration (optional, falls back to global LLM config)
ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
ENV_RETAIN_LLM_API_KEY = "HINDSIGHT_API_RETAIN_LLM_API_KEY"
ENV_RETAIN_LLM_MODEL = "HINDSIGHT_API_RETAIN_LLM_MODEL"
ENV_RETAIN_LLM_BASE_URL = "HINDSIGHT_API_RETAIN_LLM_BASE_URL"
ENV_REFLECT_LLM_PROVIDER = "HINDSIGHT_API_REFLECT_LLM_PROVIDER"
ENV_REFLECT_LLM_API_KEY = "HINDSIGHT_API_REFLECT_LLM_API_KEY"
ENV_REFLECT_LLM_MODEL = "HINDSIGHT_API_REFLECT_LLM_MODEL"
ENV_REFLECT_LLM_BASE_URL = "HINDSIGHT_API_REFLECT_LLM_BASE_URL"
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
@@ -127,7 +138,7 @@ class HindsightConfig:
# Database
database_url: str
# LLM
# LLM (default, used as fallback for per-operation config)
llm_provider: str
llm_api_key: str | None
llm_model: str
@@ -135,6 +146,17 @@ class HindsightConfig:
llm_max_concurrent: int
llm_timeout: float
# Per-operation LLM configuration (None = use default LLM config)
retain_llm_provider: str | None
retain_llm_api_key: str | None
retain_llm_model: str | None
retain_llm_base_url: str | None
reflect_llm_provider: str | None
reflect_llm_api_key: str | None
reflect_llm_model: str | None
reflect_llm_base_url: str | None
# Embeddings
embeddings_provider: str
embeddings_local_model: str
@@ -181,6 +203,15 @@ class HindsightConfig:
llm_base_url=os.getenv(ENV_LLM_BASE_URL) or None,
llm_max_concurrent=int(os.getenv(ENV_LLM_MAX_CONCURRENT, str(DEFAULT_LLM_MAX_CONCURRENT))),
llm_timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
# Per-operation LLM config (None = use default)
retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None,
retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) or None,
retain_llm_model=os.getenv(ENV_RETAIN_LLM_MODEL) or None,
retain_llm_base_url=os.getenv(ENV_RETAIN_LLM_BASE_URL) or None,
reflect_llm_provider=os.getenv(ENV_REFLECT_LLM_PROVIDER) or None,
reflect_llm_api_key=os.getenv(ENV_REFLECT_LLM_API_KEY) or None,
reflect_llm_model=os.getenv(ENV_REFLECT_LLM_MODEL) or None,
reflect_llm_base_url=os.getenv(ENV_REFLECT_LLM_BASE_URL) or None,
# Embeddings
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
@@ -251,6 +282,14 @@ class HindsightConfig:
"""Log the current configuration (without sensitive values)."""
logger.info(f"Database: {self.database_url}")
logger.info(f"LLM: provider={self.llm_provider}, model={self.llm_model}")
if self.retain_llm_provider or self.retain_llm_model:
retain_provider = self.retain_llm_provider or self.llm_provider
retain_model = self.retain_llm_model or self.llm_model
logger.info(f"LLM (retain): provider={retain_provider}, model={retain_model}")
if self.reflect_llm_provider or self.reflect_llm_model:
reflect_provider = self.reflect_llm_provider or self.llm_provider
reflect_model = self.reflect_llm_model or self.llm_model
logger.info(f"LLM (reflect): provider={reflect_provider}, model={reflect_model}")
logger.info(f"Embeddings: provider={self.embeddings_provider}")
logger.info(f"Reranker: provider={self.reranker_provider}")
logger.info(f"Graph retriever: {self.graph_retriever}")
@@ -88,10 +88,14 @@ class LLMProvider:
self.groq_service_tier = groq_service_tier or os.getenv(ENV_LLM_GROQ_SERVICE_TIER, "auto")
# Validate provider
valid_providers = ["openai", "groq", "ollama", "gemini", "anthropic", "lmstudio"]
valid_providers = ["openai", "groq", "ollama", "gemini", "anthropic", "lmstudio", "mock"]
if self.provider not in valid_providers:
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
# Mock provider tracking (for testing)
self._mock_calls: list[dict] = []
self._mock_response: Any = None
# Set default base URLs
if not self.base_url:
if self.provider == "groq":
@@ -101,8 +105,8 @@ class LLMProvider:
elif self.provider == "lmstudio":
self.base_url = "http://localhost:1234/v1"
# Validate API key (not needed for ollama or lmstudio)
if self.provider not in ("ollama", "lmstudio") and not self.api_key:
# Validate API key (not needed for ollama, lmstudio, or mock)
if self.provider not in ("ollama", "lmstudio", "mock") and not self.api_key:
raise ValueError(f"API key not found for {self.provider}")
# Get timeout config (set HINDSIGHT_API_LLM_TIMEOUT for local LLMs that need longer timeouts)
@@ -113,7 +117,10 @@ class LLMProvider:
self._gemini_client = None
self._anthropic_client = None
if self.provider == "gemini":
if self.provider == "mock":
# Mock provider - no client needed
pass
elif self.provider == "gemini":
self._gemini_client = genai.Client(api_key=self.api_key)
elif self.provider == "anthropic":
from anthropic import AsyncAnthropic
@@ -205,6 +212,15 @@ class LLMProvider:
async with _global_llm_semaphore:
start_time = time.time()
# Handle Mock provider (for testing)
if self.provider == "mock":
return await self._call_mock(
messages,
response_format,
scope,
return_usage,
)
# Handle Gemini provider separately
if self.provider == "gemini":
return await self._call_gemini(
@@ -954,6 +970,61 @@ class LLMProvider:
raise last_exception
raise RuntimeError("Gemini call failed after all retries")
async def _call_mock(
self,
messages: list[dict[str, str]],
response_format: Any | None,
scope: str,
return_usage: bool,
) -> Any:
"""
Handle mock provider calls for testing.
Records the call and returns a configurable mock response.
"""
# Record the call for test verification
call_record = {
"provider": self.provider,
"model": self.model,
"messages": messages,
"response_format": response_format.__name__
if response_format and hasattr(response_format, "__name__")
else str(response_format),
"scope": scope,
}
self._mock_calls.append(call_record)
logger.debug(f"Mock LLM call recorded: scope={scope}, model={self.model}")
# Return mock response
if self._mock_response is not None:
result = self._mock_response
elif response_format is not None:
# Try to create a minimal valid instance of the response format
try:
# For Pydantic models, try to create with minimal valid data
result = {"mock": True}
except Exception:
result = {"mock": True}
else:
result = "mock response"
if return_usage:
token_usage = TokenUsage(input_tokens=10, output_tokens=5, total_tokens=15)
return result, token_usage
return result
def set_mock_response(self, response: Any) -> None:
"""Set the response to return from mock calls."""
self._mock_response = response
def get_mock_calls(self) -> list[dict]:
"""Get the list of recorded mock calls."""
return self._mock_calls
def clear_mock_calls(self) -> None:
"""Clear the recorded mock calls."""
self._mock_calls = []
@classmethod
def for_memory(cls) -> "LLMProvider":
"""Create provider for memory operations from environment variables."""
@@ -203,6 +203,15 @@ class MemoryEngine(MemoryEngineInterface):
memory_llm_api_key: str | None = None,
memory_llm_model: str | None = None,
memory_llm_base_url: str | None = None,
# Per-operation LLM config (optional, falls back to memory_llm_* params)
retain_llm_provider: str | None = None,
retain_llm_api_key: str | None = None,
retain_llm_model: str | None = None,
retain_llm_base_url: str | None = None,
reflect_llm_provider: str | None = None,
reflect_llm_api_key: str | None = None,
reflect_llm_model: str | None = None,
reflect_llm_base_url: str | None = None,
embeddings: Embeddings | None = None,
cross_encoder: CrossEncoderModel | None = None,
query_analyzer: QueryAnalyzer | None = None,
@@ -228,6 +237,14 @@ class MemoryEngine(MemoryEngineInterface):
memory_llm_api_key: API key for the LLM provider. Defaults to HINDSIGHT_API_LLM_API_KEY env var.
memory_llm_model: Model name. Defaults to HINDSIGHT_API_LLM_MODEL env var.
memory_llm_base_url: Base URL for the LLM API. Defaults based on provider.
retain_llm_provider: LLM provider for retain operations. Falls back to memory_llm_provider.
retain_llm_api_key: API key for retain LLM. Falls back to memory_llm_api_key.
retain_llm_model: Model for retain operations. Falls back to memory_llm_model.
retain_llm_base_url: Base URL for retain LLM. Falls back to memory_llm_base_url.
reflect_llm_provider: LLM provider for reflect operations. Falls back to memory_llm_provider.
reflect_llm_api_key: API key for reflect LLM. Falls back to memory_llm_api_key.
reflect_llm_model: Model for reflect operations. Falls back to memory_llm_model.
reflect_llm_base_url: Base URL for reflect LLM. Falls back to memory_llm_base_url.
embeddings: Embeddings implementation. If not provided, created from env vars.
cross_encoder: Cross-encoder model. If not provided, created from env vars.
query_analyzer: Query analyzer implementation. If not provided, uses DateparserQueryAnalyzer.
@@ -260,8 +277,8 @@ class MemoryEngine(MemoryEngineInterface):
db_url = db_url or config.database_url
memory_llm_provider = memory_llm_provider or config.llm_provider
memory_llm_api_key = memory_llm_api_key or config.llm_api_key
# Ollama doesn't require an API key
if not memory_llm_api_key and memory_llm_provider != "ollama":
# Ollama and mock don't require an API key
if not memory_llm_api_key and memory_llm_provider not in ("ollama", "mock"):
raise ValueError("LLM API key is required. Set HINDSIGHT_API_LLM_API_KEY environment variable.")
memory_llm_model = memory_llm_model or config.llm_model
memory_llm_base_url = memory_llm_base_url or config.get_llm_base_url() or None
@@ -310,7 +327,7 @@ class MemoryEngine(MemoryEngineInterface):
self.query_analyzer = DateparserQueryAnalyzer()
# Initialize LLM configuration
# Initialize LLM configuration (default, used as fallback)
self._llm_config = LLMConfig(
provider=memory_llm_provider,
api_key=memory_llm_api_key,
@@ -322,6 +339,49 @@ class MemoryEngine(MemoryEngineInterface):
self._llm_client = self._llm_config._client
self._llm_model = self._llm_config.model
# Initialize per-operation LLM configs (fall back to default if not specified)
# Retain LLM config - for fact extraction (benefits from strong structured output)
retain_provider = retain_llm_provider or config.retain_llm_provider or memory_llm_provider
retain_api_key = retain_llm_api_key or config.retain_llm_api_key or memory_llm_api_key
retain_model = retain_llm_model or config.retain_llm_model or memory_llm_model
retain_base_url = retain_llm_base_url or config.retain_llm_base_url or memory_llm_base_url
# Apply provider-specific base URL defaults for retain
if retain_base_url is None:
if retain_provider.lower() == "groq":
retain_base_url = "https://api.groq.com/openai/v1"
elif retain_provider.lower() == "ollama":
retain_base_url = "http://localhost:11434/v1"
else:
retain_base_url = ""
self._retain_llm_config = LLMConfig(
provider=retain_provider,
api_key=retain_api_key,
base_url=retain_base_url,
model=retain_model,
)
# Reflect LLM config - for think/observe operations (can use lighter models)
reflect_provider = reflect_llm_provider or config.reflect_llm_provider or memory_llm_provider
reflect_api_key = reflect_llm_api_key or config.reflect_llm_api_key or memory_llm_api_key
reflect_model = reflect_llm_model or config.reflect_llm_model or memory_llm_model
reflect_base_url = reflect_llm_base_url or config.reflect_llm_base_url or memory_llm_base_url
# Apply provider-specific base URL defaults for reflect
if reflect_base_url is None:
if reflect_provider.lower() == "groq":
reflect_base_url = "https://api.groq.com/openai/v1"
elif reflect_provider.lower() == "ollama":
reflect_base_url = "http://localhost:11434/v1"
else:
reflect_base_url = ""
self._reflect_llm_config = LLMConfig(
provider=reflect_provider,
api_key=reflect_api_key,
base_url=reflect_base_url,
model=reflect_model,
)
# Initialize cross-encoder reranker (cached for performance)
self._cross_encoder_reranker = CrossEncoderReranker(cross_encoder=cross_encoder)
@@ -609,9 +669,27 @@ class MemoryEngine(MemoryEngineInterface):
await loop.run_in_executor(None, self.query_analyzer.load)
async def verify_llm():
"""Verify LLM connection is working."""
"""Verify LLM connections are working for all unique configs."""
if not self._skip_llm_verification:
# Verify default config
await self._llm_config.verify_connection()
# Verify retain config if different from default
retain_is_different = (
self._retain_llm_config.provider != self._llm_config.provider
or self._retain_llm_config.model != self._llm_config.model
)
if retain_is_different:
await self._retain_llm_config.verify_connection()
# Verify reflect config if different from default and retain
reflect_is_different = (
self._reflect_llm_config.provider != self._llm_config.provider
or self._reflect_llm_config.model != self._llm_config.model
) and (
self._reflect_llm_config.provider != self._retain_llm_config.provider
or self._reflect_llm_config.model != self._retain_llm_config.model
)
if reflect_is_different:
await self._reflect_llm_config.verify_connection()
# Build list of initialization tasks
init_tasks = [
@@ -1175,7 +1253,7 @@ class MemoryEngine(MemoryEngineInterface):
return await orchestrator.retain_batch(
pool=pool,
embeddings_model=self.embeddings,
llm_config=self._llm_config,
llm_config=self._retain_llm_config,
entity_resolver=self.entity_resolver,
task_backend=self._task_backend,
format_date_fn=self._format_readable_date,
@@ -2822,7 +2900,7 @@ Guidelines:
- Small changes in confidence are normal; large jumps should be rare"""
try:
result = await self._llm_config.call(
result = await self._reflect_llm_config.call(
messages=[
{"role": "system", "content": "You evaluate and update opinions based on new information."},
{"role": "user", "content": evaluation_prompt},
@@ -2932,7 +3010,7 @@ Guidelines:
return
# Use cached LLM config
if self._llm_config is None:
if self._reflect_llm_config is None:
logger.error("[REINFORCE] LLM config not available, skipping opinion reinforcement")
return
@@ -3077,7 +3155,9 @@ Guidelines:
"""
await self._authenticate_tenant(request_context)
pool = await self._get_pool()
return await bank_utils.merge_bank_background(pool, self._llm_config, bank_id, new_info, update_disposition)
return await bank_utils.merge_bank_background(
pool, self._reflect_llm_config, bank_id, new_info, update_disposition
)
async def list_banks(
self,
@@ -3137,7 +3217,7 @@ Guidelines:
- structured_output: Optional dict if response_schema was provided
"""
# Use cached LLM config
if self._llm_config is None:
if self._reflect_llm_config is None:
raise ValueError("Memory LLM API key not set. Set HINDSIGHT_API_LLM_API_KEY environment variable.")
# Authenticate tenant and set schema in context (for fq_table())
@@ -3232,7 +3312,7 @@ Guidelines:
response_format = JsonSchemaWrapper(response_schema)
llm_start = time.time()
llm_result, usage = await self._llm_config.call(
llm_result, usage = await self._reflect_llm_config.call(
messages=messages,
scope="memory_reflect",
max_completion_tokens=max_tokens,
@@ -3318,7 +3398,9 @@ Guidelines:
"""
try:
# Extract opinions from the answer
new_opinions = await think_utils.extract_opinions_from_text(self._llm_config, text=answer_text, query=query)
new_opinions = await think_utils.extract_opinions_from_text(
self._reflect_llm_config, text=answer_text, query=query
)
# Store new opinions
if new_opinions:
@@ -3569,7 +3651,9 @@ Guidelines:
)
# Step 3: Extract observations using LLM (no personality)
observations = await observation_utils.extract_observations_from_facts(self._llm_config, entity_name, facts)
observations = await observation_utils.extract_observations_from_facts(
self._reflect_llm_config, entity_name, facts
)
if not observations:
return []
+8
View File
@@ -171,6 +171,14 @@ def main():
llm_base_url=config.llm_base_url,
llm_max_concurrent=config.llm_max_concurrent,
llm_timeout=config.llm_timeout,
retain_llm_provider=config.retain_llm_provider,
retain_llm_api_key=config.retain_llm_api_key,
retain_llm_model=config.retain_llm_model,
retain_llm_base_url=config.retain_llm_base_url,
reflect_llm_provider=config.reflect_llm_provider,
reflect_llm_api_key=config.reflect_llm_api_key,
reflect_llm_model=config.reflect_llm_model,
reflect_llm_base_url=config.reflect_llm_base_url,
embeddings_provider=config.embeddings_provider,
embeddings_local_model=config.embeddings_local_model,
embeddings_tei_url=config.embeddings_tei_url,
@@ -0,0 +1,277 @@
"""
Tests for per-operation LLM configuration.
Verifies that retain and reflect operations use their respective LLM configs.
"""
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_set = {
"HINDSIGHT_API_SKIP_LLM_VERIFICATION": "true",
"HINDSIGHT_API_LAZY_RERANKER": "true",
"HINDSIGHT_API_LLM_PROVIDER": "mock",
"HINDSIGHT_API_LLM_MODEL": "default-model",
"HINDSIGHT_API_RETAIN_LLM_PROVIDER": "mock",
"HINDSIGHT_API_RETAIN_LLM_MODEL": "retain-model",
"HINDSIGHT_API_REFLECT_LLM_PROVIDER": "mock",
"HINDSIGHT_API_REFLECT_LLM_MODEL": "reflect-model",
}
# Save original values
original_values = {}
for key in env_vars_to_set:
original_values[key] = os.environ.get(key)
# Set test values
for key, value in env_vars_to_set.items():
os.environ[key] = value
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()
class TestPerOperationLLMConfig:
"""Test that per-operation LLM configs are correctly applied."""
def test_config_loads_per_operation_settings(self):
"""Test that config correctly loads per-operation LLM settings."""
from hindsight_api.config import get_config
config = get_config()
# Default config
assert config.llm_provider == "mock"
assert config.llm_model == "default-model"
# Retain config
assert config.retain_llm_provider == "mock"
assert config.retain_llm_model == "retain-model"
# Reflect config
assert config.reflect_llm_provider == "mock"
assert config.reflect_llm_model == "reflect-model"
def test_memory_engine_creates_separate_llm_configs(self):
"""Test that MemoryEngine creates separate LLM configs for each operation."""
from hindsight_api import MemoryEngine
engine = MemoryEngine(
skip_llm_verification=True,
lazy_reranker=True,
)
# Verify default config
assert engine._llm_config.provider == "mock"
assert engine._llm_config.model == "default-model"
# Verify retain config
assert engine._retain_llm_config.provider == "mock"
assert engine._retain_llm_config.model == "retain-model"
# Verify reflect config
assert engine._reflect_llm_config.provider == "mock"
assert engine._reflect_llm_config.model == "reflect-model"
def test_memory_engine_with_explicit_params(self):
"""Test that explicit params override env config."""
from hindsight_api import MemoryEngine
engine = MemoryEngine(
memory_llm_provider="mock",
memory_llm_model="explicit-default",
retain_llm_provider="mock",
retain_llm_model="explicit-retain",
reflect_llm_provider="mock",
reflect_llm_model="explicit-reflect",
skip_llm_verification=True,
lazy_reranker=True,
)
assert engine._llm_config.model == "explicit-default"
assert engine._retain_llm_config.model == "explicit-retain"
assert engine._reflect_llm_config.model == "explicit-reflect"
def test_memory_engine_fallback_when_no_per_operation_config(self):
"""Test that per-operation configs fall back to default when not set."""
from hindsight_api.config import clear_config_cache as clear_cache
# Temporarily clear per-operation env vars
retain_provider = os.environ.pop("HINDSIGHT_API_RETAIN_LLM_PROVIDER", None)
retain_model = os.environ.pop("HINDSIGHT_API_RETAIN_LLM_MODEL", None)
reflect_provider = os.environ.pop("HINDSIGHT_API_REFLECT_LLM_PROVIDER", None)
reflect_model = os.environ.pop("HINDSIGHT_API_REFLECT_LLM_MODEL", None)
try:
clear_cache()
from hindsight_api import MemoryEngine
engine = MemoryEngine(
skip_llm_verification=True,
lazy_reranker=True,
)
# All should fall back to default
assert engine._llm_config.model == "default-model"
assert engine._retain_llm_config.model == "default-model"
assert engine._reflect_llm_config.model == "default-model"
finally:
# Restore env vars
if retain_provider:
os.environ["HINDSIGHT_API_RETAIN_LLM_PROVIDER"] = retain_provider
if retain_model:
os.environ["HINDSIGHT_API_RETAIN_LLM_MODEL"] = retain_model
if reflect_provider:
os.environ["HINDSIGHT_API_REFLECT_LLM_PROVIDER"] = reflect_provider
if reflect_model:
os.environ["HINDSIGHT_API_REFLECT_LLM_MODEL"] = reflect_model
clear_cache()
class TestMockLLMProvider:
"""Test the mock LLM provider functionality."""
def test_mock_provider_records_calls(self):
"""Test that mock provider records calls."""
from hindsight_api.engine.llm_wrapper import LLMProvider
provider = LLMProvider(
provider="mock",
api_key="",
base_url="",
model="test-model",
)
import asyncio
async def make_call():
return await provider.call(
messages=[{"role": "user", "content": "test"}],
scope="test_scope",
)
result = asyncio.get_event_loop().run_until_complete(make_call())
# Verify call was recorded
calls = provider.get_mock_calls()
assert len(calls) == 1
assert calls[0]["model"] == "test-model"
assert calls[0]["scope"] == "test_scope"
assert calls[0]["messages"] == [{"role": "user", "content": "test"}]
def test_mock_provider_returns_custom_response(self):
"""Test that mock provider can return custom responses."""
from hindsight_api.engine.llm_wrapper import LLMProvider
provider = LLMProvider(
provider="mock",
api_key="",
base_url="",
model="test-model",
)
provider.set_mock_response({"custom": "response"})
import asyncio
async def make_call():
return await provider.call(
messages=[{"role": "user", "content": "test"}],
)
result = asyncio.get_event_loop().run_until_complete(make_call())
assert result == {"custom": "response"}
def test_mock_provider_returns_usage_when_requested(self):
"""Test that mock provider returns token usage."""
from hindsight_api.engine.llm_wrapper import LLMProvider
provider = LLMProvider(
provider="mock",
api_key="",
base_url="",
model="test-model",
)
import asyncio
async def make_call():
return await provider.call(
messages=[{"role": "user", "content": "test"}],
return_usage=True,
)
result, usage = asyncio.get_event_loop().run_until_complete(make_call())
assert usage.input_tokens == 10
assert usage.output_tokens == 5
assert usage.total_tokens == 15
class TestRetainUsesRetainLLMConfig:
"""Test that retain operations use the retain LLM config."""
def test_retain_llm_config_is_passed_to_orchestrator(self):
"""Verify retain operation is configured to use _retain_llm_config."""
from hindsight_api import MemoryEngine
engine = MemoryEngine(
memory_llm_provider="mock",
memory_llm_model="default-model",
retain_llm_provider="mock",
retain_llm_model="retain-specific-model",
reflect_llm_provider="mock",
reflect_llm_model="reflect-specific-model",
skip_llm_verification=True,
lazy_reranker=True,
)
# Verify the retain LLM config is set correctly
assert engine._retain_llm_config.model == "retain-specific-model"
assert engine._retain_llm_config.provider == "mock"
# Verify it's different from the reflect config
assert engine._retain_llm_config.model != engine._reflect_llm_config.model
class TestReflectUsesReflectLLMConfig:
"""Test that reflect operations use the reflect LLM config."""
def test_reflect_llm_config_is_set_correctly(self):
"""Verify reflect/think operation is configured to use _reflect_llm_config."""
from hindsight_api import MemoryEngine
engine = MemoryEngine(
memory_llm_provider="mock",
memory_llm_model="default-model",
retain_llm_provider="mock",
retain_llm_model="retain-specific-model",
reflect_llm_provider="mock",
reflect_llm_model="reflect-specific-model",
skip_llm_verification=True,
lazy_reranker=True,
)
# Verify the reflect LLM config is set correctly
assert engine._reflect_llm_config.model == "reflect-specific-model"
assert engine._reflect_llm_config.provider == "mock"
# Verify it's different from the retain config
assert engine._reflect_llm_config.model != engine._retain_llm_config.model
@@ -86,6 +86,44 @@ export HINDSIGHT_API_LLM_API_KEY=your-api-key
export HINDSIGHT_API_LLM_MODEL=your-model-name
```
### Per-Operation LLM Configuration
Different memory operations have different requirements. **Retain** (fact extraction) benefits from models with strong structured output capabilities, while **Reflect** (reasoning/response generation) can use lighter, faster models. Configure separate LLM models for each operation to optimize for cost and performance.
| Variable | Description | Default |
|----------|-------------|---------|
| `HINDSIGHT_API_RETAIN_LLM_PROVIDER` | LLM provider for retain operations | Falls back to `HINDSIGHT_API_LLM_PROVIDER` |
| `HINDSIGHT_API_RETAIN_LLM_API_KEY` | API key for retain LLM | Falls back to `HINDSIGHT_API_LLM_API_KEY` |
| `HINDSIGHT_API_RETAIN_LLM_MODEL` | Model for retain operations | Falls back to `HINDSIGHT_API_LLM_MODEL` |
| `HINDSIGHT_API_RETAIN_LLM_BASE_URL` | Base URL for retain LLM | Falls back to `HINDSIGHT_API_LLM_BASE_URL` |
| `HINDSIGHT_API_REFLECT_LLM_PROVIDER` | LLM provider for reflect operations | Falls back to `HINDSIGHT_API_LLM_PROVIDER` |
| `HINDSIGHT_API_REFLECT_LLM_API_KEY` | API key for reflect LLM | Falls back to `HINDSIGHT_API_LLM_API_KEY` |
| `HINDSIGHT_API_REFLECT_LLM_MODEL` | Model for reflect operations | Falls back to `HINDSIGHT_API_LLM_MODEL` |
| `HINDSIGHT_API_REFLECT_LLM_BASE_URL` | Base URL for reflect LLM | Falls back to `HINDSIGHT_API_LLM_BASE_URL` |
:::tip When to Use Per-Operation Config
- **Retain**: Use models with strong structured output (e.g., GPT-4o, Claude) for accurate fact extraction
- **Reflect**: Use faster/cheaper models (e.g., GPT-4o-mini, Groq) for reasoning and response generation
- **Recall**: Does not use LLM (pure retrieval), so no configuration needed
:::
**Example: Separate Models for Retain and Reflect**
```bash
# Default LLM (used as fallback)
export HINDSIGHT_API_LLM_PROVIDER=openai
export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx
export HINDSIGHT_API_LLM_MODEL=gpt-4o
# Use GPT-4o for retain (strong structured output)
export HINDSIGHT_API_RETAIN_LLM_MODEL=gpt-4o
# Use faster/cheaper model for reflect
export HINDSIGHT_API_REFLECT_LLM_PROVIDER=groq
export HINDSIGHT_API_REFLECT_LLM_API_KEY=gsk_xxxxxxxxxxxx
export HINDSIGHT_API_REFLECT_LLM_MODEL=llama-3.3-70b-versatile
```
### Embeddings
| Variable | Description | Default |