Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 1ea00bf5ee feat(litellm): support tags and mission in litellm package 2026-01-26 17:09:17 +01:00
23 changed files with 1745 additions and 5676 deletions
@@ -1,5 +0,0 @@
"""Hindsight-LangMem: LangGraph langmem drop-in replacement using Hindsight."""
from hindsight_langmem.store import HindsightStore
__all__ = ["HindsightStore"]
@@ -1,282 +0,0 @@
"""Hindsight implementation of LangGraph BaseStore interface."""
import json
from typing import Any, Iterable
from hindsight_client import Hindsight
from langgraph.store.base import (
BaseStore,
GetOp,
Item,
ListNamespacesOp,
Op,
PutOp,
Result,
SearchItem,
SearchOp,
)
class HindsightStore(BaseStore):
"""
Hindsight implementation of LangGraph BaseStore.
This store uses Hindsight's memory system as a backend for LangGraph's memory storage.
Each namespace maps to a Hindsight agent, and items are stored as memory units.
Args:
base_url: The base URL of the Hindsight API server
default_agent_id: Default agent ID to use when namespace is empty (optional)
"""
def __init__(self, base_url: str, default_agent_id: str | None = None):
"""Initialize the Hindsight store.
Args:
base_url: Base URL for the Hindsight API
default_agent_id: Default agent ID when namespace is empty
"""
super().__init__()
self.client = Hindsight(base_url=base_url)
self.default_agent_id = default_agent_id or "default"
self._ensure_agent_exists(self.default_agent_id)
def _namespace_to_agent_id(self, namespace: tuple[str, ...]) -> str:
"""Convert namespace to agent ID."""
if not namespace:
return self.default_agent_id
return "__".join(namespace)
def _ensure_agent_exists(self, agent_id: str) -> None:
"""Ensure an agent exists, create if it doesn't."""
try:
# Try to create agent (idempotent operation)
self.client.create_agent(agent_id=agent_id)
except Exception:
# Agent likely already exists
pass
def _serialize_value(self, value: dict[str, Any]) -> str:
"""Serialize a value to JSON string."""
return json.dumps(value, sort_keys=True)
def _deserialize_value(self, content: str) -> dict[str, Any]:
"""Deserialize JSON string back to value."""
try:
return json.loads(content)
except json.JSONDecodeError:
return {"content": content}
def batch(self, ops: Iterable[Op]) -> list[Result]:
"""Execute a batch of operations synchronously."""
results: list[Result] = []
for op in ops:
if isinstance(op, PutOp):
results.append(self._put(op))
elif isinstance(op, GetOp):
results.append(self._get(op))
elif isinstance(op, SearchOp):
results.append(self._search(op))
elif isinstance(op, ListNamespacesOp):
results.append(self._list_namespaces(op))
else:
results.append(None)
return results
async def abatch(self, ops: Iterable[Op]) -> list[Result]:
"""Execute a batch of operations asynchronously."""
return self.batch(ops)
def _put(self, op: PutOp) -> None:
"""Store an item."""
agent_id = self._namespace_to_agent_id(op.namespace)
self._ensure_agent_exists(agent_id)
value_with_key = {"__key__": op.key, **op.value}
content = self._serialize_value(value_with_key)
self.client.put(
agent_id=agent_id,
content=content,
context=f"key:{op.key}",
document_id=op.key,
)
return None
def _get(self, op: GetOp) -> Item | None:
"""Retrieve an item by namespace and key."""
agent_id = self._namespace_to_agent_id(op.namespace)
try:
response = self.client.get_document(agent_id=agent_id, document_id=op.key)
if not response or not response.get("original_text"):
return None
# Parse the original text to get the value
value = self._deserialize_value(response["original_text"])
stored_key = value.pop("__key__", op.key)
if stored_key != op.key:
return None
return Item(
namespace=op.namespace,
key=op.key,
value=value,
created_at=response.get("created_at"),
updated_at=response.get("updated_at"),
)
except Exception:
return None
def _search(self, op: SearchOp) -> list[SearchItem]:
"""Search for items within a namespace prefix."""
agent_id = self._namespace_to_agent_id(op.namespace_prefix)
try:
results = self.client.search(
agent_id=agent_id,
query=op.query or "",
max_tokens=op.limit * 100,
)
if not results:
return []
items: list[SearchItem] = []
seen_keys = set()
for result in results[op.offset : op.offset + op.limit]:
try:
text = result.get("text", "")
value = self._deserialize_value(text)
key = value.pop("__key__", result.get("id"))
if key in seen_keys:
continue
seen_keys.add(key)
items.append(
SearchItem(
namespace=op.namespace_prefix,
key=key,
value=value,
score=1.0,
created_at=None,
updated_at=None,
)
)
if len(items) >= op.limit:
break
except Exception:
continue
return items
except Exception:
return []
def _list_namespaces(self, op: ListNamespacesOp) -> list[tuple[str, ...]]:
"""List all namespaces."""
# Not fully implemented - would need to list all agents
return []
def _matches_prefix(self, namespace: tuple[str, ...], prefix: tuple[str, ...]) -> bool:
"""Check if namespace matches prefix."""
if len(namespace) < len(prefix):
return False
return namespace[: len(prefix)] == prefix
def _matches_suffix(self, namespace: tuple[str, ...], suffix: tuple[str, ...]) -> bool:
"""Check if namespace matches suffix."""
if len(namespace) < len(suffix):
return False
return namespace[-len(suffix) :] == suffix
def put(
self,
namespace: tuple[str, ...],
key: str,
value: dict[str, Any],
index: bool | list[str] | None = None,
) -> None:
"""Store a single item."""
self._put(PutOp(namespace=namespace, key=key, value=value))
async def aput(
self,
namespace: tuple[str, ...],
key: str,
value: dict[str, Any],
index: bool | list[str] | None = None,
) -> None:
"""Store a single item asynchronously."""
self.put(namespace, key, value, index)
def get(self, namespace: tuple[str, ...], key: str) -> Item | None:
"""Retrieve a single item."""
return self._get(GetOp(namespace=namespace, key=key))
async def aget(self, namespace: tuple[str, ...], key: str) -> Item | None:
"""Retrieve a single item asynchronously."""
return self.get(namespace, key)
def delete(self, namespace: tuple[str, ...], key: str) -> None:
"""Delete an item by deleting the document."""
agent_id = self._namespace_to_agent_id(namespace)
try:
self.client.delete_document(agent_id=agent_id, document_id=key)
except Exception:
pass
async def adelete(self, namespace: tuple[str, ...], key: str) -> None:
"""Delete an item asynchronously."""
self.delete(namespace, key)
def search(
self,
namespace_prefix: tuple[str, ...],
query: str | None = None,
filter: dict[str, Any] | None = None,
limit: int = 10,
offset: int = 0,
) -> list[SearchItem]:
"""Search for items."""
return self._search(SearchOp(namespace_prefix=namespace_prefix, query=query, limit=limit, offset=offset))
async def asearch(
self,
namespace_prefix: tuple[str, ...],
query: str | None = None,
filter: dict[str, Any] | None = None,
limit: int = 10,
offset: int = 0,
) -> list[SearchItem]:
"""Search for items asynchronously."""
return self.search(namespace_prefix, query, filter, limit, offset)
def list_namespaces(
self,
prefix: tuple[str, ...] | None = None,
suffix: tuple[str, ...] | None = None,
max_depth: int | None = None,
limit: int = 100,
offset: int = 0,
) -> list[tuple[str, ...]]:
"""List all namespaces."""
return self._list_namespaces(
ListNamespacesOp(prefix=prefix, suffix=suffix, max_depth=max_depth, limit=limit, offset=offset)
)
async def alist_namespaces(
self,
prefix: tuple[str, ...] | None = None,
suffix: tuple[str, ...] | None = None,
max_depth: int | None = None,
limit: int = 100,
offset: int = 0,
) -> list[tuple[str, ...]]:
"""List all namespaces asynchronously."""
return self.list_namespaces(prefix, suffix, max_depth, limit, offset)
@@ -1,37 +0,0 @@
[project]
name = "hindsight-langmem"
version = "0.0.1"
description = "LangGraph langmem drop-in replacement using Hindsight memory system"
authors = [
{name = "Hindsight Team"}
]
requires-python = ">=3.10"
dependencies = [
"langgraph>=0.2.0",
"hindsight-client",
# Transitive dependency security fixes
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
"langchain-core>=1.2.5", # Serialization injection vulnerability
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass
]
[tool.uv.sources]
hindsight-client = { path = "../../hindsight-clients/python" }
[project.optional-dependencies]
test = [
"pytest>=7.0.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["hindsight_langmem"]
[tool.ruff]
line-length = 120
[tool.ruff.lint]
select = ["F", "I", "UP"]
@@ -1 +0,0 @@
"""Tests for hindsight-langmem package."""
@@ -1,119 +0,0 @@
"""Tests for HindsightStore implementation."""
import os
import time
import pytest
from hindsight_langmem import HindsightStore
@pytest.fixture
def store():
"""Create a HindsightStore instance for testing."""
base_url = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
return HindsightStore(base_url=base_url, default_agent_id=f"test_agent_{int(time.time())}")
def test_put_and_get(store):
"""Test storing and retrieving an item."""
namespace = ("test", "namespace")
key = "test_key"
value = {"data": "test_value", "number": 42}
store.put(namespace, key, value)
time.sleep(1)
retrieved = store.get(namespace, key)
assert retrieved is not None
assert retrieved.key == key
assert retrieved.namespace == namespace
assert retrieved.value == value
def test_search(store):
"""Test searching for items."""
namespace = ("search", "test")
key1 = "item1"
value1 = {"content": "This is about machine learning", "type": "note"}
key2 = "item2"
value2 = {"content": "This is about deep learning and neural networks", "type": "article"}
store.put(namespace, key1, value1)
store.put(namespace, key2, value2)
time.sleep(2)
results = store.search(namespace, query="machine learning", limit=5)
assert len(results) > 0
assert any(r.key in [key1, key2] for r in results)
def test_delete(store):
"""Test deleting an item."""
namespace = ("delete", "test")
key = "to_delete"
value = {"data": "temporary"}
store.put(namespace, key, value)
time.sleep(1)
retrieved = store.get(namespace, key)
assert retrieved is not None
store.delete(namespace, key)
time.sleep(1)
retrieved_after_delete = store.get(namespace, key)
assert retrieved_after_delete is None
def test_list_namespaces(store):
"""Test listing namespaces."""
namespace1 = ("list", "test", "one")
namespace2 = ("list", "test", "two")
store.put(namespace1, "key1", {"data": "value1"})
store.put(namespace2, "key2", {"data": "value2"})
time.sleep(1)
namespaces = store.list_namespaces(prefix=("list",))
assert len(namespaces) >= 2
assert namespace1 in namespaces
assert namespace2 in namespaces
def test_batch_operations(store):
"""Test batch operations."""
from langgraph.store.base import GetOp, PutOp
namespace = ("batch", "test")
ops = [
PutOp(namespace=namespace, key="key1", value={"data": "value1"}),
PutOp(namespace=namespace, key="key2", value={"data": "value2"}),
]
results = store.batch(ops)
assert len(results) == 2
time.sleep(1)
get_ops = [
GetOp(namespace=namespace, key="key1"),
GetOp(namespace=namespace, key="key2"),
]
get_results = store.batch(get_ops)
assert len(get_results) == 2
assert get_results[0] is not None
assert get_results[0].value == {"data": "value1"}
assert get_results[1] is not None
assert get_results[1].value == {"data": "value2"}
@@ -1,163 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": "# Hindsight-LangMem: Drop-in Semantic Memory for LangGraph\n\nReplace your LangGraph memory store in one line and get advanced semantic capabilities."
},
{
"cell_type": "markdown",
"metadata": {},
"source": "## What is Hindsight-LangMem?\n\n`hindsight-langmem` implements LangGraph's `BaseStore` interface using Hindsight as the backend.\n\n### What You Get vs Standard LangGraph Memory\n\n| Feature | Standard Memory | Hindsight-LangMem |\n|---------|-----------------|-------------------|\n| Basic Key-Value Storage | ✅ | ✅ |\n| Semantic Search | ✅ Basic | ✅ **Enhanced with spreading activation** |\n| Namespace Support | ✅ | ✅ |\n| **Personality-Driven Retrieval** | ❌ | ✅ |\n| **Automatic Fact Extraction** | ❌ | ✅ |\n| **Entity Recognition** | ❌ | ✅ |\n| **Temporal Reasoning** | ❌ | ✅ |\n| **Opinion Formation** | ❌ | ✅ |\n| **Background Knowledge** | ❌ | ✅ |\n| **Thinking/Reasoning API** | ❌ | ✅ |\n\n### When to Use\n- Conversational agents needing long-term memory\n- Personalized AI with context-aware responses \n- Multi-agent systems with distinct personalities\n- Knowledge management with semantic search"
},
{
"cell_type": "markdown",
"source": "## Installation\n\nRun this cell to install dependencies:",
"metadata": {}
},
{
"cell_type": "code",
"source": "!pip install langgraph langmem",
"metadata": {},
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"source": "Make sure Hindsight API is running at `http://localhost:8888`",
"metadata": {}
},
{
"cell_type": "markdown",
"source": "## Setup API Keys\n\nSet up your OpenAI API key and Hindsight URL:",
"metadata": {}
},
{
"cell_type": "code",
"source": "import os\nimport getpass\n\n# Set OpenAI API key\nif \"OPENAI_API_KEY\" not in os.environ:\n os.environ[\"OPENAI_API_KEY\"] = getpass.getpass(\"Enter your OpenAI API key: \")\n\n# Set Hindsight API URL\nif \"HINDSIGHT_API_URL\" not in os.environ:\n os.environ[\"HINDSIGHT_API_URL\"] = input(\"Enter Hindsight API URL (default: http://localhost:8888): \") or \"http://localhost:8888\"\n\nprint(\"✅ API keys configured\")",
"metadata": {},
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## The Drop-in Replacement\n",
"\n"
]
},
{
"cell_type": "markdown",
"source": "### Before: Standard LangGraph Memory",
"metadata": {}
},
{
"cell_type": "code",
"source": "from langmem import create_manage_memory_tool, create_search_memory_tool\nfrom langgraph.prebuilt import create_react_agent\nfrom langgraph.store.memory import InMemoryStore\n\n# Standard store - basic key-value with optional vector search\nstore = InMemoryStore()\n\nagent = create_react_agent(\n \"openai:gpt-4o\",\n tools=[\n create_manage_memory_tool(namespace=(\"memories\",)),\n create_search_memory_tool(namespace=(\"memories\",)),\n ],\n store=store\n)",
"metadata": {},
"execution_count": null,
"outputs": []
},
{
"cell_type": "markdown",
"source": "### After: With Hindsight-LangMem\n\n**Just change one line!**",
"metadata": {},
"execution_count": null,
"outputs": []
},
{
"cell_type": "code",
"metadata": {},
"source": "import os\nfrom langmem import create_manage_memory_tool, create_search_memory_tool\nfrom langgraph.prebuilt import create_react_agent\nfrom hindsight_langmem import HindsightStore # ← Only import change!\n\n# Replace InMemoryStore with HindsightStore\nbase_url = os.getenv(\"HINDSIGHT_API_URL\", \"http://localhost:8888\")\nstore = HindsightStore(base_url=base_url, default_agent_id=\"my_agent\") # ← One line change!\n\n# Everything else stays exactly the same\nagent = create_react_agent(\n \"openai:gpt-4o\", # ← Use OpenAI\n tools=[\n create_manage_memory_tool(namespace=(\"memories\",)),\n create_search_memory_tool(namespace=(\"memories\",)),\n ],\n store=store # ← Now using Hindsight with enhanced capabilities!\n)\n\nprint(\"✅ Agent created with Hindsight-powered memory\")",
"execution_count": null,
"outputs": []
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "import os\nfrom langmem import create_manage_memory_tool, create_search_memory_tool\nfrom langgraph.prebuilt import create_react_agent\nfrom hindsight_langmem import HindsightStore # ← Only import change!\n\n# Replace InMemoryStore with HindsightStore\nbase_url = os.getenv(\"HINDSIGHT_API_URL\", \"http://localhost:8888\")\nstore = HindsightStore(base_url=base_url, default_agent_id=\"my_agent\") # ← One line change!\n\n# Everything else stays exactly the same\nagent = create_react_agent(\n \"anthropic:claude-3-5-sonnet-latest\",\n tools=[\n create_manage_memory_tool(namespace=(\"memories\",)),\n create_search_memory_tool(namespace=(\"memories\",)),\n ],\n store=store # ← Now using Hindsight with enhanced capabilities!\n)\n\nprint(\"✅ Agent created with Hindsight-powered memory\")"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Example: Conversational Memory in Action"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import time\n",
"\n",
"# Store information\n",
"result1 = agent.invoke({\n",
" \"messages\": [{\n",
" \"role\": \"user\",\n",
" \"content\": \"\"\"Remember: I'm David, a software engineer working on AI projects. \n",
" I love Python and machine learning. Currently building a chatbot with LangGraph.\"\"\"\n",
" }]\n",
"})\n",
"print(\"Agent:\", result1[\"messages\"][-1].content)\n",
"\n",
"time.sleep(2)\n",
"\n",
"# Recall information\n",
"result2 = agent.invoke({\n",
" \"messages\": [{\"role\": \"user\", \"content\": \"What do you remember about me?\"}]\n",
"})\n",
"print(\"\\nAgent:\", result2[\"messages\"][-1].content)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": "## What Happens Behind the Scenes\n\nWhen your agent stores memories with Hindsight, automatically:\n\n1. **Fact Extraction**: Natural language → structured facts\n2. **Entity Recognition**: Identifies people, places, concepts\n3. **Semantic Indexing**: Spreading activation for better retrieval\n4. **Temporal Awareness**: Event dates tracked for time queries\n5. **Opinion Formation**: Agent develops perspectives over time\n6. **Personality Influence**: Memory retrieval shaped by personality traits\n\n**You use the standard LangGraph API - Hindsight does the rest!**"
},
{
"cell_type": "code",
"metadata": {},
"source": "# Different agents with different personalities\ncreative_store = HindsightStore(base_url=base_url, default_agent_id=\"creative_writer\")\nanalyst_store = HindsightStore(base_url=base_url, default_agent_id=\"data_analyst\")\n\ncreative_agent = create_react_agent(\n \"openai:gpt-4o\",\n tools=[\n create_manage_memory_tool(namespace=(\"creative\",)),\n create_search_memory_tool(namespace=(\"creative\",))\n ],\n store=creative_store\n)\n\nanalyst_agent = create_react_agent(\n \"openai:gpt-4o\",\n tools=[\n create_manage_memory_tool(namespace=(\"analysis\",)),\n create_search_memory_tool(namespace=(\"analysis\",))\n ],\n store=analyst_store\n)\n\nprint(\"✅ Two agents with isolated memories and distinct personalities\")",
"execution_count": null,
"outputs": []
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "# Different agents with different personalities\ncreative_store = HindsightStore(base_url=base_url, default_agent_id=\"creative_writer\")\nanalyst_store = HindsightStore(base_url=base_url, default_agent_id=\"data_analyst\")\n\ncreative_agent = create_react_agent(\n \"anthropic:claude-3-5-sonnet-latest\",\n tools=[\n create_manage_memory_tool(namespace=(\"creative\",)),\n create_search_memory_tool(namespace=(\"creative\",))\n ],\n store=creative_store\n)\n\nanalyst_agent = create_react_agent(\n \"anthropic:claude-3-5-sonnet-latest\",\n tools=[\n create_manage_memory_tool(namespace=(\"analysis\",)),\n create_search_memory_tool(namespace=(\"analysis\",))\n ],\n store=analyst_store\n)\n\nprint(\"✅ Two agents with isolated memories and distinct personalities\")"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "## Summary\n\n### The Change\n```python\n# Before\nstore = InMemoryStore()\n\n# After \nstore = HindsightStore(base_url=\"http://localhost:8888\", default_agent_id=\"my_agent\")\n```\n\n### What You Get\n- ✅ Semantic search with spreading activation\n- ✅ Automatic fact extraction from conversations\n- ✅ Entity recognition and linking\n- ✅ Temporal reasoning (time-aware queries)\n- ✅ Personality-driven memory retrieval\n- ✅ Opinion formation over time\n- ✅ Multi-agent support with isolated memories\n\n**Same LangGraph API. Smarter memory. Zero code changes (except the store line).**"
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.10"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
File diff suppressed because it is too large Load Diff
@@ -10,7 +10,7 @@ Features:
- Works with any LiteLLM-supported provider
- Multi-user support via separate bank_ids
- Per-call overrides via hindsight_* kwargs
- Document grouping for conversation threading
- Session grouping for conversation threading
- Direct recall API for manual memory queries
- Native client wrappers for OpenAI and Anthropic
- STRICT ERROR HANDLING: Raises HindsightError on any memory operation failure
@@ -22,41 +22,30 @@ Error Handling:
a HindsightError will be raised and propagate to your code.
API Structure:
1. configure() - Static settings (rarely change during session)
- hindsight_api_url, api_key, verbose
- injection_mode, excluded_models, store_conversations, inject_memories
1. configure() - All settings in one place
- Connection: hindsight_api_url, api_key
- Bank setup: mission, bank_name
- Behavior: verbose, sync_storage, excluded_models
- Per-call defaults: bank_id, session_id, budget, etc.
2. set_defaults() - Default values for per-call settings (required: bank_id)
- bank_id (REQUIRED), document_id, budget, fact_types
- max_memories, max_memory_tokens, use_reflect, reflect_include_facts
- include_entities (default True), trace (default False)
2. set_defaults() - Update per-call defaults after initial configuration
- bank_id, session_id, budget, fact_types, etc.
3. Per-call kwargs (hindsight_* prefix) - Override any default per-call
- hindsight_bank_id, hindsight_document_id, hindsight_budget, etc.
- hindsight_include_entities, hindsight_trace
4. set_bank_mission() - Set mission/instructions for a bank (for mental models)
- Can be called anytime, bank is auto-created if needed
- set_bank_background() is deprecated, use set_bank_mission() instead
- hindsight_bank_id, hindsight_session_id, hindsight_budget, etc.
Basic usage:
>>> import hindsight_litellm
>>> from hindsight_litellm import HindsightError
>>>
>>> # Configure static settings
>>> # Configure everything in one call
>>> hindsight_litellm.configure(
... hindsight_api_url="http://localhost:8888",
... bank_id="user-123",
... mission="Remember customer preferences and past interactions.",
... verbose=True,
... )
>>>
>>> # Set defaults (bank_id is required)
>>> hindsight_litellm.set_defaults(bank_id="user-123")
>>>
>>> # Optionally set bank mission (for mental models)
>>> hindsight_litellm.set_bank_mission(
... mission="This agent helps with customer support. Remember customer preferences."
... )
>>>
>>> # Enable memory integration
>>> hindsight_litellm.enable()
>>>
@@ -74,7 +63,7 @@ Basic usage:
... model="gpt-4",
... messages=[...],
... hindsight_bank_id="different-bank", # Override default bank_id
... hindsight_document_id="conv-123", # Set document_id for this call
... hindsight_session_id="conv-123", # Set session for this call
... )
Direct recall API:
@@ -112,7 +101,7 @@ Works with any LiteLLM-supported provider:
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Optional, List, Any
from typing import Optional, List
import threading
import logging
@@ -121,12 +110,10 @@ import litellm
from .config import (
configure,
set_defaults,
set_bank_mission,
get_config,
get_defaults,
is_configured,
reset_config,
set_document_id,
HindsightConfig,
HindsightDefaults,
MemoryInjectionMode,
@@ -190,12 +177,15 @@ class InjectionDebugInfo:
injected: Whether memories were actually injected into the prompt
error: Error message if injection failed (None on success)
"""
mode: str # "reflect" or "recall"
query: str
bank_id: str
memory_context: str # The formatted context that was injected
reflect_text: Optional[str] = None # Raw reflect response text
reflect_facts: Optional[List[dict]] = None # Facts used by reflect (when reflect_include_facts=True)
reflect_facts: Optional[List[dict]] = (
None # Facts used by reflect (when reflect_include_facts=True)
)
recall_results: Optional[List[dict]] = None # Raw recall results
results_count: int = 0
injected: bool = False
@@ -234,7 +224,11 @@ def clear_injection_debug() -> None:
_last_injection_debug = None
def _inject_memories(messages: List[dict], custom_query: Optional[str] = None, custom_reflect_context: Optional[str] = None) -> List[dict]:
def _inject_memories(
messages: List[dict],
custom_query: Optional[str] = None,
custom_reflect_context: Optional[str] = None,
) -> List[dict]:
"""Inject memories into messages list.
Returns the modified messages list with memories injected into the system message.
@@ -310,28 +304,39 @@ def _inject_memories(messages: List[dict], custom_query: Optional[str] = None, c
# Add response_schema for structured output
if defaults.reflect_response_schema:
reflect_kwargs["response_schema"] = defaults.reflect_response_schema
# Add tags filtering
if defaults.recall_tags:
reflect_kwargs["tags"] = defaults.recall_tags
reflect_kwargs["tags_match"] = defaults.recall_tags_match
# If reflect_include_facts is enabled, use the API directly to include facts
if defaults.reflect_include_facts:
from hindsight_client_api.models import reflect_request, reflect_include_options
from hindsight_client_api.models import (
reflect_request,
reflect_include_options,
)
request_obj = reflect_request.ReflectRequest(
include=reflect_include_options.ReflectIncludeOptions(facts={}),
**reflect_kwargs,
)
import asyncio
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = loop.run_until_complete(client._api.reflect(bank_id, request_obj))
result = loop.run_until_complete(
client._api.reflect(bank_id, request_obj)
)
# Extract facts from based_on
if hasattr(result, 'based_on') and result.based_on:
if hasattr(result, "based_on") and result.based_on:
reflect_facts = [
{
"text": f.text if hasattr(f, 'text') else str(f),
"type": getattr(f, 'type', None),
"context": getattr(f, 'context', None),
"text": f.text if hasattr(f, "text") else str(f),
"type": getattr(f, "type", None),
"context": getattr(f, "context", None),
}
for f in result.based_on
]
@@ -340,7 +345,7 @@ def _inject_memories(messages: List[dict], custom_query: Optional[str] = None, c
bank_id=bank_id,
**reflect_kwargs,
)
reflect_text = result.text if hasattr(result, 'text') else str(result)
reflect_text = result.text if hasattr(result, "text") else str(result)
if not reflect_text:
# Store debug info for empty result
@@ -358,31 +363,32 @@ def _inject_memories(messages: List[dict], custom_query: Optional[str] = None, c
return messages
results_count = 1 # reflect returns a single synthesized response
memory_context = (
"# Relevant Context from Memory\n"
f"{reflect_text}"
)
memory_context = f"# Relevant Context from Memory\n{reflect_text}"
else:
# Use recall API (original behavior)
result = client.recall(
bank_id=bank_id,
query=user_query,
budget=defaults.budget or "mid",
max_tokens=defaults.max_memory_tokens or 4096,
types=defaults.fact_types,
)
recall_kwargs = {
"bank_id": bank_id,
"query": user_query,
"budget": defaults.budget or "mid",
"max_tokens": defaults.max_memory_tokens or 4096,
"types": defaults.fact_types,
}
if defaults.recall_tags:
recall_kwargs["tags"] = defaults.recall_tags
recall_kwargs["tags_match"] = defaults.recall_tags_match
result = client.recall(**recall_kwargs)
# client.recall() returns a list directly, not an object with .results
if isinstance(result, list):
results = result
elif hasattr(result, 'results'):
elif hasattr(result, "results"):
results = result.results
else:
results = []
# Convert to dicts for debug info
recall_results = [
{
"text": r.text if hasattr(r, 'text') else str(r),
"type": getattr(r, 'type', 'world'),
"text": r.text if hasattr(r, "text") else str(r),
"type": getattr(r, "type", "world"),
}
for r in results
]
@@ -402,11 +408,13 @@ def _inject_memories(messages: List[dict], custom_query: Optional[str] = None, c
return messages
# Format memories (apply limit if set, otherwise use all)
results_to_use = results[:defaults.max_memories] if defaults.max_memories else results
results_to_use = (
results[: defaults.max_memories] if defaults.max_memories else results
)
memory_lines = []
for i, r in enumerate(results_to_use, 1):
text = r.text if hasattr(r, 'text') else str(r)
fact_type = getattr(r, 'type', 'world')
text = r.text if hasattr(r, "text") else str(r)
fact_type = getattr(r, "type", "world")
if text:
type_label = fact_type.upper() if fact_type else "MEMORY"
memory_lines.append(f"{i}. [{type_label}] {text}")
@@ -441,16 +449,13 @@ def _inject_memories(messages: List[dict], custom_query: Optional[str] = None, c
existing_content = msg.get("content", "")
updated_messages[i] = {
**msg,
"content": f"{existing_content}\n\n{memory_context}"
"content": f"{existing_content}\n\n{memory_context}",
}
found_system = True
break
if not found_system:
updated_messages.insert(0, {
"role": "system",
"content": memory_context
})
updated_messages.insert(0, {"role": "system", "content": memory_context})
# Store debug info when verbose
if config.verbose:
@@ -488,7 +493,9 @@ def _inject_memories(messages: List[dict], custom_query: Optional[str] = None, c
except Exception as e:
# Always set debug info on error when verbose mode is on
if config and config.verbose:
logging.getLogger("hindsight_litellm").warning(f"Failed to inject memories: {e}")
logging.getLogger("hindsight_litellm").warning(
f"Failed to inject memories: {e}"
)
_last_injection_debug = InjectionDebugInfo(
mode="reflect" if (defaults and defaults.use_reflect) else "recall",
query=user_query or "",
@@ -534,7 +541,11 @@ def _wrapped_completion(*args, **kwargs):
# Step 1: Inject memories (raises HindsightError on failure)
if config and config.inject_memories and messages:
try:
injected_messages = _inject_memories(messages, custom_query=custom_query, custom_reflect_context=custom_reflect_context)
injected_messages = _inject_memories(
messages,
custom_query=custom_query,
custom_reflect_context=custom_reflect_context,
)
kwargs["messages"] = injected_messages
except Exception as e:
raise HindsightError(f"Failed to inject memories: {e}") from e
@@ -577,7 +588,11 @@ async def _wrapped_acompletion(*args, **kwargs):
# Step 1: Inject memories (raises HindsightError on failure)
if config and config.inject_memories and messages:
try:
injected_messages = _inject_memories(messages, custom_query=custom_query, custom_reflect_context=custom_reflect_context)
injected_messages = _inject_memories(
messages,
custom_query=custom_query,
custom_reflect_context=custom_reflect_context,
)
kwargs["messages"] = injected_messages
except Exception as e:
raise HindsightError(f"Failed to inject memories: {e}") from e
@@ -719,6 +734,7 @@ def cleanup() -> None:
# Convenience wrappers - use hindsight_litellm.completion() directly
# =============================================================================
def _format_conversation_for_storage(
messages: List[dict],
response,
@@ -755,7 +771,9 @@ def _format_conversation_for_storage(
tc_strs.append(f"{tc.function.name}({tc.function.arguments})")
elif isinstance(tc, dict) and "function" in tc:
func = tc["function"]
tc_strs.append(f"{func.get('name', '')}({func.get('arguments', '')})")
tc_strs.append(
f"{func.get('name', '')}({func.get('arguments', '')})"
)
if tc_strs:
items.append(f"ASSISTANT_TOOL_CALLS: {'; '.join(tc_strs)}")
if content:
@@ -783,7 +801,9 @@ def _format_conversation_for_storage(
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
for tc in choice.message.tool_calls:
if hasattr(tc, "function"):
assistant_tool_calls.append(f"{tc.function.name}({tc.function.arguments})")
assistant_tool_calls.append(
f"{tc.function.name}({tc.function.arguments})"
)
if assistant_content:
items.append(f"ASSISTANT: {assistant_content}")
@@ -800,21 +820,93 @@ _pending_storage_errors: List[Exception] = []
_storage_error_lock = threading.Lock()
def _get_existing_document_content(
bank_id: str, document_id: str, verbose: bool
) -> Optional[str]:
"""Fetch existing document content for accumulation via low-level API.
Returns:
The existing document's original_text, or None if not found.
"""
import asyncio
config = get_config()
if not config:
return None
try:
import hindsight_client_api
from hindsight_client_api.api import documents_api
api_config = hindsight_client_api.Configuration(
host=config.hindsight_api_url, access_token=config.api_key
)
api_client = hindsight_client_api.ApiClient(api_config)
if config.api_key:
api_client.set_default_header("Authorization", f"Bearer {config.api_key}")
docs_api = documents_api.DocumentsApi(api_client)
async def _fetch():
try:
doc = await docs_api.get_document(bank_id, document_id)
return doc.original_text if doc else None
except Exception as e:
if "404" in str(e) or "Not Found" in str(e):
return None
raise
finally:
await api_client.close()
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
original_text = loop.run_until_complete(_fetch())
if original_text and verbose:
_storage_logger.debug(f"Fetched existing document: {document_id}")
return original_text
except Exception as e:
if verbose:
_storage_logger.debug(f"No existing document found: {e}")
return None
def _store_conversation_sync(
conversation_text: str,
bank_id: str,
document_id: Optional[str],
tags: Optional[List[str]],
model: str,
verbose: bool,
) -> None:
"""Actually store the conversation (runs in background thread)."""
"""Actually store the conversation (runs in background thread).
If document_id is set, fetches existing document content and appends
to accumulate the full conversation in one document.
"""
global _pending_storage_errors
try:
# If document_id is set, fetch existing content and append
content_to_store = conversation_text
if document_id:
existing_content = _get_existing_document_content(
bank_id, document_id, verbose
)
if existing_content:
content_to_store = f"{existing_content}\n\n{conversation_text}"
if verbose:
_storage_logger.debug(
f"Appending to existing document: {document_id}"
)
retain(
content=conversation_text,
content=content_to_store,
bank_id=bank_id,
context=f"conversation:litellm:{model}",
document_id=document_id,
tags=tags,
metadata={"source": "litellm", "model": model},
)
if verbose:
@@ -887,11 +979,26 @@ def _store_conversation(
# Sync mode: run directly and raise errors
if config.sync_storage:
try:
# If document_id is set, fetch existing content and append
content_to_store = conversation_text
if defaults.effective_document_id:
existing_content = _get_existing_document_content(
defaults.bank_id, defaults.effective_document_id, config.verbose
)
if existing_content:
content_to_store = f"{existing_content}\n\n{conversation_text}"
if config.verbose:
_storage_logger.debug(
f"Appending to existing document: "
f"{defaults.effective_document_id}"
)
retain(
content=conversation_text,
content=content_to_store,
bank_id=defaults.bank_id,
context=f"conversation:litellm:{model}",
document_id=defaults.document_id,
document_id=defaults.effective_document_id,
tags=defaults.tags,
metadata={"source": "litellm", "model": model},
)
if config.verbose:
@@ -906,7 +1013,8 @@ def _store_conversation(
args=(
conversation_text,
defaults.bank_id,
defaults.document_id,
defaults.effective_document_id,
defaults.tags,
model,
config.verbose,
),
@@ -981,7 +1089,11 @@ def completion(*args, **kwargs):
# Step 1: Inject memories (raises HindsightError on failure)
if config and config.inject_memories and messages:
try:
injected_messages = _inject_memories(messages, custom_query=custom_query, custom_reflect_context=custom_reflect_context)
injected_messages = _inject_memories(
messages,
custom_query=custom_query,
custom_reflect_context=custom_reflect_context,
)
kwargs["messages"] = injected_messages
except Exception as e:
raise HindsightError(f"Failed to inject memories: {e}") from e
@@ -1052,7 +1164,11 @@ async def acompletion(*args, **kwargs):
# Step 1: Inject memories (raises HindsightError on failure)
if config and config.inject_memories and messages:
try:
injected_messages = _inject_memories(messages, custom_query=custom_query, custom_reflect_context=custom_reflect_context)
injected_messages = _inject_memories(
messages,
custom_query=custom_query,
custom_reflect_context=custom_reflect_context,
)
kwargs["messages"] = injected_messages
except Exception as e:
raise HindsightError(f"Failed to inject memories: {e}") from e
@@ -1161,6 +1277,7 @@ def hindsight_memory(
if previous_defaults:
set_defaults(
bank_id=previous_defaults.bank_id,
session_id=previous_defaults.session_id,
document_id=previous_defaults.document_id,
budget=previous_defaults.budget,
fact_types=previous_defaults.fact_types,
@@ -1193,12 +1310,16 @@ __all__ = [
"recall",
"arecall",
"RecallResult",
"RecallResponse",
"RecallDebugInfo",
"reflect",
"areflect",
"ReflectResult",
"ReflectDebugInfo",
"retain",
"aretain",
"RetainResult",
"RetainDebugInfo",
# Native client wrappers
"wrap_openai",
"wrap_anthropic",
@@ -1209,8 +1330,6 @@ __all__ = [
"get_defaults",
"is_configured",
"reset_config",
"set_document_id",
"set_bank_mission",
"HindsightConfig",
"HindsightDefaults",
"MemoryInjectionMode",
@@ -10,7 +10,6 @@ when the hindsight_client's async methods are called from LiteLLM callbacks.
import logging
import fnmatch
import hashlib
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set
import asyncio
import threading
@@ -22,21 +21,24 @@ from litellm.types.utils import ModelResponse
from .config import (
get_config,
get_defaults,
is_configured,
HindsightConfig,
HindsightDefaults,
HindsightCallSettings,
HindsightDefaults, # Backward compatibility alias
MemoryInjectionMode,
_merge_call_settings,
)
# Use requests for sync HTTP calls to avoid async event loop issues
try:
import requests
HAS_REQUESTS = True
except ImportError:
HAS_REQUESTS = False
try:
import httpx
HAS_HTTPX = True
except ImportError:
HAS_HTTPX = False
@@ -51,11 +53,14 @@ class HindsightError(Exception):
This is raised when inject_memories=True and recall fails,
or when store_conversations=True and store fails.
"""
pass
# Thread pool for running async operations in background
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=4, thread_name_prefix="hindsight-")
_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=4, thread_name_prefix="hindsight-"
)
class HindsightCallback(CustomLogger):
@@ -93,42 +98,20 @@ class HindsightCallback(CustomLogger):
self._recent_hashes: Set[str] = set()
self._max_hash_cache = 1000
def _get_effective_settings(self, kwargs: Dict[str, Any]) -> HindsightDefaults:
def _get_effective_settings(self, kwargs: Dict[str, Any]) -> HindsightCallSettings:
"""Get effective per-call settings from kwargs with fallback to defaults.
Per-call kwargs (hindsight_*) override defaults. Supported kwargs:
- hindsight_bank_id: Override bank_id
- hindsight_document_id: Override document_id
- hindsight_budget: Override budget
- hindsight_fact_types: Override fact_types
- hindsight_max_memories: Override max_memories
- hindsight_max_memory_tokens: Override max_memory_tokens
- hindsight_use_reflect: Override use_reflect
- hindsight_reflect_include_facts: Override reflect_include_facts
- hindsight_context: Override reflect_context
- hindsight_response_schema: Override reflect_response_schema
- hindsight_include_entities: Override include_entities
- hindsight_trace: Override trace
Uses the unified _merge_call_settings function which automatically handles
all HindsightCallSettings fields. When a new field is added to the dataclass,
it automatically works here.
Per-call kwargs use hindsight_* prefix (e.g., hindsight_bank_id, hindsight_budget).
Note: hindsight_query is handled separately in log_pre_api_call since it's
always per-call (no sensible default for dynamic queries).
"""
defaults = get_defaults() or HindsightDefaults()
return HindsightDefaults(
bank_id=kwargs.get("hindsight_bank_id", defaults.bank_id),
document_id=kwargs.get("hindsight_document_id", defaults.document_id),
budget=kwargs.get("hindsight_budget", defaults.budget),
fact_types=kwargs.get("hindsight_fact_types", defaults.fact_types),
max_memories=kwargs.get("hindsight_max_memories", defaults.max_memories),
max_memory_tokens=kwargs.get("hindsight_max_memory_tokens", defaults.max_memory_tokens),
use_reflect=kwargs.get("hindsight_use_reflect", defaults.use_reflect),
reflect_include_facts=kwargs.get("hindsight_reflect_include_facts", defaults.reflect_include_facts),
reflect_context=kwargs.get("hindsight_context", defaults.reflect_context),
reflect_response_schema=kwargs.get("hindsight_response_schema", defaults.reflect_response_schema),
include_entities=kwargs.get("hindsight_include_entities", defaults.include_entities),
trace=kwargs.get("hindsight_trace", defaults.trace),
)
defaults = get_defaults() or HindsightCallSettings()
return _merge_call_settings(defaults, kwargs)
def _get_http_session(self):
"""Get or create a requests Session (thread-safe)."""
@@ -159,7 +142,9 @@ class HindsightCallback(CustomLogger):
try:
if HAS_REQUESTS:
response = session.post(url, json=json_data, headers=headers, timeout=30)
response = session.post(
url, json=json_data, headers=headers, timeout=30
)
response.raise_for_status()
return response.json()
elif HAS_HTTPX:
@@ -167,7 +152,9 @@ class HindsightCallback(CustomLogger):
response.raise_for_status()
return response.json()
else:
raise HindsightError("No HTTP client available (install requests or httpx)")
raise HindsightError(
"No HTTP client available (install requests or httpx)"
)
except HindsightError:
raise
except Exception as e:
@@ -175,6 +162,46 @@ class HindsightCallback(CustomLogger):
logger.error(f"HTTP POST failed: {e}")
raise HindsightError(f"Hindsight API request failed: {e}") from e
def _http_get(
self, url: str, config: HindsightConfig
) -> Optional[dict]:
"""Make a synchronous HTTP GET request.
Returns:
Response JSON dict, or None if 404 (not found).
Raises:
HindsightError: If the request fails for reasons other than 404.
"""
session = self._get_http_session()
headers = {}
if config.api_key:
headers["Authorization"] = f"Bearer {config.api_key}"
try:
if HAS_REQUESTS:
response = session.get(url, headers=headers, timeout=30)
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
elif HAS_HTTPX:
response = session.get(url, headers=headers)
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
else:
raise HindsightError(
"No HTTP client available (install requests or httpx)"
)
except HindsightError:
raise
except Exception as e:
if config.verbose:
logger.error(f"HTTP GET failed: {e}")
raise HindsightError(f"Hindsight API request failed: {e}") from e
def _should_skip_model(self, model: str, config: HindsightConfig) -> bool:
"""Check if this model should be excluded from interception."""
for pattern in config.excluded_models:
@@ -237,10 +264,7 @@ class HindsightCallback(CustomLogger):
return False
def _format_memories(
self,
results: List[Any],
settings: HindsightDefaults,
config: HindsightConfig
self, results: List[Any], settings: HindsightDefaults, config: HindsightConfig
) -> str:
"""Format memory recall results into a context string.
@@ -251,14 +275,16 @@ class HindsightCallback(CustomLogger):
return ""
# Apply limit if set, otherwise use all results
results_to_use = results[:settings.max_memories] if settings.max_memories else results
results_to_use = (
results[: settings.max_memories] if settings.max_memories else results
)
memory_lines = []
for i, result in enumerate(results_to_use, 1):
# Handle both RecallResult objects and dicts
if hasattr(result, 'text'):
if hasattr(result, "text"):
text = result.text or ""
fact_type = getattr(result, 'type', 'world') or "world"
weight = getattr(result, 'weight', 0.0) or 0.0
fact_type = getattr(result, "type", "world") or "world"
weight = getattr(result, "weight", 0.0) or 0.0
else:
text = result.get("text", "")
fact_type = result.get("type", result.get("fact_type", "world"))
@@ -301,15 +327,12 @@ class HindsightCallback(CustomLogger):
existing_content = msg.get("content", "")
updated_messages[i] = {
**msg,
"content": f"{existing_content}\n\n{memory_context}"
"content": f"{existing_content}\n\n{memory_context}",
}
return updated_messages
# No system message found, prepend one
updated_messages.insert(0, {
"role": "system",
"content": memory_context
})
updated_messages.insert(0, {"role": "system", "content": memory_context})
elif config.injection_mode == MemoryInjectionMode.PREPEND_USER:
# Find the last user message and prepend context
@@ -319,17 +342,14 @@ class HindsightCallback(CustomLogger):
if isinstance(original_content, str):
updated_messages[i] = {
**updated_messages[i],
"content": f"{memory_context}\n\n---\n\n{original_content}"
"content": f"{memory_context}\n\n---\n\n{original_content}",
}
break
return updated_messages
def _recall_memories_sync(
self,
query: str,
settings: HindsightDefaults,
config: HindsightConfig
self, query: str, settings: HindsightDefaults, config: HindsightConfig
) -> List[Dict[str, Any]]:
"""Recall relevant memories from Hindsight (sync) using direct HTTP.
@@ -352,6 +372,9 @@ class HindsightCallback(CustomLogger):
}
if settings.fact_types:
request_data["types"] = settings.fact_types
if settings.recall_tags:
request_data["tags"] = settings.recall_tags
request_data["tags_match"] = settings.recall_tags_match
# Add trace parameter for debugging
if settings.trace:
@@ -376,10 +399,7 @@ class HindsightCallback(CustomLogger):
raise HindsightError(f"Memory recall failed: {e}") from e
async def _recall_memories_async(
self,
query: str,
settings: HindsightDefaults,
config: HindsightConfig
self, query: str, settings: HindsightDefaults, config: HindsightConfig
) -> List[Any]:
"""Recall relevant memories from Hindsight (async).
@@ -390,17 +410,13 @@ class HindsightCallback(CustomLogger):
"""
loop = asyncio.get_running_loop()
results = await loop.run_in_executor(
_executor,
lambda: self._recall_memories_sync(query, settings, config)
_executor, lambda: self._recall_memories_sync(query, settings, config)
)
return results if isinstance(results, list) else []
def _reflect_sync(
self,
query: str,
settings: HindsightDefaults,
config: HindsightConfig
self, query: str, settings: HindsightDefaults, config: HindsightConfig
) -> Optional[str]:
"""Generate a reflection response from Hindsight (sync) using direct HTTP.
@@ -433,6 +449,11 @@ class HindsightCallback(CustomLogger):
if settings.reflect_response_schema:
request_data["response_schema"] = settings.reflect_response_schema
# Add tags filtering
if settings.recall_tags:
request_data["tags"] = settings.recall_tags
request_data["tags_match"] = settings.recall_tags_match
# Add include options for facts if requested
if settings.reflect_include_facts:
request_data["include"] = {"facts": {}}
@@ -444,6 +465,7 @@ class HindsightCallback(CustomLogger):
if settings.reflect_response_schema and "structured_output" in response:
# Return structured output as JSON string for injection
import json
return json.dumps(response["structured_output"], indent=2)
# Otherwise return text response
return response.get("text", "")
@@ -454,10 +476,7 @@ class HindsightCallback(CustomLogger):
raise HindsightError(f"Reflect failed: {e}") from e
async def _reflect_async(
self,
query: str,
settings: HindsightDefaults,
config: HindsightConfig
self, query: str, settings: HindsightDefaults, config: HindsightConfig
) -> Optional[str]:
"""Generate a reflection response from Hindsight (async).
@@ -471,8 +490,7 @@ class HindsightCallback(CustomLogger):
"""
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
_executor,
lambda: self._reflect_sync(query, settings, config)
_executor, lambda: self._reflect_sync(query, settings, config)
)
return result
@@ -527,7 +545,9 @@ class HindsightCallback(CustomLogger):
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
for tc in choice.message.tool_calls:
if hasattr(tc, "function"):
assistant_tool_calls.append(f"{tc.function.name}({tc.function.arguments})")
assistant_tool_calls.append(
f"{tc.function.name}({tc.function.arguments})"
)
# Skip if no content AND no tool calls - nothing to store
if not assistant_output and not assistant_tool_calls:
@@ -550,7 +570,6 @@ class HindsightCallback(CustomLogger):
# Handle tool messages (results from tool calls)
if role == "TOOL":
tool_call_id = msg.get("tool_call_id", "")
items.append(f"TOOL_RESULT: {content}")
continue
@@ -563,7 +582,9 @@ class HindsightCallback(CustomLogger):
tc_strs.append(f"{tc.function.name}({tc.function.arguments})")
elif isinstance(tc, dict) and "function" in tc:
func = tc["function"]
tc_strs.append(f"{func.get('name', '')}({func.get('arguments', '')})")
tc_strs.append(
f"{func.get('name', '')}({func.get('arguments', '')})"
)
if tc_strs:
items.append(f"ASSISTANT_TOOL_CALLS: {'; '.join(tc_strs)}")
if content:
@@ -605,7 +626,30 @@ class HindsightCallback(CustomLogger):
# Build the full conversation as a single item for now
# (Future: could store each message as separate item in same document)
conversation_text = "\n\n".join(items)
new_conversation_text = "\n\n".join(items)
# If document_id is set, fetch existing content and append
# This ensures the full conversation accumulates in one document
conversation_text = new_conversation_text
if settings.effective_document_id:
try:
doc_url = (
f"{config.hindsight_api_url}/v1/default/banks/{bank_id}"
f"/documents/{settings.effective_document_id}"
)
existing_doc = self._http_get(doc_url, config)
if existing_doc and existing_doc.get("original_text"):
conversation_text = (
f"{existing_doc['original_text']}\n\n{new_conversation_text}"
)
if config.verbose:
logger.debug(
f"Appending to existing document: "
f"{settings.effective_document_id}"
)
except Exception as e:
if config.verbose:
logger.debug(f"No existing document found, creating new: {e}")
# Build metadata
metadata = {
@@ -620,15 +664,17 @@ class HindsightCallback(CustomLogger):
url = f"{config.hindsight_api_url}/v1/default/banks/{bank_id}/memories"
item_data = {
"content": conversation_text,
"context": f"conversation:litellm:{model}",
"metadata": metadata,
"document_id": settings.effective_document_id, # Group by session/document
}
if settings.tags:
item_data["tags"] = settings.tags
request_data = {
"items": [
{
"content": conversation_text,
"context": f"conversation:litellm:{model}",
"metadata": metadata,
"document_id": settings.document_id, # Group by document
}
],
"items": [item_data],
}
try:
@@ -660,7 +706,7 @@ class HindsightCallback(CustomLogger):
_executor,
lambda: self._store_conversation_sync(
messages, response, model, settings, config
)
),
)
# ========== LiteLLM CustomLogger Interface ==========
@@ -709,10 +755,7 @@ class HindsightCallback(CustomLogger):
return
# Format reflect response as context
memory_context = (
"# Relevant Context from Memory\n"
f"{reflect_response}"
)
memory_context = f"# Relevant Context from Memory\n{reflect_response}"
else:
# Use recall API for raw fact retrieval
memories = self._recall_memories_sync(user_query, settings, config)
@@ -778,10 +821,7 @@ class HindsightCallback(CustomLogger):
return
# Format reflect response as context
memory_context = (
"# Relevant Context from Memory\n"
f"{reflect_response}"
)
memory_context = f"# Relevant Context from Memory\n{reflect_response}"
else:
# Use recall API for raw fact retrieval
memories = await self._recall_memories_async(user_query, settings, config)
@@ -865,7 +905,9 @@ class HindsightCallback(CustomLogger):
return
# Store the conversation
await self._store_conversation_async(messages, response_obj, model, settings, config)
await self._store_conversation_async(
messages, response_obj, model, settings, config
)
def log_failure_event(
self,
@@ -2,23 +2,22 @@
This module provides a clean API for configuring Hindsight integration:
1. configure() - Static settings that rarely change during a session
- API URL, authentication, logging, injection mode, etc.
1. configure() - Connection settings + default per-call settings
- API URL, authentication, and default values for all per-call settings
2. set_defaults() - Default values for per-call settings
- bank_id, document_id, budget, fact_types, etc.
- These are used when per-call kwargs are not provided
2. set_defaults() - Update default values for per-call settings
- Convenience function to update defaults without reconfiguring connection
3. Per-call kwargs (hindsight_* prefix) - Override any default per-call
- hindsight_bank_id, hindsight_document_id, etc.
3. Per-call kwargs (hindsight_* prefix) - Override any setting per-call
- hindsight_bank_id, hindsight_budget, hindsight_inject_memories, etc.
4. set_bank_mission() - Set the mission for a memory bank (for mental models)
"""
import os
from typing import Optional, List, Any, Dict
from dataclasses import dataclass, field
from dataclasses import asdict, dataclass, field, fields
from enum import Enum
from typing import Any, Dict, List, Optional
# Default Hindsight API URL (production)
DEFAULT_HINDSIGHT_API_URL = "https://api.hindsight.vectorize.io"
@@ -31,127 +30,262 @@ class MemoryInjectionMode(str, Enum):
Use inject_memories=False if you don't want memory injection.
"""
SYSTEM_MESSAGE = "system_message" # Add to/create system message
PREPEND_USER = "prepend_user" # Prepend to last user message
@dataclass
class HindsightConfig:
"""Static configuration for Hindsight integration with LiteLLM.
class HindsightCallSettings:
"""Unified settings for Hindsight memory operations.
These settings typically don't change during a session.
All fields here can be:
- Set as defaults via configure() or set_defaults()
- Overridden per-call via hindsight_* kwargs (e.g., hindsight_bank_id="other")
To add a new setting, just add a field here - it automatically works everywhere.
Attributes:
hindsight_api_url: URL of the Hindsight API server
(default: https://api.hindsight.vectorize.io)
bank_id: Memory bank ID for memory operations (default: "default").
For multi-user support, use different bank_ids per user (e.g., f"user-{user_id}")
api_key: API key for Hindsight authentication. If not provided,
reads from HINDSIGHT_API_KEY environment variable.
bank_id: Memory bank ID for operations. Use different bank_ids per user
for multi-user support (e.g., f"user-{user_id}")
session_id: Session ID for grouping conversations (maps to Hindsight's
document_id). Use this to group related messages in a conversation.
When set, Hindsight uses upsert behavior (same session = replace).
document_id: DEPRECATED - Use session_id instead. Kept for backward
compatibility. If both are set, session_id takes precedence.
store_conversations: Whether to store conversations to Hindsight
inject_memories: Whether to inject relevant memories into prompts
injection_mode: How to inject memories (system_message or prepend_user)
excluded_models: List of model patterns to exclude from interception
verbose: Enable verbose logging
sync_storage: If True, storage runs synchronously and raises errors immediately.
If False (default), storage runs in background thread for better performance.
"""
hindsight_api_url: str = DEFAULT_HINDSIGHT_API_URL
bank_id: str = DEFAULT_BANK_ID
api_key: Optional[str] = None
store_conversations: bool = True
inject_memories: bool = True
injection_mode: MemoryInjectionMode = MemoryInjectionMode.SYSTEM_MESSAGE
excluded_models: List[str] = field(default_factory=list)
verbose: bool = False
sync_storage: bool = False
@dataclass
class HindsightDefaults:
"""Default values for per-call settings.
These can be overridden on a per-call basis using hindsight_* kwargs.
Attributes:
bank_id: Memory bank ID for memory operations
document_id: Optional document ID for grouping stored conversations
budget: Budget level for memory recall (low, mid, high)
fact_types: List of fact types to filter recall (world, experience, opinion, observation)
max_memories: Maximum number of memories to inject (None = no limit)
max_memory_tokens: Maximum tokens for injected memory context
use_reflect: Use reflect API instead of recall for memory injection
reflect_include_facts: Include facts used by reflect in debug info
reflect_context: Additional context for reflect reasoning (does not affect retrieval)
reflect_response_schema: JSON Schema for structured reflect output
fact_types: Filter by fact types (world, experience, opinion, observation)
max_memories: Maximum memories to inject (None = no limit)
max_memory_tokens: Maximum tokens for memory context
include_entities: Include entity observations in recall results
trace: Enable trace info for recall debugging
Note:
For custom queries, use the hindsight_query kwarg per-call instead of a default,
since queries typically need to be dynamic (e.g., include recipient name).
tags: Tags to apply when storing conversations. Use for visibility scoping
(e.g., ["user:alice", "session:123"]). Stored memories will have these tags.
recall_tags: Tags to filter by when recalling/reflecting memories. Only memories
matching these tags (based on recall_tags_match mode) will be retrieved.
recall_tags_match: How to match recall_tags. Options:
- "any": OR matching, includes untagged memories (default)
- "all": AND matching, includes untagged memories
- "any_strict": OR matching, excludes untagged memories
- "all_strict": AND matching, excludes untagged memories
use_reflect: Use reflect API instead of recall for memory injection
reflect_context: Context for reflect reasoning (shapes response, not retrieval)
reflect_response_schema: JSON Schema for structured reflect output
reflect_include_facts: Include facts used by reflect in debug info
query: Custom query for memory recall (if not set, extracts from user message)
verbose: Enable verbose logging
"""
# Memory bank settings
bank_id: Optional[str] = None
document_id: Optional[str] = None
session_id: Optional[str] = None # Primary - maps to Hindsight's document_id
document_id: Optional[str] = None # Deprecated - use session_id instead
# Feature toggles
store_conversations: bool = True
inject_memories: bool = True
injection_mode: MemoryInjectionMode = MemoryInjectionMode.SYSTEM_MESSAGE
# Recall settings
budget: str = "mid" # low, mid, high
fact_types: Optional[List[str]] = None # world, experience, opinion, observation
max_memories: Optional[int] = None # None = no limit
max_memory_tokens: int = 4096
include_entities: bool = True
trace: bool = False
# Tags for visibility scoping
tags: Optional[List[str]] = None # Tags applied when storing conversations
recall_tags: Optional[List[str]] = None # Tags to filter recall/reflect
recall_tags_match: str = "any" # any, all, any_strict, all_strict
# Reflect settings (alternative to recall)
use_reflect: bool = False
reflect_context: Optional[str] = None
reflect_response_schema: Optional[Dict[str, Any]] = None
reflect_include_facts: bool = False
reflect_context: Optional[str] = None # Context for reflect reasoning
reflect_response_schema: Optional[Dict[str, Any]] = None # JSON Schema for structured output
include_entities: bool = True # Include entity observations by default
trace: bool = False # Enable trace info for debugging
# Query override (if not set, extracts from last user message)
query: Optional[str] = None
# Logging
verbose: bool = False
@property
def effective_document_id(self) -> Optional[str]:
"""Get the effective document_id for Hindsight API calls.
Returns session_id if set, otherwise falls back to document_id.
This maps to Hindsight's document_id parameter for retain operations.
"""
return self.session_id if self.session_id is not None else self.document_id
def _merge_call_settings(
defaults: HindsightCallSettings, kwargs: Dict[str, Any]
) -> HindsightCallSettings:
"""Merge per-call kwargs (hindsight_*) with defaults.
This automatically handles all fields in HindsightCallSettings.
When a new field is added to the dataclass, it works here automatically.
Args:
defaults: The default settings
kwargs: The kwargs passed to the call, may contain hindsight_* overrides
Returns:
Merged settings with per-call values overriding defaults
"""
# Start with defaults as dict
merged = asdict(defaults)
# Get valid field names from the dataclass
valid_fields = {f.name for f in fields(HindsightCallSettings)}
# Override with hindsight_* kwargs
for key, value in kwargs.items():
if key.startswith("hindsight_"):
setting_name = key[len("hindsight_") :]
if setting_name in valid_fields:
merged[setting_name] = value
return HindsightCallSettings(**merged)
# Backward compatibility alias
HindsightDefaults = HindsightCallSettings
@dataclass
class HindsightConfig:
"""Connection-level configuration for Hindsight integration.
These are settings that require a new client connection to change:
- API URL and authentication
- Session-level settings (excluded_models, sync_storage)
Per-call settings (bank_id, budget, etc.) are in default_settings.
Attributes:
hindsight_api_url: URL of the Hindsight API server
api_key: API key for Hindsight authentication
excluded_models: List of model patterns to exclude from interception
sync_storage: If True, storage runs synchronously and raises errors immediately
default_settings: Default values for all per-call settings
"""
hindsight_api_url: str = DEFAULT_HINDSIGHT_API_URL
api_key: Optional[str] = None
excluded_models: List[str] = field(default_factory=list)
sync_storage: bool = False
default_settings: HindsightCallSettings = field(
default_factory=HindsightCallSettings
)
# Backward compatibility properties - delegate to default_settings
@property
def bank_id(self) -> Optional[str]:
return self.default_settings.bank_id
@property
def store_conversations(self) -> bool:
return self.default_settings.store_conversations
@property
def inject_memories(self) -> bool:
return self.default_settings.inject_memories
@property
def injection_mode(self) -> MemoryInjectionMode:
return self.default_settings.injection_mode
@property
def verbose(self) -> bool:
return self.default_settings.verbose
# Global instances
_global_config: Optional[HindsightConfig] = None
_global_defaults: Optional[HindsightDefaults] = None
def configure(
hindsight_api_url: Optional[str] = None,
bank_id: Optional[str] = None,
api_key: Optional[str] = None,
background: Optional[str] = None,
excluded_models: Optional[List[str]] = None,
sync_storage: bool = False,
# Bank setup (one-time)
mission: Optional[str] = None,
bank_name: Optional[str] = None,
# Per-call defaults (all HindsightCallSettings fields)
bank_id: Optional[str] = None,
session_id: Optional[str] = None,
document_id: Optional[str] = None, # Deprecated - use session_id
store_conversations: bool = True,
inject_memories: bool = True,
injection_mode: MemoryInjectionMode = MemoryInjectionMode.SYSTEM_MESSAGE,
excluded_models: Optional[List[str]] = None,
budget: str = "mid",
fact_types: Optional[List[str]] = None,
max_memories: Optional[int] = None,
max_memory_tokens: int = 4096,
include_entities: bool = True,
trace: bool = False,
tags: Optional[List[str]] = None,
recall_tags: Optional[List[str]] = None,
recall_tags_match: str = "any",
use_reflect: bool = False,
reflect_context: Optional[str] = None,
reflect_response_schema: Optional[Dict[str, Any]] = None,
reflect_include_facts: bool = False,
verbose: bool = False,
sync_storage: bool = False,
) -> HindsightConfig:
"""Configure static Hindsight integration settings for LiteLLM.
"""Configure Hindsight integration settings.
This sets up settings that typically don't change during a session.
For per-call settings like bank_id, use set_defaults() or per-call kwargs.
With sensible defaults, you can use minimal configuration:
configure() # Just set HINDSIGHT_API_KEY env var
enable()
Sets up connection settings and default values for per-call settings.
All per-call settings can be overridden using hindsight_* kwargs.
Args:
hindsight_api_url: URL of the Hindsight API server
(default: https://api.hindsight.vectorize.io)
bank_id: Memory bank ID for memory operations (default: "default").
For multi-user support, use different bank_ids per user (e.g., f"user-{user_id}")
api_key: API key for Hindsight authentication. If not provided,
reads from HINDSIGHT_API_KEY environment variable.
background: Instructions guiding what Hindsight should learn and remember.
bank_name: Optional display name for the bank.
store_conversations: Whether to store conversations to Hindsight
inject_memories: Whether to inject relevant memories into prompts
injection_mode: How to inject memories into the prompt
excluded_models: List of model patterns to exclude from interception
verbose: Enable verbose logging
sync_storage: If True, storage runs synchronously and raises errors immediately.
If False (default), storage runs in background for better performance.
Use get_pending_storage_errors() to check for async storage failures.
mission: Instructions guiding what Hindsight should learn and remember
(used for mental model generation).
bank_name: Optional display name for the bank.
# Per-call defaults (can be overridden with hindsight_* kwargs):
bank_id: Memory bank ID (default: "default"). For multi-user support,
use different bank_ids per user (e.g., f"user-{user_id}")
session_id: Session ID for grouping conversations. Maps to Hindsight's
document_id. When set, enables upsert behavior (same session = replace).
document_id: DEPRECATED - Use session_id instead.
store_conversations: Whether to store conversations (default: True)
inject_memories: Whether to inject memories (default: True)
injection_mode: How to inject memories (system_message or prepend_user)
budget: Recall budget level - low/mid/high (default: "mid")
fact_types: Filter by fact types (world/experience/opinion/observation)
max_memories: Max memories to inject (None = no limit)
max_memory_tokens: Max tokens for memory context (default: 4096)
include_entities: Include entity observations in recall (default: True)
trace: Enable trace info for debugging (default: False)
tags: Tags to apply when storing conversations (e.g., ["user:alice"])
recall_tags: Tags to filter by when recalling/reflecting memories
recall_tags_match: Tag matching mode - any/all/any_strict/all_strict (default: "any")
use_reflect: Use reflect API instead of recall (default: False)
reflect_context: Context for reflect reasoning
reflect_response_schema: JSON Schema for structured reflect output
reflect_include_facts: Include facts in reflect debug info (default: False)
verbose: Enable verbose logging (default: False)
Returns:
The configured HindsightConfig instance
@@ -163,39 +297,67 @@ def configure(
>>> configure()
>>> enable()
>>>
>>> # Or with custom settings
>>> # With per-call defaults
>>> configure(
... bank_id="user-123", # Per-user bank for multi-user support
... background="Remember user preferences and past interactions.",
... bank_id="user-123",
... budget="high",
... background="Remember user preferences.",
... )
>>> enable()
>>>
>>> # Override per-call:
>>> response = litellm.completion(
... model="gpt-4",
... messages=[...],
... hindsight_bank_id="other-user", # Override default
... )
"""
global _global_config
# Apply defaults
# Apply connection-level defaults
resolved_api_url = hindsight_api_url or DEFAULT_HINDSIGHT_API_URL
resolved_bank_id = bank_id or DEFAULT_BANK_ID
resolved_api_key = api_key or os.environ.get(HINDSIGHT_API_KEY_ENV)
resolved_bank_id = bank_id or DEFAULT_BANK_ID
_global_config = HindsightConfig(
hindsight_api_url=resolved_api_url,
# Build default settings
default_settings = HindsightCallSettings(
bank_id=resolved_bank_id,
api_key=resolved_api_key,
document_id=document_id,
session_id=session_id,
store_conversations=store_conversations,
inject_memories=inject_memories,
injection_mode=injection_mode,
excluded_models=excluded_models or [],
budget=budget,
fact_types=fact_types,
max_memories=max_memories,
max_memory_tokens=max_memory_tokens,
include_entities=include_entities,
trace=trace,
tags=tags,
recall_tags=recall_tags,
recall_tags_match=recall_tags_match,
use_reflect=use_reflect,
reflect_context=reflect_context,
reflect_response_schema=reflect_response_schema,
reflect_include_facts=reflect_include_facts,
verbose=verbose,
sync_storage=sync_storage,
)
# If background or bank_name is provided, create/update the bank
if background or bank_name:
_global_config = HindsightConfig(
hindsight_api_url=resolved_api_url,
api_key=resolved_api_key,
excluded_models=excluded_models or [],
sync_storage=sync_storage,
default_settings=default_settings,
)
# If mission or bank_name is provided, create/update the bank
if mission or bank_name:
_create_or_update_bank(
hindsight_api_url=resolved_api_url,
bank_id=resolved_bank_id,
name=bank_name,
mission=background,
mission=mission,
verbose=verbose,
api_key=resolved_api_key,
)
@@ -205,83 +367,125 @@ def configure(
def set_defaults(
bank_id: Optional[str] = None,
document_id: Optional[str] = None,
session_id: Optional[str] = None,
document_id: Optional[str] = None, # Deprecated - use session_id
store_conversations: Optional[bool] = None,
inject_memories: Optional[bool] = None,
injection_mode: Optional[MemoryInjectionMode] = None,
budget: Optional[str] = None,
fact_types: Optional[List[str]] = None,
max_memories: Optional[int] = None,
max_memory_tokens: Optional[int] = None,
use_reflect: Optional[bool] = None,
reflect_include_facts: Optional[bool] = None,
reflect_context: Optional[str] = None,
reflect_response_schema: Optional[Dict[str, Any]] = None,
include_entities: Optional[bool] = None,
trace: Optional[bool] = None,
) -> HindsightDefaults:
"""Set default values for per-call settings.
tags: Optional[List[str]] = None,
recall_tags: Optional[List[str]] = None,
recall_tags_match: Optional[str] = None,
use_reflect: Optional[bool] = None,
reflect_context: Optional[str] = None,
reflect_response_schema: Optional[Dict[str, Any]] = None,
reflect_include_facts: Optional[bool] = None,
verbose: Optional[bool] = None,
) -> HindsightCallSettings:
"""Update default values for per-call settings.
These defaults are used when per-call kwargs are not provided.
Any of these can be overridden on individual LLM calls using
Updates only the specified fields, preserving other defaults.
Any of these can be overridden on individual calls using
hindsight_* kwargs (e.g., hindsight_bank_id="other-bank").
Args:
bank_id: Default memory bank ID for memory operations
document_id: Default document ID for grouping stored conversations
budget: Default budget level for memory recall (low, mid, high)
fact_types: Default fact types to filter (world, experience, opinion, observation)
max_memories: Default max number of memories to inject
max_memory_tokens: Default max tokens for memory context
use_reflect: Default whether to use reflect API instead of recall
reflect_include_facts: Default whether to include facts in reflect debug info
reflect_context: Default context for reflect reasoning (shapes LLM response, not retrieval)
reflect_response_schema: Default JSON Schema for structured reflect output
include_entities: Default whether to include entity observations in recall (default True)
trace: Default whether to enable trace info for debugging (default False)
bank_id: Memory bank ID for memory operations
session_id: Session ID for grouping conversations. Maps to Hindsight's
document_id. When set, enables upsert behavior (same session = replace).
document_id: DEPRECATED - Use session_id instead.
store_conversations: Whether to store conversations
inject_memories: Whether to inject memories
injection_mode: How to inject memories (system_message or prepend_user)
budget: Budget level for memory recall (low, mid, high)
fact_types: Fact types to filter (world, experience, opinion, observation)
max_memories: Max number of memories to inject
max_memory_tokens: Max tokens for memory context
include_entities: Include entity observations in recall
trace: Enable trace info for debugging
tags: Tags to apply when storing conversations
recall_tags: Tags to filter by when recalling/reflecting memories
recall_tags_match: Tag matching mode - any/all/any_strict/all_strict
use_reflect: Use reflect API instead of recall
reflect_context: Context for reflect reasoning
reflect_response_schema: JSON Schema for structured reflect output
reflect_include_facts: Include facts in reflect debug info
verbose: Enable verbose logging
Returns:
The configured HindsightDefaults instance
Note:
For custom memory queries, use hindsight_query per-call instead of a default,
since queries typically need to be dynamic (e.g., include recipient name).
The updated HindsightCallSettings instance
Example:
>>> from hindsight_litellm import set_defaults
>>> set_defaults(
... bank_id="my-agent",
... budget="high",
... fact_types=["world", "opinion"],
... reflect_context="I am a delivery agent finding package recipients.",
... )
>>>
>>> # Override per-call with dynamic query:
>>> response = litellm.completion(
... model="gpt-4",
... messages=[...],
... hindsight_query=f"Where is {recipient_name} located?", # Dynamic query
... )
>>> from hindsight_litellm import configure, set_defaults
>>> configure()
>>> set_defaults(bank_id="my-agent", budget="high")
"""
global _global_defaults
global _global_config
# Get current defaults or create new
current = _global_defaults or HindsightDefaults()
# Ensure configure() was called
if _global_config is None:
# Auto-configure with defaults if not configured
configure()
# Update only provided values
_global_defaults = HindsightDefaults(
# Get current defaults
current = _global_config.default_settings
# Update only provided values using dataclass fields
updated_settings = HindsightCallSettings(
bank_id=bank_id if bank_id is not None else current.bank_id,
document_id=document_id if document_id is not None else current.document_id,
session_id=session_id if session_id is not None else current.session_id,
store_conversations=store_conversations
if store_conversations is not None
else current.store_conversations,
inject_memories=inject_memories
if inject_memories is not None
else current.inject_memories,
injection_mode=injection_mode
if injection_mode is not None
else current.injection_mode,
budget=budget if budget is not None else current.budget,
fact_types=fact_types if fact_types is not None else current.fact_types,
max_memories=max_memories if max_memories is not None else current.max_memories,
max_memory_tokens=max_memory_tokens if max_memory_tokens is not None else current.max_memory_tokens,
use_reflect=use_reflect if use_reflect is not None else current.use_reflect,
reflect_include_facts=reflect_include_facts if reflect_include_facts is not None else current.reflect_include_facts,
reflect_context=reflect_context if reflect_context is not None else current.reflect_context,
reflect_response_schema=reflect_response_schema if reflect_response_schema is not None else current.reflect_response_schema,
include_entities=include_entities if include_entities is not None else current.include_entities,
max_memory_tokens=max_memory_tokens
if max_memory_tokens is not None
else current.max_memory_tokens,
include_entities=include_entities
if include_entities is not None
else current.include_entities,
trace=trace if trace is not None else current.trace,
tags=tags if tags is not None else current.tags,
recall_tags=recall_tags if recall_tags is not None else current.recall_tags,
recall_tags_match=recall_tags_match
if recall_tags_match is not None
else current.recall_tags_match,
use_reflect=use_reflect if use_reflect is not None else current.use_reflect,
reflect_context=reflect_context
if reflect_context is not None
else current.reflect_context,
reflect_response_schema=reflect_response_schema
if reflect_response_schema is not None
else current.reflect_response_schema,
reflect_include_facts=reflect_include_facts
if reflect_include_facts is not None
else current.reflect_include_facts,
verbose=verbose if verbose is not None else current.verbose,
)
return _global_defaults
# Update the config's default settings
_global_config = HindsightConfig(
hindsight_api_url=_global_config.hindsight_api_url,
api_key=_global_config.api_key,
excluded_models=_global_config.excluded_models,
sync_storage=_global_config.sync_storage,
default_settings=updated_settings,
)
return updated_settings
def _create_or_update_bank(
@@ -312,12 +516,14 @@ def _create_or_update_bank(
)
if verbose:
import logging
logging.getLogger("hindsight_litellm").info(
f"Created/updated bank '{bank_id}' with mission"
)
except ImportError:
if verbose:
import logging
logging.getLogger("hindsight_litellm").warning(
"hindsight_client not installed. Cannot create bank. "
"Install with: pip install hindsight-client"
@@ -325,13 +531,14 @@ def _create_or_update_bank(
except Exception as e:
if verbose:
import logging
logging.getLogger("hindsight_litellm").warning(
f"Failed to create/update bank: {e}"
)
def get_config() -> Optional[HindsightConfig]:
"""Get the current global static configuration.
"""Get the current global configuration.
Returns:
The current HindsightConfig instance, or None if not configured
@@ -339,13 +546,15 @@ def get_config() -> Optional[HindsightConfig]:
return _global_config
def get_defaults() -> Optional[HindsightDefaults]:
def get_defaults() -> Optional[HindsightCallSettings]:
"""Get the current global defaults for per-call settings.
Returns:
The current HindsightDefaults instance, or None if not set
The current HindsightCallSettings instance, or None if not configured
"""
return _global_defaults
if _global_config is not None:
return _global_config.default_settings
return None
def is_configured() -> bool:
@@ -356,121 +565,12 @@ def is_configured() -> bool:
"""
if _global_config is not None and _global_config.bank_id:
return True
return (
_global_config is not None
and _global_defaults is not None
and _global_defaults.bank_id is not None
)
return False
def reset_config() -> None:
"""Reset all global configuration to None."""
global _global_config, _global_defaults
global _global_config
_global_config = None
_global_defaults = None
def set_document_id(document_id: str | None) -> None:
"""Set the document_id for grouping stored conversations.
This is a convenience function that updates just the document_id
in the defaults without requiring a full set_defaults() call.
When document_id is set, Hindsight uses upsert behavior:
- Same document_id = replace previous version
- Hindsight deduplicates facts automatically
Args:
document_id: Document ID for grouping conversations, or None to clear
Example:
>>> from hindsight_litellm import configure, set_defaults, enable, set_document_id
>>> configure(hindsight_api_url="http://localhost:8888")
>>> set_defaults(bank_id="my-agent")
>>> enable()
>>>
>>> # Start a new conversation
>>> set_document_id("conversation-123")
>>> response = litellm.completion(model="gpt-4", messages=[...])
>>>
>>> # Switch to another conversation
>>> set_document_id("conversation-456")
>>> response = litellm.completion(model="gpt-4", messages=[...])
"""
global _global_defaults
if _global_defaults is not None:
_global_defaults = HindsightDefaults(
bank_id=_global_defaults.bank_id,
document_id=document_id,
budget=_global_defaults.budget,
fact_types=_global_defaults.fact_types,
max_memories=_global_defaults.max_memories,
max_memory_tokens=_global_defaults.max_memory_tokens,
use_reflect=_global_defaults.use_reflect,
reflect_include_facts=_global_defaults.reflect_include_facts,
reflect_context=_global_defaults.reflect_context,
reflect_response_schema=_global_defaults.reflect_response_schema,
include_entities=_global_defaults.include_entities,
trace=_global_defaults.trace,
)
else:
# Create defaults with just document_id if none exist
_global_defaults = HindsightDefaults(document_id=document_id)
def set_bank_mission(
bank_id: Optional[str] = None,
mission: Optional[str] = None,
name: Optional[str] = None,
) -> None:
"""Set or update the mission for a memory bank.
The mission guides Hindsight on what information to learn and remember,
and is used for mental model generation. If the bank doesn't exist,
it will be auto-created.
Args:
bank_id: The bank ID to update. If not provided, uses the default bank_id.
mission: Instructions guiding what Hindsight should learn and remember.
name: Optional display name for the bank.
Raises:
ValueError: If no bank_id is provided and no default is set.
RuntimeError: If configure() hasn't been called.
Example:
>>> from hindsight_litellm import configure, set_defaults, set_bank_mission
>>> configure(hindsight_api_url="http://localhost:8888")
>>> set_defaults(bank_id="delivery-agent")
>>> set_bank_mission(
... mission="You are a delivery agent navigating a building. "
... "Remember employee locations, building layout, and optimal paths."
... )
"""
config = get_config()
if not config:
raise RuntimeError("Hindsight not configured. Call configure() first.")
# Determine which bank_id to use
effective_bank_id = bank_id
if effective_bank_id is None:
defaults = get_defaults()
if defaults:
effective_bank_id = defaults.bank_id
if not effective_bank_id:
raise ValueError(
"No bank_id provided and no default bank_id set. "
"Either pass bank_id or call set_defaults(bank_id=...) first."
)
# Use the Hindsight API to create/update the bank
_create_or_update_bank(
hindsight_api_url=config.hindsight_api_url,
bank_id=effective_bank_id,
name=name,
mission=mission,
verbose=config.verbose,
)
File diff suppressed because it is too large Load Diff
@@ -1,20 +1,24 @@
"""Unit tests for hindsight_litellm configuration and defaults."""
import os
from dataclasses import fields
from unittest.mock import MagicMock, patch
import pytest
from hindsight_litellm import configure, wrap_openai, wrap_anthropic, reset_config, is_configured
from hindsight_litellm import configure, wrap_openai, wrap_anthropic
from hindsight_litellm.config import (
DEFAULT_HINDSIGHT_API_URL,
DEFAULT_BANK_ID,
HINDSIGHT_API_KEY_ENV,
reset_config,
get_config,
is_configured,
)
from hindsight_litellm.wrappers import HindsightOpenAI, HindsightAnthropic
from hindsight_litellm.wrappers import (
HindsightOpenAI,
HindsightAnthropic,
HindsightCallSettings,
_merge_settings,
)
class TestDefaults:
@@ -107,7 +111,7 @@ class TestWrapOpenAI:
wrapped = wrap_openai(mock_client)
assert isinstance(wrapped, HindsightOpenAI)
assert wrapped._bank_id == DEFAULT_BANK_ID
assert wrapped._default_settings.bank_id == DEFAULT_BANK_ID
assert wrapped._api_url == DEFAULT_HINDSIGHT_API_URL
assert wrapped._api_key == "test-key"
@@ -117,7 +121,7 @@ class TestWrapOpenAI:
wrapped = wrap_openai(mock_client)
assert wrapped._bank_id == DEFAULT_BANK_ID
assert wrapped._default_settings.bank_id == DEFAULT_BANK_ID
assert wrapped._api_url == DEFAULT_HINDSIGHT_API_URL
def test_wrap_openai_reads_api_key_from_env(self):
@@ -140,10 +144,47 @@ class TestWrapOpenAI:
api_key="my-key",
)
assert wrapped._bank_id == "my-bank"
assert wrapped._default_settings.bank_id == "my-bank"
assert wrapped._api_url == "http://localhost:9999"
assert wrapped._api_key == "my-key"
def test_wrap_openai_all_settings_kwargs(self):
"""Test wrap_openai() accepts all HindsightCallSettings fields as kwargs."""
mock_client = MagicMock()
# Create kwargs with all settings
settings_kwargs = {
"bank_id": "test-bank",
"document_id": "test-doc",
"session_id": "test-session",
"store_conversations": False,
"inject_memories": False,
"budget": "high",
"fact_types": ["world", "experience"],
"max_memories": 10,
"max_memory_tokens": 2048,
"include_entities": False,
"trace": True,
"tags": ["user:alice", "session:123"],
"recall_tags": ["user:alice"],
"recall_tags_match": "all",
"use_reflect": True,
"reflect_context": "test context",
"reflect_response_schema": {"type": "object"},
"reflect_include_facts": True,
"query": "test query",
"verbose": True,
}
wrapped = wrap_openai(mock_client, **settings_kwargs)
# Verify all settings were applied
for field_name, expected_value in settings_kwargs.items():
actual_value = getattr(wrapped._default_settings, field_name)
assert actual_value == expected_value, (
f"Field {field_name}: expected {expected_value}, got {actual_value}"
)
class TestWrapAnthropic:
"""Test wrap_anthropic() function."""
@@ -164,7 +205,7 @@ class TestWrapAnthropic:
wrapped = wrap_anthropic(mock_client)
assert isinstance(wrapped, HindsightAnthropic)
assert wrapped._bank_id == DEFAULT_BANK_ID
assert wrapped._default_settings.bank_id == DEFAULT_BANK_ID
assert wrapped._api_url == DEFAULT_HINDSIGHT_API_URL
assert wrapped._api_key == "test-key"
@@ -174,7 +215,7 @@ class TestWrapAnthropic:
wrapped = wrap_anthropic(mock_client)
assert wrapped._bank_id == DEFAULT_BANK_ID
assert wrapped._default_settings.bank_id == DEFAULT_BANK_ID
assert wrapped._api_url == DEFAULT_HINDSIGHT_API_URL
def test_wrap_anthropic_reads_api_key_from_env(self):
@@ -197,6 +238,236 @@ class TestWrapAnthropic:
api_key="my-key",
)
assert wrapped._bank_id == "my-bank"
assert wrapped._default_settings.bank_id == "my-bank"
assert wrapped._api_url == "http://localhost:9999"
assert wrapped._api_key == "my-key"
def test_wrap_anthropic_all_settings_kwargs(self):
"""Test wrap_anthropic() accepts all HindsightCallSettings fields as kwargs."""
mock_client = MagicMock()
# Create kwargs with all settings
settings_kwargs = {
"bank_id": "test-bank",
"document_id": "test-doc",
"session_id": "test-session",
"store_conversations": False,
"inject_memories": False,
"budget": "high",
"fact_types": ["world", "experience"],
"max_memories": 10,
"max_memory_tokens": 2048,
"include_entities": False,
"trace": True,
"tags": ["user:alice", "session:123"],
"recall_tags": ["user:alice"],
"recall_tags_match": "all",
"use_reflect": True,
"reflect_context": "test context",
"reflect_response_schema": {"type": "object"},
"reflect_include_facts": True,
"query": "test query",
"verbose": True,
}
wrapped = wrap_anthropic(mock_client, **settings_kwargs)
# Verify all settings were applied
for field_name, expected_value in settings_kwargs.items():
actual_value = getattr(wrapped._default_settings, field_name)
assert actual_value == expected_value, (
f"Field {field_name}: expected {expected_value}, got {actual_value}"
)
class TestMergeSettings:
"""Test _merge_settings() function for per-call overrides."""
def test_merge_settings_no_overrides(self):
"""Test _merge_settings() returns defaults when no overrides provided."""
defaults = HindsightCallSettings(bank_id="default-bank", budget="mid")
kwargs = {"model": "gpt-4", "messages": []} # No hindsight_* kwargs
merged = _merge_settings(defaults, kwargs)
assert merged.bank_id == "default-bank"
assert merged.budget == "mid"
def test_merge_settings_with_overrides(self):
"""Test _merge_settings() applies hindsight_* overrides."""
defaults = HindsightCallSettings(bank_id="default-bank", budget="mid")
kwargs = {
"model": "gpt-4",
"hindsight_bank_id": "override-bank",
"hindsight_budget": "high",
}
merged = _merge_settings(defaults, kwargs)
assert merged.bank_id == "override-bank"
assert merged.budget == "high"
def test_merge_settings_partial_override(self):
"""Test _merge_settings() only overrides specified fields."""
defaults = HindsightCallSettings(
bank_id="default-bank",
budget="mid",
verbose=True,
max_memories=5,
)
kwargs = {
"hindsight_bank_id": "override-bank",
# budget, verbose, max_memories not overridden
}
merged = _merge_settings(defaults, kwargs)
assert merged.bank_id == "override-bank" # Overridden
assert merged.budget == "mid" # Default preserved
assert merged.verbose is True # Default preserved
assert merged.max_memories == 5 # Default preserved
def test_merge_settings_ignores_invalid_fields(self):
"""Test _merge_settings() ignores hindsight_* kwargs for non-existent fields."""
defaults = HindsightCallSettings(bank_id="default-bank")
kwargs = {
"hindsight_bank_id": "valid-override",
"hindsight_nonexistent_field": "should-be-ignored",
}
merged = _merge_settings(defaults, kwargs)
assert merged.bank_id == "valid-override"
assert not hasattr(merged, "nonexistent_field")
def test_merge_settings_all_fields(self):
"""Test _merge_settings() works with all HindsightCallSettings fields."""
# Create defaults with non-default values
defaults = HindsightCallSettings(
bank_id="default-bank",
document_id="default-doc",
session_id="default-session",
store_conversations=True,
inject_memories=True,
budget="mid",
fact_types=None,
max_memories=None,
max_memory_tokens=4096,
include_entities=True,
trace=False,
tags=None,
recall_tags=None,
recall_tags_match="any",
use_reflect=False,
reflect_context=None,
reflect_response_schema=None,
reflect_include_facts=False,
query=None,
verbose=False,
)
# Override every field
override_values = {
"hindsight_bank_id": "override-bank",
"hindsight_document_id": "override-doc",
"hindsight_session_id": "override-session",
"hindsight_store_conversations": False,
"hindsight_inject_memories": False,
"hindsight_budget": "high",
"hindsight_fact_types": ["world", "experience"],
"hindsight_max_memories": 10,
"hindsight_max_memory_tokens": 2048,
"hindsight_include_entities": False,
"hindsight_trace": True,
"hindsight_tags": ["user:bob", "org:acme"],
"hindsight_recall_tags": ["user:bob"],
"hindsight_recall_tags_match": "all",
"hindsight_use_reflect": True,
"hindsight_reflect_context": "test context",
"hindsight_reflect_response_schema": {"type": "object"},
"hindsight_reflect_include_facts": True,
"hindsight_query": "test query",
"hindsight_verbose": True,
}
merged = _merge_settings(defaults, override_values)
# Verify all fields were overridden
assert merged.bank_id == "override-bank"
assert merged.document_id == "override-doc"
assert merged.session_id == "override-session"
assert merged.store_conversations is False
assert merged.inject_memories is False
assert merged.budget == "high"
assert merged.fact_types == ["world", "experience"]
assert merged.max_memories == 10
assert merged.max_memory_tokens == 2048
assert merged.include_entities is False
assert merged.trace is True
assert merged.tags == ["user:bob", "org:acme"]
assert merged.recall_tags == ["user:bob"]
assert merged.recall_tags_match == "all"
assert merged.use_reflect is True
assert merged.reflect_context == "test context"
assert merged.reflect_response_schema == {"type": "object"}
assert merged.reflect_include_facts is True
assert merged.query == "test query"
assert merged.verbose is True
class TestHindsightCallSettingsConsistency:
"""Test that HindsightCallSettings works consistently across wrappers."""
def test_same_settings_for_openai_and_anthropic(self):
"""Test that OpenAI and Anthropic wrappers use the same HindsightCallSettings."""
mock_openai_client = MagicMock()
mock_anthropic_client = MagicMock()
settings_kwargs = {
"bank_id": "shared-bank",
"budget": "high",
"use_reflect": True,
"verbose": True,
}
openai_wrapped = wrap_openai(mock_openai_client, **settings_kwargs)
anthropic_wrapped = wrap_anthropic(mock_anthropic_client, **settings_kwargs)
# Both should have identical settings
assert (
openai_wrapped._default_settings.bank_id
== anthropic_wrapped._default_settings.bank_id
)
assert (
openai_wrapped._default_settings.budget
== anthropic_wrapped._default_settings.budget
)
assert (
openai_wrapped._default_settings.use_reflect
== anthropic_wrapped._default_settings.use_reflect
)
assert (
openai_wrapped._default_settings.verbose
== anthropic_wrapped._default_settings.verbose
)
def test_new_field_works_for_both_wrappers(self):
"""Test that all HindsightCallSettings fields work for both wrappers."""
mock_openai_client = MagicMock()
mock_anthropic_client = MagicMock()
# Get all fields from the dataclass
all_field_names = [f.name for f in fields(HindsightCallSettings)]
# Verify both wrappers can access all fields
openai_wrapped = wrap_openai(mock_openai_client)
anthropic_wrapped = wrap_anthropic(mock_anthropic_client)
for field_name in all_field_names:
# Both should have the field accessible
assert hasattr(openai_wrapped._default_settings, field_name), (
f"OpenAI wrapper missing field: {field_name}"
)
assert hasattr(anthropic_wrapped._default_settings, field_name), (
f"Anthropic wrapper missing field: {field_name}"
)
@@ -1,8 +1,6 @@
"""Integration tests for hindsight-litellm."""
import pytest
from unittest.mock import Mock, patch, MagicMock
from typing import List, Dict, Any
from hindsight_litellm import (
configure,
@@ -15,8 +13,6 @@ from hindsight_litellm import (
get_config,
is_configured,
reset_config,
HindsightConfig,
HindsightDefaults,
MemoryInjectionMode,
)
from hindsight_litellm.callbacks import HindsightCallback
@@ -129,11 +125,12 @@ class TestEnableDisable:
with pytest.raises(RuntimeError, match="not configured"):
enable()
def test_enable_without_bank_id_raises(self):
"""Test enable raises error without bank_id."""
def test_enable_with_default_bank_id_works(self):
"""Test enable works with default bank_id (no explicit bank_id required)."""
configure(hindsight_api_url="http://localhost:8888")
with pytest.raises(RuntimeError, match="bank_id not set"):
enable()
# Should work - configure() provides default bank_id="default"
enable()
assert is_enabled() is True
def test_enable_sets_enabled_flag(self):
"""Test enable sets the enabled flag."""
@@ -209,7 +206,10 @@ class TestCallback:
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{"type": "image_url", "image_url": {"url": "http://example.com/img.png"}},
{
"type": "image_url",
"image_url": {"url": "http://example.com/img.png"},
},
],
},
]
@@ -294,7 +294,9 @@ class TestCallback:
]
memory_context = "# Relevant Memories\n1. User is John"
result = callback._inject_memories_into_messages(messages, memory_context, config)
result = callback._inject_memories_into_messages(
messages, memory_context, config
)
assert len(result) == 2
assert result[0]["role"] == "system"
@@ -319,7 +321,9 @@ class TestCallback:
]
memory_context = "# Relevant Memories\n1. User is John"
result = callback._inject_memories_into_messages(messages, memory_context, config)
result = callback._inject_memories_into_messages(
messages, memory_context, config
)
assert len(result) == 2
assert result[0]["role"] == "system"
@@ -343,7 +347,9 @@ class TestCallback:
]
memory_context = "# Relevant Memories\n1. User is John"
result = callback._inject_memories_into_messages(messages, memory_context, config)
result = callback._inject_memories_into_messages(
messages, memory_context, config
)
assert len(result) == 1
assert result[0]["role"] == "user"
@@ -523,6 +529,7 @@ class TestFactTypes:
assert defaults.fact_types is None
class TestSetDefaults:
"""Tests for set_defaults functionality."""
-210
View File
@@ -1,210 +0,0 @@
# Hindsight-OpenAI
Drop-in replacement for OpenAI Python client with automatic Hindsight integration.
## Overview
`hindsight-openai` is a transparent wrapper around the official OpenAI Python client that automatically:
- 🧠 **Injects relevant memories** from your Hindsight system into conversations
- 💾 **Stores conversation history** to Hindsight for future retrieval
- 🔄 **Works seamlessly** with existing OpenAI code (just change the import)
-**Supports both sync and async** clients
## Installation
This package is part of the Hindsight workspace. Install from the root:
```bash
# From repository root
uv sync
# Or install just this package
cd hindsight-integrations/openai
uv pip install -e .
```
## Quick Start
### Basic Usage
```python
from hindsight_openai import configure, OpenAI
# Configure Hindsight integration once
configure(
hindsight_api_url="http://localhost:8888",
agent_id="my-agent",
store_conversations=True,
inject_memories=True,
)
# Use OpenAI client as normal - Hindsight integration happens automatically
client = OpenAI(api_key="sk-...")
response = client.chat.completions.create(
model="gpt-4",
messages=[
{"role": "user", "content": "What did we discuss about AI last week?"}
]
)
print(response.choices[0].message.content)
```
### Async Usage
```python
from hindsight_openai import configure, AsyncOpenAI
configure(
hindsight_api_url="http://localhost:8888",
agent_id="my-agent",
)
client = AsyncOpenAI(api_key="sk-...")
response = await client.chat.completions.create(
model="gpt-4",
messages=[
{"role": "user", "content": "Remind me about my preferences"}
]
)
```
## Configuration Options
The `configure()` function accepts the following parameters:
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `hindsight_api_url` | str | `"http://localhost:8888"` | URL of your Hindsight API server |
| `agent_id` | str | `None` | **Required.** Agent identifier for memory operations |
| `api_key` | str | `None` | Optional API key for Hindsight authentication |
| `store_conversations` | bool | `True` | Store conversations to Hindsight |
| `inject_memories` | bool | `True` | Inject relevant memories into prompts |
| `document_id` | str | `None` | Optional document ID for stored conversations |
| `enabled` | bool | `True` | Master switch to enable/disable Hindsight integration |
## How It Works
### Memory Injection
When `inject_memories=True`, the wrapper:
1. Extracts the user's query from the last message
2. Searches Hindsight for relevant memories using the query
3. Injects the top memories as a system message before the conversation
4. Sends the enhanced conversation to OpenAI
Example:
```python
# Your code:
messages = [
{"role": "user", "content": "What's my favorite programming language?"}
]
# What gets sent to OpenAI (automatically):
messages = [
{
"role": "system",
"content": "Relevant context from your memory:\n\n1. User prefers Python for its simplicity\n (Date: 2024-01-15)\n (Type: opinion)"
},
{"role": "user", "content": "What's my favorite programming language?"}
]
```
### Conversation Storage
When `store_conversations=True`, the wrapper:
1. Captures the conversation context (recent messages)
2. Captures the assistant's response
3. Stores the complete exchange to Hindsight asynchronously
4. Tags it with context `"openai_conversation"` for filtering
This creates a searchable memory of all your AI conversations.
## Advanced Usage
### Disable for Specific Requests
```python
from hindsight_openai import configure, OpenAI, reset_config
# Configure globally
configure(hindsight_api_url="http://localhost:8888", agent_id="agent-1")
client = OpenAI(api_key="sk-...")
# Normal request with Hindsight
response1 = client.chat.completions.create(...)
# Temporarily disable
reset_config()
response2 = client.chat.completions.create(...) # No Hindsight integration
# Re-enable
configure(hindsight_api_url="http://localhost:8888", agent_id="agent-1")
```
### Using Document ID
Group related conversations together using a document ID:
```python
configure(
hindsight_api_url="http://localhost:8888",
agent_id="my-agent",
document_id="meeting-2024-01-15", # All conversations tagged with this ID
)
client = OpenAI(api_key="sk-...")
# All these calls will be stored under the same document
response1 = client.chat.completions.create(...)
response2 = client.chat.completions.create(...)
```
### Cleanup
```python
from hindsight_openai import cleanup_interceptor
# Clean up resources when done
await cleanup_interceptor()
```
## Requirements
- Python >= 3.10
- openai >= 1.0.0
- httpx >= 0.23.0
- A running Hindsight API server
## Development
### Running Tests
```bash
uv run pytest tests
```
### Project Structure
```
hindsight-integrations/openai/
├── hindsight_openai/
│ ├── __init__.py # Main exports
│ ├── client.py # OpenAI client wrappers
│ ├── config.py # Global configuration
│ └── interceptor.py # Request/response interception logic
├── tests/
│ └── test_client.py # Test suite
├── pyproject.toml # Package configuration
└── README.md # This file
```
## License
Part of the Hindsight project.
@@ -1,71 +0,0 @@
"""Hindsight-OpenAI: Drop-in replacement for OpenAI client with automatic Hindsight integration.
This package provides a transparent wrapper around the OpenAI Python client that
automatically stores conversations and injects relevant memories from your Hindsight
memory system.
Basic usage:
>>> from hindsight_openai import configure, OpenAI
>>>
>>> # Configure Hindsight integration
>>> configure(
... hindsight_api_url="http://localhost:8888",
... agent_id="my-agent",
... store_conversations=True,
... inject_memories=True,
... )
>>>
>>> # Use OpenAI client as normal - Hindsight integration is automatic
>>> client = OpenAI(api_key="sk-...")
>>> response = client.chat.completions.create(
... model="gpt-4",
... messages=[{"role": "user", "content": "What did we discuss about AI?"}]
... )
Async usage:
>>> from hindsight_openai import configure, AsyncOpenAI
>>>
>>> configure(
... hindsight_api_url="http://localhost:8888",
... agent_id="my-agent",
... )
>>>
>>> client = AsyncOpenAI(api_key="sk-...")
>>> response = await client.chat.completions.create(
... model="gpt-4",
... messages=[{"role": "user", "content": "Tell me about quantum computing"}]
... )
Configuration options:
- hindsight_api_url: URL of your Hindsight API server
- agent_id: Agent identifier for memory operations
- api_key: Optional API key for Hindsight authentication
- store_conversations: Whether to store conversations to Hindsight (default: True)
- inject_memories: Whether to inject relevant memories (default: True)
- memory_search_budget: Number of memories to retrieve (default: 10)
- context_window: Number of conversation turns to store (default: 10)
- enabled: Master switch to disable Hindsight integration (default: True)
"""
from .client import OpenAI, AsyncOpenAI
from .config import (
configure,
get_config,
is_configured,
reset_config,
HindsightConfig,
)
from .interceptor import cleanup_interceptor
__version__ = "0.1.0"
__all__ = [
"OpenAI",
"AsyncOpenAI",
"configure",
"get_config",
"is_configured",
"reset_config",
"cleanup_interceptor",
"HindsightConfig",
]
@@ -1,169 +0,0 @@
"""Drop-in replacement for OpenAI client with Hindsight integration."""
import asyncio
from typing import Any, Optional, List, Dict
from openai import OpenAI as _OpenAI, AsyncOpenAI as _AsyncOpenAI
from .config import get_config, is_configured
from .interceptor import get_interceptor
class _CompletionsWrapper:
"""Wrapper for chat completions with Hindsight integration (sync)."""
def __init__(self, original_completions):
"""Initialize wrapper with original completions object."""
self._original = original_completions
def create(self, *args, **kwargs):
"""Create a chat completion with Hindsight integration."""
if not is_configured():
return self._original.create(*args, **kwargs)
config = get_config()
if not config.enabled:
return self._original.create(*args, **kwargs)
messages = kwargs.get("messages", [])
if not messages:
return self._original.create(*args, **kwargs)
# Check if an event loop is already running (e.g., in Jupyter)
try:
running_loop = asyncio.get_running_loop()
# If we get here, a loop is already running
print(
"Warning: Detected running event loop (Jupyter/IPython). "
"Hindsight features are disabled in sync mode. "
"Please use AsyncOpenAI for full functionality in notebooks."
)
return self._original.create(*args, **kwargs)
except RuntimeError:
# No loop running, we can create our own
pass
# Run async operations in a new event loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# Inject memories if configured
if config.inject_memories:
interceptor = get_interceptor()
modified_messages = loop.run_until_complete(
interceptor.inject_memories(messages, config)
)
kwargs["messages"] = modified_messages
# Call original OpenAI API
response = self._original.create(*args, **kwargs)
# Store conversation if configured
if config.store_conversations:
interceptor = get_interceptor()
loop.run_until_complete(
interceptor.store_conversation(
kwargs["messages"], response, config
)
)
return response
finally:
loop.close()
def __getattr__(self, name):
"""Delegate all other attributes to the original completions."""
return getattr(self._original, name)
class _AsyncCompletionsWrapper:
"""Wrapper for chat completions with Hindsight integration (async)."""
def __init__(self, original_completions):
"""Initialize wrapper with original completions object."""
self._original = original_completions
async def create(self, *args, **kwargs):
"""Create a chat completion with Hindsight integration."""
if not is_configured():
return await self._original.create(*args, **kwargs)
config = get_config()
if not config.enabled:
return await self._original.create(*args, **kwargs)
messages = kwargs.get("messages", [])
if not messages:
return await self._original.create(*args, **kwargs)
# Inject memories if configured
if config.inject_memories:
interceptor = get_interceptor()
modified_messages = await interceptor.inject_memories(messages, config)
kwargs["messages"] = modified_messages
# Call original OpenAI API
response = await self._original.create(*args, **kwargs)
# Store conversation if configured
if config.store_conversations:
interceptor = get_interceptor()
await interceptor.store_conversation(
kwargs["messages"], response, config
)
return response
def __getattr__(self, name):
"""Delegate all other attributes to the original completions."""
return getattr(self._original, name)
class OpenAI(_OpenAI):
"""Drop-in replacement for OpenAI client with Hindsight integration.
Usage:
>>> from hindsight_openai import configure, OpenAI
>>> configure(
... hindsight_api_url="http://localhost:8888",
... agent_id="my-agent",
... store_conversations=True,
... inject_memories=True,
... )
>>> client = OpenAI(api_key="sk-...")
>>> response = client.chat.completions.create(
... model="gpt-4",
... messages=[{"role": "user", "content": "Hello!"}]
... )
"""
def __init__(self, *args, **kwargs):
"""Initialize OpenAI client with Hindsight integration."""
super().__init__(*args, **kwargs)
# Wrap chat completions with our interceptor
self.chat.completions = _CompletionsWrapper(self.chat.completions)
class AsyncOpenAI(_AsyncOpenAI):
"""Drop-in replacement for AsyncOpenAI client with Hindsight integration.
Usage:
>>> from hindsight_openai import configure, AsyncOpenAI
>>> configure(
... hindsight_api_url="http://localhost:8888",
... agent_id="my-agent",
... store_conversations=True,
... inject_memories=True,
... )
>>> client = AsyncOpenAI(api_key="sk-...")
>>> response = await client.chat.completions.create(
... model="gpt-4",
... messages=[{"role": "user", "content": "Hello!"}]
... )
"""
def __init__(self, *args, **kwargs):
"""Initialize AsyncOpenAI client with Hindsight integration."""
super().__init__(*args, **kwargs)
# Wrap chat completions with our interceptor
self.chat.completions = _AsyncCompletionsWrapper(self.chat.completions)
@@ -1,104 +0,0 @@
"""Global configuration for Hindsight-OpenAI integration."""
from typing import Optional
from dataclasses import dataclass, field
@dataclass
class HindsightConfig:
"""Configuration for Hindsight integration.
Attributes:
hindsight_api_url: URL of the Hindsight API server
agent_id: Agent ID for memory operations
api_key: Optional API key for Hindsight authentication
store_conversations: Whether to store conversations to Hindsight
inject_memories: Whether to inject relevant memories into prompts
document_id: Optional document ID for stored conversations
enabled: Master switch to enable/disable Hindsight integration
"""
hindsight_api_url: str = "http://localhost:8888"
agent_id: Optional[str] = None
api_key: Optional[str] = None
store_conversations: bool = True
inject_memories: bool = True
document_id: Optional[str] = None
enabled: bool = True
# Global configuration instance
_global_config: Optional[HindsightConfig] = None
def configure(
hindsight_api_url: str = "http://localhost:8888",
agent_id: Optional[str] = None,
api_key: Optional[str] = None,
store_conversations: bool = True,
inject_memories: bool = True,
document_id: Optional[str] = None,
enabled: bool = True,
) -> HindsightConfig:
"""Configure global Hindsight integration settings.
Args:
hindsight_api_url: URL of the Hindsight API server
agent_id: Agent ID for memory operations (required)
api_key: Optional API key for Hindsight authentication
store_conversations: Whether to store conversations to Hindsight
inject_memories: Whether to inject relevant memories into prompts
document_id: Optional document ID for stored conversations
enabled: Master switch to enable/disable Hindsight integration
Returns:
The configured HindsightConfig instance
Example:
>>> from hindsight_openai import configure, OpenAI
>>> configure(
... hindsight_api_url="http://localhost:8888",
... agent_id="my-agent",
... store_conversations=True,
... inject_memories=True,
... document_id="conversation-123",
... )
>>> client = OpenAI(api_key="...")
"""
global _global_config
_global_config = HindsightConfig(
hindsight_api_url=hindsight_api_url,
agent_id=agent_id,
api_key=api_key,
store_conversations=store_conversations,
inject_memories=inject_memories,
document_id=document_id,
enabled=enabled,
)
return _global_config
def get_config() -> Optional[HindsightConfig]:
"""Get the current global configuration.
Returns:
The current HindsightConfig instance, or None if not configured
"""
return _global_config
def is_configured() -> bool:
"""Check if Hindsight has been configured.
Returns:
True if configure() has been called, False otherwise
"""
return _global_config is not None and _global_config.enabled
def reset_config() -> None:
"""Reset the global configuration to None."""
global _global_config
_global_config = None
@@ -1,246 +0,0 @@
"""Request/response interceptor for OpenAI API calls."""
from typing import Any, Dict, List, Optional
from datetime import datetime
from hindsight_client import Hindsight
from .config import get_config, HindsightConfig
class HindsightInterceptor:
"""Intercepts OpenAI API calls to integrate with Hindsight."""
def __init__(self):
"""Initialize the interceptor with a Hindsight client."""
self._client: Optional[Hindsight] = None
def get_client(self, config: HindsightConfig) -> Hindsight:
"""Get or create the Hindsight client."""
if self._client is None:
self._client = Hindsight(base_url=config.hindsight_api_url, timeout=30.0)
return self._client
def close(self):
"""Close the client."""
if self._client is not None:
self._client.close()
self._client = None
async def inject_memories(
self,
messages: List[Dict[str, Any]],
config: HindsightConfig,
) -> List[Dict[str, Any]]:
"""
Inject relevant memories into messages before sending to OpenAI.
"""
if not config.enabled:
return messages
try:
# Extract user query from messages
user_query = self._extract_user_query(messages)
if not user_query:
return messages
# Get client
client = self.get_client(config)
# Search for relevant memories
results = client.search(
agent_id=config.agent_id,
query=user_query,
max_tokens=4096,
thinking_budget=500,
)
if not results:
return messages
# Format memories and add to context
memory_context = self._format_memories(results)
# Add memory context to system message or create new one
updated_messages = self._add_memory_context(messages, memory_context)
return updated_messages
except Exception as e:
# Don't fail the request if memory retrieval fails
print(f"Warning: Failed to inject memories: {e}")
return messages
async def process_request(
self,
messages: List[Dict[str, Any]],
**kwargs: Any,
) -> List[Dict[str, Any]]:
"""
Process request before sending to OpenAI.
Retrieves relevant memories and adds them to the context.
"""
config = get_config()
return await self.inject_memories(messages, config)
async def store_conversation(
self,
messages: List[Dict[str, Any]],
response: Any,
config: HindsightConfig,
) -> None:
"""
Store conversation in Hindsight after receiving response from OpenAI.
"""
if not config.enabled or not config.store_conversations:
return
try:
# Extract conversation context
conversation = self._extract_conversation_context(messages, response)
if not conversation:
return
# Get client
client = self.get_client(config)
# Store conversation as memories
items = [
{
"content": msg["content"],
"context": f"role:{msg['role']}",
"event_date": datetime.now(),
}
for msg in conversation
]
# Store to Hindsight (async)
client.put_batch(
agent_id=config.agent_id,
items=items,
)
except Exception as e:
# Don't fail the request if storage fails
print(f"Warning: Failed to store conversation: {e}")
async def process_response(
self,
messages: List[Dict[str, Any]],
response: Any,
**kwargs: Any,
) -> None:
"""
Process response after receiving from OpenAI.
Stores the conversation in Hindsight.
"""
config = get_config()
await self.store_conversation(messages, response, config)
def _extract_user_query(self, messages: List[Dict[str, Any]]) -> Optional[str]:
"""Extract the user's query from messages."""
# Get the last user message
for msg in reversed(messages):
if msg.get("role") == "user":
content = msg.get("content")
if isinstance(content, str):
return content
elif isinstance(content, list):
# Handle structured content
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
return item.get("text")
return None
def _format_memories(self, results: List[Dict[str, Any]]) -> str:
"""Format memory search results into context string."""
if not results:
return ""
memory_lines = []
for i, result in enumerate(results, 1):
text = result.get("text", "")
if text:
memory_lines.append(f"{i}. {text}")
if not memory_lines:
return ""
return (
"# Relevant Memories\n"
"The following memories may be relevant to this conversation:\n\n"
+ "\n".join(memory_lines)
)
def _add_memory_context(
self,
messages: List[Dict[str, Any]],
memory_context: str
) -> List[Dict[str, Any]]:
"""Add memory context to messages."""
if not memory_context:
return messages
# Check if there's already a system message
updated_messages = messages.copy()
for i, msg in enumerate(updated_messages):
if msg.get("role") == "system":
# Append to existing system message
updated_messages[i] = {
**msg,
"content": f"{msg['content']}\n\n{memory_context}"
}
return updated_messages
# No system message found, prepend one
system_message = {
"role": "system",
"content": memory_context
}
return [system_message] + updated_messages
def _extract_conversation_context(
self,
messages: List[Dict[str, Any]],
response: Any,
) -> List[Dict[str, str]]:
"""Extract conversation context for storage."""
conversation = []
# Add recent user messages
for msg in messages[-3:]: # Last 3 messages
if msg.get("role") in ("user", "assistant"):
content = msg.get("content")
if isinstance(content, str):
conversation.append({
"role": msg["role"],
"content": content,
})
# Add assistant response
if hasattr(response, "choices") and response.choices:
choice = response.choices[0]
if hasattr(choice, "message"):
content = choice.message.content
if content:
conversation.append({
"role": "assistant",
"content": content,
})
return conversation
# Global interceptor instance
_interceptor = HindsightInterceptor()
def get_interceptor() -> HindsightInterceptor:
"""Get the global interceptor instance."""
return _interceptor
def cleanup_interceptor() -> None:
"""Cleanup the global interceptor instance."""
_interceptor.close()
@@ -1,34 +0,0 @@
[project]
name = "hindsight-openai"
version = "0.1.0"
description = "Drop-in replacement for OpenAI client with automatic Hindsight integration"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"openai>=1.0.0",
"hindsight-client",
# Transitive dependency security fixes
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass
]
[tool.uv.sources]
hindsight-client = { path = "../../hindsight-clients/python" }
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",
"pytest-mock>=3.10.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["hindsight_openai"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
@@ -1,265 +0,0 @@
"""Tests for Hindsight-OpenAI client wrapper."""
import os
import pytest
from hindsight_openai import (
configure,
reset_config,
OpenAI,
AsyncOpenAI,
is_configured,
)
@pytest.fixture(autouse=True)
def cleanup():
"""Reset configuration after each test."""
yield
reset_config()
@pytest.fixture
def groq_api_key():
"""Get Groq API key from environment."""
api_key = os.getenv("GROQ_API_KEY")
if not api_key:
pytest.skip("GROQ_API_KEY environment variable not set")
return api_key
@pytest.fixture
def hindsight_api_url():
"""Get Hindsight API URL from environment."""
return os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
class TestConfiguration:
"""Test configuration management."""
def test_configure_basic(self):
"""Test basic configuration."""
config = configure(
hindsight_api_url="http://test:8888",
agent_id="test-agent",
)
assert config.hindsight_api_url == "http://test:8888"
assert config.agent_id == "test-agent"
assert config.store_conversations is True
assert config.inject_memories is True
assert is_configured()
def test_configure_custom_options(self):
"""Test configuration with custom options."""
config = configure(
hindsight_api_url="http://test:8888",
agent_id="test-agent",
store_conversations=False,
inject_memories=False,
document_id="test-doc",
)
assert config.store_conversations is False
assert config.inject_memories is False
assert config.document_id == "test-doc"
def test_reset_config(self):
"""Test resetting configuration."""
configure(hindsight_api_url="http://test:8888", agent_id="test-agent")
assert is_configured()
reset_config()
assert not is_configured()
class TestSyncClient:
"""Test synchronous OpenAI client wrapper."""
def test_client_creation(self, groq_api_key):
"""Test that client can be created."""
configure(hindsight_api_url="http://test:8888", agent_id="test-agent")
client = OpenAI(
api_key=groq_api_key,
base_url="https://api.groq.com/openai/v1",
)
assert client is not None
assert hasattr(client.chat.completions, "_original")
def test_chat_completion_without_config(self, groq_api_key):
"""Test that chat completion works without Hindsight configuration."""
reset_config()
client = OpenAI(
api_key=groq_api_key,
base_url="https://api.groq.com/openai/v1",
)
response = client.chat.completions.create(
model="llama-3.1-8b-instant",
messages=[{"role": "user", "content": "Say 'test' and nothing else"}],
max_tokens=10,
)
assert response is not None
assert len(response.choices) > 0
assert response.choices[0].message.content is not None
def test_wrapper_passthrough(self, groq_api_key, hindsight_api_url):
"""Test that wrapper passes through when features disabled."""
configure(
hindsight_api_url=hindsight_api_url,
agent_id="test-sync-passthrough",
inject_memories=False,
store_conversations=False,
)
client = OpenAI(
api_key=groq_api_key,
base_url="https://api.groq.com/openai/v1",
)
response = client.chat.completions.create(
model="llama-3.1-8b-instant",
messages=[{"role": "user", "content": "Say 'hello' and nothing else"}],
max_tokens=10,
)
assert response is not None
assert len(response.choices) > 0
class TestAsyncClient:
"""Test asynchronous OpenAI client wrapper."""
def test_client_creation(self, groq_api_key):
"""Test that async client can be created."""
configure(hindsight_api_url="http://test:8888", agent_id="test-agent")
client = AsyncOpenAI(
api_key=groq_api_key,
base_url="https://api.groq.com/openai/v1",
)
assert client is not None
assert hasattr(client.chat.completions, "_original")
async def test_chat_completion_without_config(self, groq_api_key):
"""Test that async chat completion works without Hindsight configuration."""
reset_config()
client = AsyncOpenAI(
api_key=groq_api_key,
base_url="https://api.groq.com/openai/v1",
)
response = await client.chat.completions.create(
model="llama-3.1-8b-instant",
messages=[{"role": "user", "content": "Say 'test' and nothing else"}],
max_tokens=10,
)
assert response is not None
assert len(response.choices) > 0
assert response.choices[0].message.content is not None
async def test_wrapper_passthrough(self, groq_api_key, hindsight_api_url):
"""Test that async wrapper passes through when features disabled."""
configure(
hindsight_api_url=hindsight_api_url,
agent_id="test-async-passthrough",
inject_memories=False,
store_conversations=False,
)
client = AsyncOpenAI(
api_key=groq_api_key,
base_url="https://api.groq.com/openai/v1",
)
response = await client.chat.completions.create(
model="llama-3.1-8b-instant",
messages=[{"role": "user", "content": "Say 'hello' and nothing else"}],
max_tokens=10,
)
assert response is not None
assert len(response.choices) > 0
class TestInterceptor:
"""Test interceptor functionality."""
def test_extract_user_query_simple(self):
"""Test extracting user query from simple messages."""
from hindsight_openai.interceptor import HindsightInterceptor
interceptor = HindsightInterceptor()
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is Python?"},
]
query = interceptor._extract_user_query(messages)
assert query == "What is Python?"
def test_extract_user_query_structured(self):
"""Test extracting user query from structured content."""
from hindsight_openai.interceptor import HindsightInterceptor
interceptor = HindsightInterceptor()
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{"type": "image_url", "image_url": {"url": "https://..."}},
],
}
]
query = interceptor._extract_user_query(messages)
assert query == "What's in this image?"
def test_extract_conversation_context(self):
"""Test extracting conversation context."""
from hindsight_openai.interceptor import HindsightInterceptor
from unittest.mock import Mock
interceptor = HindsightInterceptor()
messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi! How can I help?"},
{"role": "user", "content": "Tell me about AI"},
]
# Mock response object
mock_response = Mock()
mock_response.choices = [Mock()]
mock_response.choices[0].message = Mock()
mock_response.choices[0].message.content = "AI stands for Artificial Intelligence"
context = interceptor._extract_conversation_context(messages, mock_response)
# Should include recent messages and response
assert len(context) > 0
assert any(msg["content"] == "Tell me about AI" for msg in context)
assert any(msg["content"] == "AI stands for Artificial Intelligence" for msg in context)
def test_format_memories(self):
"""Test formatting memories."""
from hindsight_openai.interceptor import HindsightInterceptor
interceptor = HindsightInterceptor()
memories = [
{
"text": "User likes Python",
"event_date": "2024-01-01",
"fact_type": "opinion",
},
{"text": "Working on AI project", "event_date": None, "fact_type": "world"},
]
formatted = interceptor._format_memories(memories)
assert "1. User likes Python" in formatted
assert "2. Working on AI project" in formatted
assert "Relevant Memories" in formatted
@@ -1,163 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": "# Hindsight-OpenAI Tutorial\n\n**A drop-in replacement for the OpenAI Python client with automatic memory integration**\n\n## What is Hindsight-OpenAI?\n\n`hindsight-openai` is a transparent wrapper around the official OpenAI Python client that automatically:\n\n- 🧠 **Injects relevant memories** from your Hindsight system into conversations\n- 💾 **Stores conversation history** to Hindsight for future retrieval \n- 🔄 **Works seamlessly** with existing OpenAI code (just change the import)\n- ⚡ **Supports both sync and async** clients\n\n## Why Use It?\n\n### The Problem\n\nAI assistants typically have no memory of previous conversations. Each interaction starts fresh, requiring you to:\n- Repeat context manually\n- Copy-paste relevant information\n- Build custom RAG pipelines\n- Manage conversation history yourself\n\n### The Solution\n\nHindsight-OpenAI gives your AI **automatic long-term memory**:\n- Remembers past conversations\n- Recalls user preferences and facts\n- Maintains context across sessions\n- Zero code changes to your existing OpenAI usage\n\n## Prerequisites\n\n1. **Hindsight API server running** (see main Hindsight README)\n2. **OpenAI API key** or compatible API (Groq, OpenRouter, etc.)\n3. **Python >= 3.10**"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "## Setup\n\nFirst, let's set up our environment and configure Hindsight integration:"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"jupyter": {
"is_executing": true
}
},
"outputs": [],
"source": "import os\nfrom hindsight_openai import configure, AsyncOpenAI\n\n# Set your API keys\n# Option 1: Use Groq (fast and free)\nGROQ_API_KEY = os.getenv(\"GROQ_API_KEY\", \"your-groq-api-key\")\nif not GROQ_API_KEY:\n raise (\"GROQ_API_KEY not set\") \n\n# Option 2: Use OpenAI\n# OPENAI_API_KEY = os.getenv(\"OPENAI_API_KEY\", \"sk-...\")\n\n# Configure Hindsight integration\nconfigure(\n hindsight_api_url=\"http://localhost:8888\", # Your Hindsight API server\n agent_id=\"tutorial-user\", # Unique ID for this user/agent\n store_conversations=True, # Auto-save conversations\n inject_memories=True, # Auto-inject relevant context\n)\n\nprint(\"✓ Hindsight configured successfully!\")\nprint(\"\")\nprint(\"NOTE: This tutorial uses AsyncOpenAI which works perfectly in Jupyter notebooks.\")\nprint(\"For regular Python scripts, you can use the sync OpenAI client instead.\")"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "## Example 1: Basic Usage (No Changes Needed!)\n\nUse the OpenAI client exactly as you normally would. Hindsight works transparently in the background."
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "# Create client (using Groq's OpenAI-compatible API)\nclient = AsyncOpenAI(\n api_key=GROQ_API_KEY,\n base_url=\"https://api.groq.com/openai/v1\",\n)\n\n# First conversation - establish some facts\nprint(\"=== First Conversation ===\")\nresponse = await client.chat.completions.create(\n model=\"llama-3.1-8b-instant\",\n messages=[\n {\"role\": \"user\", \"content\": \"My name is Alice and I love Python programming!\"}\n ],\n)\n\nprint(f\"Assistant: {response.choices[0].message.content}\\n\")\nprint(\"→ This conversation is now stored in Hindsight!\")"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "## Example 2: Memory Injection in Action\n\nNow ask a question that requires remembering the previous conversation:"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "print(\"=== Second Conversation (with memory) ===\")\nresponse = await client.chat.completions.create(\n model=\"llama-3.1-8b-instant\",\n messages=[\n {\"role\": \"user\", \"content\": \"What's my name and what do I like?\"}\n ],\n)\n\nprint(f\"Assistant: {response.choices[0].message.content}\\n\")\nprint(\"→ Hindsight automatically injected relevant memories before this request!\")\nprint(\"→ The AI knew your name and preferences without you repeating them.\")"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "## How It Works\n\nBehind the scenes, Hindsight-OpenAI:\n\n### 1. **Memory Storage**\nAfter each API call:\n- Captures the full conversation context\n- Stores it in Hindsight's semantic memory system\n- Indexes it for fast retrieval\n\n### 2. **Memory Injection**\nBefore each API call:\n- Extracts the user's query\n- Searches Hindsight for relevant past conversations\n- Injects top memories as a system message\n\n### What Gets Sent to OpenAI\n\nWithout Hindsight:\n```python\nmessages = [\n {\"role\": \"user\", \"content\": \"What's my name?\"}\n]\n```\n\nWith Hindsight (automatic):\n```python\nmessages = [\n {\n \"role\": \"system\",\n \"content\": \"Relevant context from your memory:\\n\\n1. User's name is Alice\\n (Date: 2024-11-18)\\n (Type: world)\"\n },\n {\"role\": \"user\", \"content\": \"What's my name?\"}\n]\n```"
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Example 3: Multi-Turn Conversations\n",
"\n",
"Build up context over multiple interactions:"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Conversation 1: Share a preference\n",
"print(\"=== Conversation 1: Sharing preferences ===\")\n",
"response = await client.chat.completions.create(\n",
" model=\"llama-3.1-8b-instant\",\n",
" messages=[\n",
" {\"role\": \"user\", \"content\": \"I'm working on a machine learning project using PyTorch.\"}\n",
" ],\n",
")\n",
"print(f\"Assistant: {response.choices[0].message.content}\\n\")\n",
"\n",
"# Conversation 2: Different topic\n",
"print(\"=== Conversation 2: Different topic ===\")\n",
"response = await client.chat.completions.create(\n",
" model=\"llama-3.1-8b-instant\",\n",
" messages=[\n",
" {\"role\": \"user\", \"content\": \"I prefer functional programming over OOP.\"}\n",
" ],\n",
")\n",
"print(f\"Assistant: {response.choices[0].message.content}\\n\")\n",
"\n",
"# Conversation 3: Ask for recommendations\n",
"print(\"=== Conversation 3: Getting personalized advice ===\")\n",
"response = await client.chat.completions.create(\n",
" model=\"llama-3.1-8b-instant\",\n",
" messages=[\n",
" {\"role\": \"user\", \"content\": \"Can you recommend a good book for me based on what you know?\"}\n",
" ],\n",
")\n",
"print(f\"Assistant: {response.choices[0].message.content}\\n\")\n",
"print(\"→ The AI used your programming interests and preferences to make recommendations!\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": "## Example 4: Document Grouping\n\nGroup related conversations using `document_id`:"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "from hindsight_openai import configure\n\n# Configure with document ID for a specific project\nconfigure(\n hindsight_api_url=\"http://localhost:8888\",\n agent_id=\"tutorial-user\",\n document_id=\"ml-project-2024\", # All conversations tagged with this ID\n)\n\n# All these conversations will be grouped together\nconversations = [\n \"I'm using ResNet for image classification\",\n \"My dataset has 10,000 images\",\n \"Training accuracy is stuck at 65%\",\n]\n\nfor msg in conversations:\n response = await client.chat.completions.create(\n model=\"llama-3.1-8b-instant\",\n messages=[{\"role\": \"user\", \"content\": msg}],\n )\n print(f\"User: {msg}\")\n print(f\"Assistant: {response.choices[0].message.content}\\n\")\n\nprint(\"→ All these conversations are grouped under document 'ml-project-2024'\")"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "## Example 5: Async Support\n\nWorks perfectly with AsyncOpenAI for high-throughput applications:"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "from hindsight_openai import AsyncOpenAI\nimport asyncio\n\nasync def async_example():\n # Create async client\n async_client = AsyncOpenAI(\n api_key=GROQ_API_KEY,\n base_url=\"https://api.groq.com/openai/v1\",\n )\n \n # Store a fact\n print(\"=== Storing fact ===\")\n response = await async_client.chat.completions.create(\n model=\"llama-3.1-8b-instant\",\n messages=[{\"role\": \"user\", \"content\": \"My favorite color is blue.\"}],\n )\n print(f\"Assistant: {response.choices[0].message.content}\\n\")\n \n # Query with memory\n print(\"=== Querying with memory ===\")\n response = await async_client.chat.completions.create(\n model=\"llama-3.1-8b-instant\",\n messages=[{\"role\": \"user\", \"content\": \"What's my favorite color?\"}],\n )\n print(f\"Assistant: {response.choices[0].message.content}\\n\")\n\n# Run async example\nawait async_example()\nprint(\"→ Async operations work seamlessly with Hindsight!\")"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "## Configuration Options\n\nFine-tune Hindsight's behavior:"
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": "from hindsight_openai import configure\n\n# Full configuration example\nconfigure(\n hindsight_api_url=\"http://localhost:8888\", # Hindsight API URL\n agent_id=\"my-agent\", # Agent identifier (required)\n api_key=None, # Optional Hindsight API key\n \n # Features\n store_conversations=True, # Store conversations automatically\n inject_memories=True, # Inject memories automatically\n \n # Organization\n document_id=\"session-123\", # Optional document grouping\n \n # Control\n enabled=True, # Master on/off switch\n)\n\nprint(\"Configuration options explained:\")\nprint(\"- store_conversations: Automatically save conversations to Hindsight\")\nprint(\"- inject_memories: Automatically retrieve and inject relevant context\")\nprint(\"- document_id: Group related conversations together\")\nprint(\"- enabled=False: Disable Hindsight without changing code\")"
},
{
"cell_type": "markdown",
"metadata": {},
"source": "## Use Cases\n\n### 1. **Personal AI Assistant**\n- Remembers your preferences, work history, and interests\n- Provides personalized recommendations\n- Maintains context across days/weeks\n\n### 2. **Customer Support Chatbot**\n- Recalls previous support tickets\n- Knows customer preferences and history\n- Provides consistent, context-aware responses\n\n### 3. **Research Assistant**\n- Remembers documents you've discussed\n- Connects related topics from different sessions\n- Builds knowledge over time\n\n### 4. **Code Review Tool**\n- Remembers project architecture decisions\n- Recalls past code review comments\n- Maintains consistency across reviews\n\n## Benefits Summary\n\n✅ **Zero Code Changes** - Drop-in replacement for OpenAI client \n✅ **Automatic Context** - No manual RAG pipeline needed \n✅ **Long-term Memory** - Conversations persist across sessions \n✅ **Smart Retrieval** - Semantic search finds relevant context \n✅ **Both Sync/Async** - Works with any OpenAI client pattern \n✅ **Configurable** - Fine-tune behavior to your needs \n✅ **Transparent** - Original OpenAI responses unchanged \n\n## Next Steps\n\n- **Explore Hindsight API**: Check out the main Hindsight README for advanced features\n- **Customize Search**: Tune `memory_search_budget` for your use case\n- **Use Document IDs**: Organize conversations by project/session\n- **Try Different Models**: Works with OpenAI, Groq, Ollama, and more\n\n## Resources\n\n- [Hindsight Main README](../README.md) - Core memory system docs\n- [Hindsight-OpenAI README](README.md) - Package documentation\n- [OpenAI API Docs](https://platform.openai.com/docs/api-reference) - Original API reference"
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.10"
}
},
"nbformat": 4,
"nbformat_minor": 4
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.uv.workspace]
members = ["hindsight", "hindsight-api", "hindsight-dev", "hindsight-mcp-server", "hindsight-openai", "hindsight-langmem", "hindsight-clients/python", "hindsight-embed"]
members = ["hindsight", "hindsight-api", "hindsight-dev", "hindsight-mcp-server", "hindsight-clients/python", "hindsight-embed"]
[tool.uv]
dev-dependencies = []