feat: add 'none' LLM provider for chunk-only storage mode (#691)

Adds a proper 'none' provider option so users can run Hindsight as a
chunk store with semantic search but without any LLM dependency, replacing
the hacky workaround of setting provider to 'mock'.

When HINDSIGHT_API_LLM_PROVIDER=none:
- Retain automatically uses chunks mode (no fact extraction)
- Recall works normally (semantic search, BM25, graph retrieval)
- Reflect returns HTTP 400 with clear error message
- Consolidation/observations are disabled
- Mental model refresh returns HTTP 400
- No API key required
This commit is contained in:
Nicolò Boschi 2026-03-25 18:01:20 +01:00 committed by GitHub
parent 5095d5e36f
commit 9e5a066d26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 390 additions and 3 deletions

View file

@ -72,6 +72,7 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
from hindsight_api.config import get_config from hindsight_api.config import get_config
from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table
from hindsight_api.engine.providers.none_llm import LLMNotAvailableError
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
from hindsight_api.engine.search.tags import TagGroup, TagsMatch from hindsight_api.engine.search.tags import TagGroup, TagsMatch
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
@ -2651,6 +2652,8 @@ def _register_routes(app: FastAPI):
raise HTTPException(status_code=e.status_code, detail=e.reason) raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException): except (AuthenticationError, HTTPException):
raise raise
except LLMNotAvailableError as e:
raise HTTPException(status_code=400, detail=str(e))
except TimeoutError as e: except TimeoutError as e:
logger.error("Timeout in /v1/default/banks/%s/reflect: %s", bank_id, e) logger.error("Timeout in /v1/default/banks/%s/reflect: %s", bank_id, e)
raise HTTPException( raise HTTPException(
@ -3017,6 +3020,8 @@ def _register_routes(app: FastAPI):
request_context=request_context, request_context=request_context,
) )
return AsyncOperationSubmitResponse(operation_id=result["operation_id"], status="queued") return AsyncOperationSubmitResponse(operation_id=result["operation_id"], status="queued")
except LLMNotAvailableError as e:
raise HTTPException(status_code=400, detail=str(e))
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
except (AuthenticationError, HTTPException): except (AuthenticationError, HTTPException):

View file

@ -365,6 +365,7 @@ PROVIDER_DEFAULT_MODELS = {
"openai-codex": "gpt-5.2-codex", "openai-codex": "gpt-5.2-codex",
"claude-code": "claude-sonnet-4-5-20250929", "claude-code": "claude-sonnet-4-5-20250929",
"mock": "mock-model", "mock": "mock-model",
"none": "none",
"litellm": "gpt-4o-mini", "litellm": "gpt-4o-mini",
"bedrock": "us.amazon.nova-2-lite-v1:0", "bedrock": "us.amazon.nova-2-lite-v1:0",
} }
@ -958,9 +959,19 @@ class HindsightConfig:
f"Invalid text_search_extension: {self.text_search_extension}. Must be one of: {', '.join(valid_text_search)}" f"Invalid text_search_extension: {self.text_search_extension}. Must be one of: {', '.join(valid_text_search)}"
) )
# When LLM provider is "none", force chunks-only mode and disable LLM-dependent features
if self.llm_provider == "none":
self.retain_extraction_mode = "chunks"
self.enable_observations = False
logger.info(
"LLM provider set to 'none': forcing retain_extraction_mode='chunks', "
"disabling observations/consolidation. Reflect will return HTTP 400."
)
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE # RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
# to ensure the LLM has enough output capacity to extract facts from chunks # to ensure the LLM has enough output capacity to extract facts from chunks
if self.retain_max_completion_tokens <= self.retain_chunk_size: # (not applicable when provider is "none" since no LLM calls are made)
if self.llm_provider != "none" and self.retain_max_completion_tokens <= self.retain_chunk_size:
raise ValueError( raise ValueError(
f"Invalid configuration: HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS " f"Invalid configuration: HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS "
f"({self.retain_max_completion_tokens}) must be greater than " f"({self.retain_max_completion_tokens}) must be greater than "

View file

@ -125,6 +125,7 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
"openai-codex", "openai-codex",
"claude-code", "claude-code",
"mock", "mock",
"none",
"vertexai", "vertexai",
"litellm", "litellm",
"bedrock", "bedrock",
@ -176,6 +177,7 @@ def create_llm_provider(
GeminiLLM, GeminiLLM,
LiteLLMLLM, LiteLLMLLM,
MockLLM, MockLLM,
NoneLLM,
OpenAICompatibleLLM, OpenAICompatibleLLM,
) )
@ -208,6 +210,15 @@ def create_llm_provider(
reasoning_effort=reasoning_effort, reasoning_effort=reasoning_effort,
) )
elif provider_lower == "none":
return NoneLLM(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort=reasoning_effort,
)
elif provider_lower in ("gemini", "vertexai"): elif provider_lower in ("gemini", "vertexai"):
return GeminiLLM( return GeminiLLM(
provider=provider, provider=provider,
@ -319,6 +330,7 @@ class LLMProvider:
"openai-codex", "openai-codex",
"claude-code", "claude-code",
"mock", "mock",
"none",
"minimax", "minimax",
"litellm", "litellm",
"bedrock", "bedrock",

View file

@ -328,6 +328,10 @@ class MemoryEngine(MemoryEngineInterface):
# Apply defaults from config # Apply defaults from config
db_url = db_url or config.database_url db_url = db_url or config.database_url
memory_llm_provider = memory_llm_provider or config.llm_provider memory_llm_provider = memory_llm_provider or config.llm_provider
# Force skip LLM verification when provider is "none" (no LLM to verify)
if memory_llm_provider == "none":
self._skip_llm_verification = True
memory_llm_api_key = memory_llm_api_key or config.llm_api_key memory_llm_api_key = memory_llm_api_key or config.llm_api_key
if not memory_llm_api_key and requires_api_key(memory_llm_provider): if not memory_llm_api_key and requires_api_key(memory_llm_provider):
raise ValueError("LLM API key is required. Set HINDSIGHT_API_LLM_API_KEY environment variable.") raise ValueError("LLM API key is required. Set HINDSIGHT_API_LLM_API_KEY environment variable.")
@ -820,6 +824,11 @@ class MemoryEngine(MemoryEngineInterface):
if not bank_id: if not bank_id:
raise ValueError("bank_id is required for consolidation task") raise ValueError("bank_id is required for consolidation task")
# Skip consolidation when LLM provider is "none"
if self._llm_config.provider == "none":
logger.info(f"[CONSOLIDATION] Skipping consolidation for bank {bank_id}: LLM provider is 'none'")
return {"memories_processed": 0, "skipped": True}
from hindsight_api.models import RequestContext from hindsight_api.models import RequestContext
from .consolidation import run_consolidation_job from .consolidation import run_consolidation_job
@ -2254,6 +2263,11 @@ class MemoryEngine(MemoryEngineInterface):
# Resolve bank-specific config for this operation # Resolve bank-specific config for this operation
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context) resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
# Force chunks mode when LLM provider is "none" (no LLM available for fact extraction)
if self._llm_config.provider == "none":
resolved_config.retain_extraction_mode = "chunks"
resolved_config.enable_observations = False
# Apply strategy overrides: explicit strategy > bank default strategy # Apply strategy overrides: explicit strategy > bank default strategy
from hindsight_api.config_resolver import apply_strategy from hindsight_api.config_resolver import apply_strategy
@ -5192,6 +5206,15 @@ class MemoryEngine(MemoryEngineInterface):
if self._reflect_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.") raise ValueError("Memory LLM API key not set. Set HINDSIGHT_API_LLM_API_KEY environment variable.")
# Block reflect when LLM provider is "none"
if self._llm_config.provider == "none":
from .providers.none_llm import LLMNotAvailableError
raise LLMNotAvailableError(
"Reflect requires an LLM provider. Current provider is set to 'none'. "
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
)
# Authenticate tenant and set schema in context (for fq_table()) # Authenticate tenant and set schema in context (for fq_table())
await self._authenticate_tenant(request_context) await self._authenticate_tenant(request_context)
@ -7856,6 +7879,15 @@ class MemoryEngine(MemoryEngineInterface):
Returns: Returns:
Dict with operation_id Dict with operation_id
""" """
# Block mental model refresh when LLM provider is "none"
if self._llm_config.provider == "none":
from .providers.none_llm import LLMNotAvailableError
raise LLMNotAvailableError(
"Mental model refresh requires an LLM provider. Current provider is set to 'none'. "
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
)
await self._authenticate_tenant(request_context) await self._authenticate_tenant(request_context)
# Pre-operation validation (credit check) # Pre-operation validation (credit check)

View file

@ -10,6 +10,16 @@ from .codex_llm import CodexLLM
from .gemini_llm import GeminiLLM from .gemini_llm import GeminiLLM
from .litellm_llm import LiteLLMLLM from .litellm_llm import LiteLLMLLM
from .mock_llm import MockLLM from .mock_llm import MockLLM
from .none_llm import NoneLLM
from .openai_compatible_llm import OpenAICompatibleLLM from .openai_compatible_llm import OpenAICompatibleLLM
__all__ = ["AnthropicLLM", "ClaudeCodeLLM", "CodexLLM", "GeminiLLM", "LiteLLMLLM", "MockLLM", "OpenAICompatibleLLM"] __all__ = [
"AnthropicLLM",
"ClaudeCodeLLM",
"CodexLLM",
"GeminiLLM",
"LiteLLMLLM",
"MockLLM",
"NoneLLM",
"OpenAICompatibleLLM",
]

View file

@ -0,0 +1,78 @@
"""
No-op LLM provider for chunk-only storage mode.
When the LLM provider is set to "none", the system operates without any LLM dependency.
Retain uses chunks mode (no fact extraction), and reflect/consolidation are disabled.
This provider acts as a safety net if any code path unexpectedly tries to call the LLM,
it raises a clear error instead of a confusing connection failure.
"""
import logging
from typing import Any
from ..llm_interface import LLMInterface
from ..response_models import LLMToolCallResult
logger = logging.getLogger(__name__)
class LLMNotAvailableError(Exception):
"""Raised when an operation requires an LLM but the provider is set to 'none'."""
pass
class NoneLLM(LLMInterface):
"""
No-op LLM provider that rejects all LLM calls.
Used when HINDSIGHT_API_LLM_PROVIDER=none to run Hindsight as a chunk store
with semantic search but without LLM-based features (fact extraction, reflect,
consolidation).
"""
async def verify_connection(self) -> None:
"""No-op — no LLM connection to verify."""
logger.debug("NoneLLM: no LLM connection to verify (provider=none)")
async def call(
self,
messages: list[dict[str, str]],
response_format: Any | None = None,
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
strict_schema: bool = False,
return_usage: bool = False,
) -> Any:
"""Raise LLMNotAvailableError — no LLM is configured."""
raise LLMNotAvailableError(
"LLM provider is set to 'none'. This operation requires an LLM. "
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
)
async def call_with_tools(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
max_completion_tokens: int | None = None,
temperature: float | None = None,
scope: str = "tools",
max_retries: int = 5,
initial_backoff: float = 1.0,
max_backoff: float = 30.0,
tool_choice: str | dict[str, Any] = "auto",
) -> LLMToolCallResult:
"""Raise LLMNotAvailableError — no LLM is configured."""
raise LLMNotAvailableError(
"LLM provider is set to 'none'. This operation requires an LLM. "
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
)
async def cleanup(self) -> None:
"""No-op — nothing to clean up."""
pass

View file

@ -0,0 +1,232 @@
"""
Tests for the 'none' LLM provider mode.
Verifies that when HINDSIGHT_API_LLM_PROVIDER=none:
- Retain defaults to chunks mode (no LLM calls)
- Reflect returns 400
- Mental model refresh returns 400
- Consolidation is skipped
- NoneLLM.call() raises LLMNotAvailableError
"""
import os
from datetime import datetime, timezone
import httpx
import pytest
import pytest_asyncio
from hindsight_api import LLMConfig, LocalSTEmbeddings, MemoryEngine, RequestContext
from hindsight_api.api import create_app
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.providers.none_llm import LLMNotAvailableError, NoneLLM
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
from hindsight_api.engine.task_backend import SyncTaskBackend
@pytest.fixture(scope="function")
def request_context():
return RequestContext()
@pytest_asyncio.fixture(scope="function")
async def none_memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
"""MemoryEngine with provider=none."""
mem = MemoryEngine(
db_url=pg0_db_url,
memory_llm_provider="none",
memory_llm_api_key=None,
memory_llm_model="none",
embeddings=embeddings,
cross_encoder=cross_encoder,
query_analyzer=query_analyzer,
pool_min_size=1,
pool_max_size=5,
run_migrations=False,
task_backend=SyncTaskBackend(),
)
await mem.initialize()
yield mem
try:
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
@pytest_asyncio.fixture
async def none_api_client(none_memory):
"""HTTP test client backed by a none-provider MemoryEngine."""
app = create_app(none_memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
# -- Unit tests for NoneLLM ---------------------------------------------------
@pytest.mark.asyncio
async def test_none_llm_call_raises():
"""NoneLLM.call() should raise LLMNotAvailableError."""
llm = NoneLLM(provider="none", api_key="", base_url="", model="none")
with pytest.raises(LLMNotAvailableError):
await llm.call(messages=[{"role": "user", "content": "hello"}])
@pytest.mark.asyncio
async def test_none_llm_call_with_tools_raises():
"""NoneLLM.call_with_tools() should raise LLMNotAvailableError."""
llm = NoneLLM(provider="none", api_key="", base_url="", model="none")
with pytest.raises(LLMNotAvailableError):
await llm.call_with_tools(
messages=[{"role": "user", "content": "hello"}],
tools=[{"type": "function", "function": {"name": "test", "parameters": {}}}],
)
@pytest.mark.asyncio
async def test_none_llm_verify_connection_succeeds():
"""NoneLLM.verify_connection() should be a no-op."""
llm = NoneLLM(provider="none", api_key="", base_url="", model="none")
await llm.verify_connection() # Should not raise
# -- Config validation tests ---------------------------------------------------
def test_config_forces_chunks_mode():
"""When provider is 'none', config.validate() forces retain_extraction_mode='chunks'."""
from hindsight_api.config import HindsightConfig
config = HindsightConfig.from_env()
# Override to none for test
config.llm_provider = "none"
config.retain_extraction_mode = "facts"
config.enable_observations = True
config.validate()
assert config.retain_extraction_mode == "chunks"
assert config.enable_observations is False
# -- Integration tests (require database) -------------------------------------
@pytest.mark.asyncio
async def test_retain_works_with_none_provider(none_memory, request_context):
"""Retain should work with provider=none, storing chunks without LLM calls."""
bank_id = f"test_none_retain_{datetime.now(timezone.utc).timestamp()}"
unit_ids = await none_memory.retain_async(
bank_id=bank_id,
content="Alice is a software engineer. She works at TechCorp and loves Python.",
context="team info",
request_context=request_context,
)
assert len(unit_ids) > 0, "Should store chunks even without an LLM"
@pytest.mark.asyncio
async def test_recall_works_with_none_provider(none_memory, request_context):
"""Recall should work with provider=none (uses embeddings, not LLM)."""
bank_id = f"test_none_recall_{datetime.now(timezone.utc).timestamp()}"
await none_memory.retain_async(
bank_id=bank_id,
content="Alice is a software engineer at TechCorp.",
context="team info",
request_context=request_context,
)
result = await none_memory.recall_async(
bank_id=bank_id,
query="Who is Alice?",
budget=Budget.LOW,
request_context=request_context,
)
assert len(result.results) > 0, "Should find results via semantic search"
@pytest.mark.asyncio
async def test_reflect_raises_with_none_provider(none_memory, request_context):
"""Reflect should raise LLMNotAvailableError with provider=none."""
bank_id = f"test_none_reflect_{datetime.now(timezone.utc).timestamp()}"
with pytest.raises(LLMNotAvailableError):
await none_memory.reflect_async(
bank_id=bank_id,
query="What do you know?",
request_context=request_context,
)
@pytest.mark.asyncio
async def test_consolidation_skipped_with_none_provider(none_memory, request_context):
"""Consolidation handler should skip when provider=none."""
result = await none_memory._handle_consolidation({"bank_id": "test_bank"})
assert result["skipped"] is True
assert result["memories_processed"] == 0
@pytest.mark.asyncio
async def test_mental_model_refresh_raises_with_none_provider(none_memory, request_context):
"""Mental model refresh should raise LLMNotAvailableError with provider=none."""
bank_id = f"test_none_mm_{datetime.now(timezone.utc).timestamp()}"
with pytest.raises(LLMNotAvailableError):
await none_memory.submit_async_refresh_mental_model(
bank_id=bank_id,
mental_model_id="fake-id",
request_context=request_context,
)
# -- HTTP API tests -----------------------------------------------------------
@pytest.mark.asyncio
async def test_http_reflect_returns_400(none_api_client):
"""Reflect endpoint should return 400 when LLM provider is none."""
bank_id = f"test_none_http_{datetime.now(timezone.utc).timestamp()}"
response = await none_api_client.post(
f"/v1/default/banks/{bank_id}/reflect",
json={"query": "What do you know?"},
)
assert response.status_code == 400
assert "none" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_http_retain_works(none_api_client):
"""Retain endpoint should work with provider=none (chunks mode)."""
bank_id = f"test_none_http_retain_{datetime.now(timezone.utc).timestamp()}"
response = await none_api_client.post(
f"/v1/default/banks/{bank_id}/memories",
json={"items": [{"content": "Hello world", "context": "test"}]},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_http_recall_works(none_api_client):
"""Recall endpoint should work with provider=none."""
bank_id = f"test_none_http_recall_{datetime.now(timezone.utc).timestamp()}"
# Retain first
await none_api_client.post(
f"/v1/default/banks/{bank_id}/memories",
json={"items": [{"content": "Alice is an engineer.", "context": "test"}]},
)
# Recall
response = await none_api_client.post(
f"/v1/default/banks/{bank_id}/memories/recall",
json={"query": "Alice"},
)
assert response.status_code == 200

View file

@ -160,7 +160,7 @@ To switch between backends:
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| |----------|-------------|---------|
| `HINDSIGHT_API_LLM_PROVIDER` | Provider: `openai`, `openai-codex`, `claude-code`, `anthropic`, `gemini`, `groq`, `minimax`, `ollama`, `lmstudio`, `vertexai`, `bedrock`, `litellm` | `openai` | | `HINDSIGHT_API_LLM_PROVIDER` | Provider: `openai`, `openai-codex`, `claude-code`, `anthropic`, `gemini`, `groq`, `minimax`, `ollama`, `lmstudio`, `vertexai`, `bedrock`, `litellm`, `none` | `openai` |
| `HINDSIGHT_API_LLM_API_KEY` | API key for LLM provider | - | | `HINDSIGHT_API_LLM_API_KEY` | API key for LLM provider | - |
| `HINDSIGHT_API_LLM_MODEL` | Model name | `gpt-5-mini` | | `HINDSIGHT_API_LLM_MODEL` | Model name | `gpt-5-mini` |
| `HINDSIGHT_API_LLM_BASE_URL` | Custom LLM endpoint | Provider default | | `HINDSIGHT_API_LLM_BASE_URL` | Custom LLM endpoint | Provider default |
@ -250,6 +250,13 @@ export HINDSIGHT_API_LLM_MODEL=azure/gpt-4o
export HINDSIGHT_API_LLM_PROVIDER=litellm export HINDSIGHT_API_LLM_PROVIDER=litellm
export HINDSIGHT_API_LLM_API_KEY=your-together-api-key export HINDSIGHT_API_LLM_API_KEY=your-together-api-key
export HINDSIGHT_API_LLM_MODEL=together_ai/meta-llama/Llama-3-70b-chat-hf export HINDSIGHT_API_LLM_MODEL=together_ai/meta-llama/Llama-3-70b-chat-hf
# No LLM (chunk storage + semantic search only, no API key needed)
export HINDSIGHT_API_LLM_PROVIDER=none
# Retain automatically uses chunks mode (no fact extraction)
# Recall works normally (semantic search, BM25, graph retrieval)
# Reflect returns HTTP 400 (requires an LLM)
# Consolidation/observations are disabled
``` ```
:::tip OpenAI Codex, Claude Code & Vertex AI Setup :::tip OpenAI Codex, Claude Code & Vertex AI Setup