fleet-memory/hindsight-integrations/litellm/hindsight_litellm/callbacks.py
DK09876 dfccbf29f1
Added hindsight_liteLLM implementation (#17)
* Added hindsight_liteLLM implementation

* Add instructions for entity vs bank id

* Add another line about entity

* Address PR review comments and enhance litellm integration

- Remove deprecated limit parameter from recall() and arecall() functions
  since Hindsight uses budget/max_tokens for result control
- Remove dead MODEL_MAX_OUTPUT_TOKENS dict and max_output_tokens property
  from LLMProvider (superseded by hardcoded max_completion_tokens)
- Add test-litellm-integration job to CI workflow
- Add reflect API support with use_reflect config option
- Add verbose mode debug info via get_last_injection_debug()
- Add entity_id support for multi-user memory isolation
- Add retain() and reflect() wrapper functions
- Update docstrings and examples

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Make max_memories optional to allow unlimited memory injection

- Change max_memories default from 10 to None (no limit)
- When max_memories is None, all results from the API are used
- Fix recall result handling to properly detect list vs object return
- Update wrappers (OpenAI, Anthropic) with same optional behavior

This allows users to control memory limits via max_memory_tokens
and recall_budget without an artificial count limit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove entity_id from hindsight_litellm; add gpt-4o token cap

Multi-user support now uses separate bank_ids per user instead of
entity_id scoping (e.g., bank_id=f"user-{user_id}"). This simplifies
the API and aligns with the Hindsight architecture.

Also fixes max_completion_tokens error for gpt-4o models by capping
the value at 16384 (gpt-4o's limit) instead of sending the default
65000 which exceeds the model's supported maximum.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix dark mode styling across Control Plane UI components

Improvements to ensure proper text visibility and contrast in both light
and dark modes:

- Add global CSS rules for datetime-local calendar picker icon visibility
  using filter: invert() for both light (0.5) and dark (1) modes
- Fix text colors in dialog components to use theme-aware foreground colors
- Update memory detail panel, document/chunk modals, and data views to use
  proper dark mode text classes (text-foreground, text-card-foreground)
- Fix form labels, headings, and content text in bank selector dialogs
- Update entities view and documents view table styling for dark mode
- Bump package versions to 0.1.4

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove session_id feature and add How It Works section to README

- Remove session_id and session management (new_session, set_session,
  get_session) from config.py, callbacks.py, and __init__.py
- Session management was a client-only abstraction not backed by core API
- Add "How It Works" section to README with visual flow diagram
- Update README to remove session management documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix readme example

* Add dark mode again

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 10:42:19 +01:00

640 lines
21 KiB
Python

"""LiteLLM callback handlers for Hindsight memory integration.
This module implements LiteLLM's CustomLogger interface to intercept
LLM calls and integrate with Hindsight for memory injection and storage.
Uses direct HTTP calls via requests/httpx to avoid async event loop conflicts
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
import concurrent.futures
from litellm.integrations.custom_logger import CustomLogger
from litellm.types.utils import ModelResponse
from .config import get_config, is_configured, HindsightConfig, MemoryInjectionMode
# 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
logger = logging.getLogger(__name__)
# Thread pool for running async operations in background
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=4, thread_name_prefix="hindsight-")
class HindsightCallback(CustomLogger):
"""LiteLLM custom logger that integrates with Hindsight memory system.
This callback handler:
1. Injects relevant memories into prompts before LLM calls
2. Stores conversations to Hindsight after successful LLM calls
Features:
- Works with 100+ LLM providers via LiteLLM
- Deduplication to avoid storing duplicate conversations
- Configurable memory injection modes
- Support for entity observations in recall
Usage:
>>> from hindsight_litellm import configure, enable
>>> configure(bank_id="my-agent", hindsight_api_url="http://localhost:8888")
>>> enable()
>>>
>>> # Now all LiteLLM calls will have memory integration
>>> import litellm
>>> response = litellm.completion(
... model="gpt-4",
... messages=[{"role": "user", "content": "What did we discuss?"}]
... )
"""
def __init__(self):
"""Initialize the Hindsight callback handler."""
super().__init__()
self._http_session = None
self._http_lock = threading.Lock()
# Track recently stored conversation hashes for deduplication
self._recent_hashes: Set[str] = set()
self._max_hash_cache = 1000
def _get_http_session(self):
"""Get or create a requests Session (thread-safe)."""
if self._http_session is None:
with self._http_lock:
if self._http_session is None:
if HAS_REQUESTS:
self._http_session = requests.Session()
elif HAS_HTTPX:
self._http_session = httpx.Client(timeout=30.0)
else:
raise RuntimeError(
"Neither 'requests' nor 'httpx' is installed. "
"Please install one: pip install requests"
)
return self._http_session
def _http_post(self, url: str, json_data: dict, config: HindsightConfig) -> Optional[dict]:
"""Make a synchronous HTTP POST request."""
try:
session = self._get_http_session()
headers = {"Content-Type": "application/json"}
if config.api_key:
headers["Authorization"] = f"Bearer {config.api_key}"
if HAS_REQUESTS:
response = session.post(url, json=json_data, headers=headers, timeout=30)
response.raise_for_status()
return response.json()
elif HAS_HTTPX:
response = session.post(url, json=json_data, headers=headers)
response.raise_for_status()
return response.json()
except Exception as e:
if config.verbose:
logger.warning(f"HTTP POST failed: {e}")
return None
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:
if fnmatch.fnmatch(model.lower(), pattern.lower()):
return True
return False
def _extract_user_query(self, messages: List[Dict[str, Any]]) -> Optional[str]:
"""Extract the user's query from the last user message."""
for msg in reversed(messages):
role = msg.get("role", "")
if role == "user":
content = msg.get("content")
if isinstance(content, str):
return content
elif isinstance(content, list):
# Handle structured content (e.g., vision messages)
text_parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
text_parts.append(item.get("text", ""))
if text_parts:
return " ".join(text_parts)
return None
def _compute_conversation_hash(
self,
user_input: str,
assistant_output: str,
) -> str:
"""Compute a hash for deduplication."""
content = f"{user_input.strip().lower()}|{assistant_output.strip().lower()}"
return hashlib.md5(content.encode()).hexdigest()[:16]
def _is_duplicate(self, conv_hash: str) -> bool:
"""Check if this conversation was recently stored."""
if conv_hash in self._recent_hashes:
return True
# Add to cache, evict oldest if full
self._recent_hashes.add(conv_hash)
if len(self._recent_hashes) > self._max_hash_cache:
# Remove oldest (arbitrary since set, but good enough)
self._recent_hashes.pop()
return False
def _format_memories(
self,
results: List[Any],
config: HindsightConfig
) -> str:
"""Format memory recall results into a context string.
Results can be RecallResult objects (with .text, .type attributes)
or dicts (with get() method).
"""
if not results:
return ""
# Apply limit if set, otherwise use all results
results_to_use = results[:config.max_memories] if config.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'):
text = result.text or ""
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"))
weight = result.get("weight", 0.0)
if text:
# Include metadata for context
type_label = fact_type.upper() if fact_type else "MEMORY"
line = f"{i}. [{type_label}] {text}"
if weight > 0 and config.verbose:
line += f" (relevance: {weight:.2f})"
memory_lines.append(line)
if not memory_lines:
return ""
return (
"# Relevant Memories\n"
"The following information from memory may be relevant:\n\n"
+ "\n".join(memory_lines)
)
def _inject_memories_into_messages(
self,
messages: List[Dict[str, Any]],
memory_context: str,
config: HindsightConfig,
) -> List[Dict[str, Any]]:
"""Inject memory context into the messages list."""
if not memory_context:
return messages
updated_messages = list(messages) # Make a copy
if config.injection_mode == MemoryInjectionMode.SYSTEM_MESSAGE:
# Find existing system message or create new one
for i, msg in enumerate(updated_messages):
if msg.get("role") == "system":
# Append to existing system message
existing_content = msg.get("content", "")
updated_messages[i] = {
**msg,
"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
})
elif config.injection_mode == MemoryInjectionMode.PREPEND_USER:
# Find the last user message and prepend context
for i in range(len(updated_messages) - 1, -1, -1):
if updated_messages[i].get("role") == "user":
original_content = updated_messages[i].get("content", "")
if isinstance(original_content, str):
updated_messages[i] = {
**updated_messages[i],
"content": f"{memory_context}\n\n---\n\n{original_content}"
}
break
return updated_messages
def _get_bank_id(self, config: HindsightConfig) -> str:
"""Get the bank_id for API calls."""
return config.bank_id
def _recall_memories_sync(
self,
query: str,
config: HindsightConfig
) -> List[Dict[str, Any]]:
"""Recall relevant memories from Hindsight (sync) using direct HTTP."""
try:
bank_id = self._get_bank_id(config)
url = f"{config.hindsight_api_url}/v1/default/banks/{bank_id}/memories/recall"
request_data = {
"query": query,
"budget": config.recall_budget or "mid",
"max_tokens": config.max_memory_tokens or 4096,
}
if config.fact_types:
request_data["types"] = config.fact_types
response = self._http_post(url, request_data, config)
if response and "results" in response:
return response["results"]
return []
except Exception as e:
if config.verbose:
logger.warning(f"Failed to recall memories: {e}")
return []
async def _recall_memories_async(
self,
query: str,
config: HindsightConfig
) -> List[Any]:
"""Recall relevant memories from Hindsight (async).
Uses thread pool executor with sync HTTP to avoid event loop conflicts.
"""
try:
loop = asyncio.get_running_loop()
results = await loop.run_in_executor(
_executor,
self._recall_memories_sync,
query,
config
)
return results if isinstance(results, list) else []
except Exception as e:
if config.verbose:
logger.warning(f"Failed to recall memories: {e}")
return []
def _store_conversation_sync(
self,
messages: List[Dict[str, Any]],
response: ModelResponse,
model: str,
config: HindsightConfig,
) -> None:
"""Store the conversation to Hindsight (sync) using direct HTTP.
By default, stores the full conversation history passed to the LLM.
Each message is stored as a separate item, all linked by document_id.
Hindsight will process the document as a whole for memory extraction.
"""
try:
# Extract assistant response from the LLM response
assistant_output = ""
if response.choices and len(response.choices) > 0:
choice = response.choices[0]
if hasattr(choice, "message") and choice.message:
assistant_output = choice.message.content or ""
if not assistant_output:
return
# Build conversation items - each message becomes a separate item
# All linked by document_id for Hindsight to process together
items = []
for msg in messages:
role = msg.get("role", "").upper()
content = msg.get("content", "")
# Skip system messages - they're instructions, not conversation
if role == "SYSTEM":
continue
# Skip if this looks like our injected memory context
if isinstance(content, str) and content.startswith("# Relevant Memories"):
continue
# Handle structured content (e.g., vision messages)
if isinstance(content, list):
text_parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
text_parts.append(item.get("text", ""))
content = " ".join(text_parts)
if content:
# Map roles to clearer labels
label = "USER" if role == "USER" else "ASSISTANT"
items.append(f"{label}: {content}")
# Add the new assistant response
items.append(f"ASSISTANT: {assistant_output}")
if not items:
return
# Use last user message for deduplication hash
user_input = self._extract_user_query(messages) or ""
# Deduplication check
conv_hash = self._compute_conversation_hash(user_input, assistant_output)
if self._is_duplicate(conv_hash):
if config.verbose:
logger.debug(f"Skipping duplicate conversation: {conv_hash}")
return
# 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)
# Build metadata
metadata = {
"source": "litellm",
"model": model,
}
# Add token usage if available
if hasattr(response, "usage") and response.usage:
if hasattr(response.usage, "total_tokens"):
metadata["tokens"] = str(response.usage.total_tokens)
bank_id = self._get_bank_id(config)
url = f"{config.hindsight_api_url}/v1/default/banks/{bank_id}/memories"
request_data = {
"items": [
{
"content": conversation_text,
"context": f"conversation:litellm:{model}",
"metadata": metadata,
"document_id": config.document_id, # Group by document
}
],
}
self._http_post(url, request_data, config)
if config.verbose:
logger.info(f"Stored conversation to Hindsight bank: {config.bank_id}")
except Exception as e:
if config.verbose:
logger.warning(f"Failed to store conversation: {e}")
async def _store_conversation_async(
self,
messages: List[Dict[str, Any]],
response: ModelResponse,
model: str,
config: HindsightConfig,
) -> None:
"""Store the conversation to Hindsight (async).
Uses thread pool executor with sync HTTP to avoid event loop conflicts.
"""
try:
loop = asyncio.get_running_loop()
await loop.run_in_executor(
_executor,
self._store_conversation_sync,
messages,
response,
model,
config
)
except Exception as e:
if config.verbose:
logger.warning(f"Failed to store conversation: {e}")
# ========== LiteLLM CustomLogger Interface ==========
def log_pre_api_call(
self,
model: str,
messages: List[Dict[str, Any]],
kwargs: Dict[str, Any],
) -> None:
"""Called before making the API call (sync).
This is where we inject memories into the messages.
"""
if not is_configured():
return
config = get_config()
if not config or not config.enabled or not config.inject_memories:
return
if self._should_skip_model(model, config):
return
# Extract user query
user_query = self._extract_user_query(messages)
if not user_query:
return
# Recall relevant memories
memories = self._recall_memories_sync(user_query, config)
if not memories:
return
# Format and inject memories
memory_context = self._format_memories(memories, config)
updated_messages = self._inject_memories_into_messages(
messages, memory_context, config
)
# Modify messages list IN-PLACE (don't just reassign kwargs)
messages.clear()
messages.extend(updated_messages)
if config.verbose:
logger.info(f"Injected {len(memories)} memories into prompt")
async def async_log_pre_api_call(
self,
model: str,
messages: List[Dict[str, Any]],
kwargs: Dict[str, Any],
) -> None:
"""Called before making the API call (async).
This is where we inject memories into the messages.
"""
if not is_configured():
return
config = get_config()
if not config or not config.enabled or not config.inject_memories:
return
if self._should_skip_model(model, config):
return
# Extract user query
user_query = self._extract_user_query(messages)
if not user_query:
return
# Recall relevant memories
memories = await self._recall_memories_async(user_query, config)
if not memories:
return
# Format and inject memories
memory_context = self._format_memories(memories, config)
updated_messages = self._inject_memories_into_messages(
messages, memory_context, config
)
# Modify messages list IN-PLACE (don't just reassign kwargs)
messages.clear()
messages.extend(updated_messages)
if config.verbose:
logger.info(f"Injected {len(memories)} memories into prompt")
def log_success_event(
self,
kwargs: Dict[str, Any],
response_obj: Any,
start_time: float,
end_time: float,
) -> None:
"""Called after successful API call (sync).
This is where we store the conversation.
"""
if not is_configured():
return
config = get_config()
if not config or not config.enabled or not config.store_conversations:
return
model = kwargs.get("model", "unknown")
if self._should_skip_model(model, config):
return
messages = kwargs.get("messages", [])
if not messages:
return
# Store the conversation
self._store_conversation_sync(messages, response_obj, model, config)
async def async_log_success_event(
self,
kwargs: Dict[str, Any],
response_obj: Any,
start_time: float,
end_time: float,
) -> None:
"""Called after successful API call (async).
This is where we store the conversation.
"""
if not is_configured():
return
config = get_config()
if not config or not config.enabled or not config.store_conversations:
return
model = kwargs.get("model", "unknown")
if self._should_skip_model(model, config):
return
messages = kwargs.get("messages", [])
if not messages:
return
# Store the conversation
await self._store_conversation_async(messages, response_obj, model, config)
def log_failure_event(
self,
kwargs: Dict[str, Any],
response_obj: Any,
start_time: float,
end_time: float,
) -> None:
"""Called after failed API call (sync)."""
# We don't store failed conversations
pass
async def async_log_failure_event(
self,
kwargs: Dict[str, Any],
response_obj: Any,
start_time: float,
end_time: float,
) -> None:
"""Called after failed API call (async)."""
# We don't store failed conversations
pass
def close(self) -> None:
"""Clean up resources."""
with self._http_lock:
if self._http_session is not None:
try:
if HAS_REQUESTS:
self._http_session.close()
elif HAS_HTTPX:
self._http_session.close()
except Exception:
pass
self._http_session = None
self._recent_hashes.clear()
# Global callback instance
_callback: Optional[HindsightCallback] = None
def get_callback() -> HindsightCallback:
"""Get the global callback instance, creating it if necessary."""
global _callback
if _callback is None:
_callback = HindsightCallback()
return _callback
def cleanup_callback() -> None:
"""Clean up the global callback instance."""
global _callback
if _callback is not None:
_callback.close()
_callback = None