fleet-memory/hindsight-integrations/litellm/tests/test_integration.py
DK09876 1d4879a206
feat(litellm): async retain, reflect support, and API cleanup (#167)
* feat(litellm): async retain with sync option, fix client session cleanup

- Add sync parameter to retain() for blocking vs background operation
- Default to async retain (sync=False) for better performance
- Add get_pending_retain_errors() to check async failures
- Fix "Unclosed client session" warnings by properly closing clients
- Fix "Timeout context manager" asyncio errors by creating fresh clients
- Each API call now creates and closes its own client (aiohttp limitation)
- Add _get_client() and _close_client() helpers for consistent handling
- Update recall(), reflect(), _retain_sync() and _inject_memories()

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

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

* feat(litellm): add reflect support and require explicit hindsight_query

- Make hindsight_query required when inject_memories=True to enforce
  intentional memory queries (no automatic last-user-message fallback)
- Add reflect_context parameter for shaping LLM reasoning in reflect
- Add reflect_response_schema for structured JSON output from reflect
- Add _reflect_sync() and _reflect_async() methods in callbacks
- Update wrappers.py to support response_schema in reflect/areflect

This improves the developer experience by making memory injection
explicit and adds full reflect API support through the integration.

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

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

* feat(litellm): rename recall_budget to budget, add per-call reflect context

- Rename `recall_budget` parameter to `budget` for consistency with API
- Add `hindsight_reflect_context` kwarg for per-call reflect context override
- Fix reflect() to not pass None values for optional parameters

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

* docs(litellm): update README for new API structure and features

- Document configure() vs set_defaults() separation
- Add hindsight_query requirement when inject_memories=True
- Document async retain (sync=False default) and get_pending_retain_errors()
- Add hindsight_reflect_context per-call override documentation
- Document budget parameter (renamed from recall_budget)
- Add reflect_context and reflect_response_schema options
- Update all code examples to use new API structure
- Add new functions to API Reference table

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

* test(litellm): update tests for new configure/set_defaults API

- Update tests to use separate configure() and set_defaults() calls
- Fix test assertions to check config vs defaults appropriately
- Add tests for legacy parameter backwards compatibility
- Add new TestSetDefaults test class
- Fix _format_memories test call signature (settings, config order)

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

* feat: add set_bank_mission(), deprecate set_bank_background()

- Add mission parameter to hindsight_client.create_bank()
- Add set_bank_mission() function to hindsight_litellm
- Deprecate set_bank_background() with DeprecationWarning
- Update _create_or_update_bank() to support mission parameter
- Update README and docstrings to document the new API

The 'background' field has been deprecated in the Hindsight API in favor
of 'mission' which is used for mental model generation.

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

* Remove deprecated background parameter and legacy configure() parameters

- Remove set_bank_background() in favor of set_bank_mission()
- Remove background parameter from _create_or_update_bank()
- Remove background parameter from hindsight_client.create_bank()
- Remove legacy parameters from configure() (bank_id, document_id, budget, etc.)
- These have been replaced by the set_defaults() API
- Remove legacy test cases for deprecated parameters

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

* fix: update tests and docs to use mission instead of background

The create_bank() parameter was renamed from background to mission.
Update all tests and doc examples to use the new parameter name.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Nicolò Boschi <boschi1997@gmail.com>
2026-01-26 10:07:32 +01:00

577 lines
19 KiB
Python

"""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,
set_defaults,
get_defaults,
enable,
disable,
is_enabled,
cleanup,
get_config,
is_configured,
reset_config,
HindsightConfig,
HindsightDefaults,
MemoryInjectionMode,
)
from hindsight_litellm.callbacks import HindsightCallback
class TestConfiguration:
"""Tests for configuration management."""
def setup_method(self):
"""Reset config before each test."""
reset_config()
disable()
def teardown_method(self):
"""Clean up after each test."""
cleanup()
def test_configure_creates_config(self):
"""Test that configure creates a config object."""
config = configure(
hindsight_api_url="http://localhost:8888",
)
# Set defaults separately (new API)
defaults = set_defaults(bank_id="test-agent")
assert config is not None
assert config.hindsight_api_url == "http://localhost:8888"
assert defaults.bank_id == "test-agent"
def test_configure_with_all_options(self):
"""Test configure with all options."""
config = configure(
hindsight_api_url="http://custom:9999",
api_key="secret-key",
store_conversations=False,
inject_memories=False,
injection_mode=MemoryInjectionMode.PREPEND_USER,
excluded_models=["gpt-3.5*"],
verbose=True,
sync_storage=True,
)
# Set defaults separately (new API)
defaults = set_defaults(
bank_id="custom-agent",
max_memories=5,
max_memory_tokens=1000,
budget="high",
fact_types=["world", "opinion"],
document_id="doc-123",
)
assert config.hindsight_api_url == "http://custom:9999"
assert config.api_key == "secret-key"
assert config.store_conversations is False
assert config.inject_memories is False
assert config.injection_mode == MemoryInjectionMode.PREPEND_USER
assert config.excluded_models == ["gpt-3.5*"]
assert config.verbose is True
assert config.sync_storage is True
assert defaults.bank_id == "custom-agent"
assert defaults.max_memories == 5
assert defaults.max_memory_tokens == 1000
assert defaults.budget == "high"
assert defaults.fact_types == ["world", "opinion"]
assert defaults.document_id == "doc-123"
def test_is_configured_without_bank_id(self):
"""Test is_configured returns False without bank_id."""
configure(hindsight_api_url="http://localhost:8888")
assert is_configured() is False
def test_is_configured_with_bank_id(self):
"""Test is_configured returns True with bank_id."""
configure(hindsight_api_url="http://localhost:8888")
set_defaults(bank_id="test-agent")
assert is_configured() is True
def test_reset_config(self):
"""Test reset_config clears the configuration."""
configure(hindsight_api_url="http://localhost:8888")
set_defaults(bank_id="test-agent")
assert is_configured() is True
reset_config()
assert get_config() is None
assert get_defaults() is None
assert is_configured() is False
class TestEnableDisable:
"""Tests for enable/disable functionality."""
def setup_method(self):
"""Reset state before each test."""
cleanup()
def teardown_method(self):
"""Clean up after each test."""
cleanup()
def test_enable_without_config_raises(self):
"""Test enable raises error without configuration."""
with pytest.raises(RuntimeError, match="not configured"):
enable()
def test_enable_without_bank_id_raises(self):
"""Test enable raises error without bank_id."""
configure(hindsight_api_url="http://localhost:8888")
with pytest.raises(RuntimeError, match="bank_id not set"):
enable()
def test_enable_sets_enabled_flag(self):
"""Test enable sets the enabled flag."""
configure(hindsight_api_url="http://localhost:8888")
set_defaults(bank_id="test-agent")
enable()
assert is_enabled() is True
def test_disable_clears_enabled_flag(self):
"""Test disable clears the enabled flag."""
configure(hindsight_api_url="http://localhost:8888")
set_defaults(bank_id="test-agent")
enable()
assert is_enabled() is True
disable()
assert is_enabled() is False
def test_enable_idempotent(self):
"""Test enable is idempotent (can be called multiple times)."""
configure(hindsight_api_url="http://localhost:8888")
set_defaults(bank_id="test-agent")
# Enable multiple times
enable()
enable()
enable()
# Should still be enabled
assert is_enabled() is True
class TestCallback:
"""Tests for the HindsightCallback class."""
def setup_method(self):
"""Reset state before each test."""
cleanup()
def teardown_method(self):
"""Clean up after each test."""
cleanup()
def test_extract_user_query_simple(self):
"""Test extracting user query from simple messages."""
callback = HindsightCallback()
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "What is the capital of France?"},
]
query = callback._extract_user_query(messages)
assert query == "What is the capital of France?"
def test_extract_user_query_from_last_user_message(self):
"""Test extracting query from last user message."""
callback = HindsightCallback()
messages = [
{"role": "user", "content": "First question"},
{"role": "assistant", "content": "First answer"},
{"role": "user", "content": "Second question"},
]
query = callback._extract_user_query(messages)
assert query == "Second question"
def test_extract_user_query_structured_content(self):
"""Test extracting query from structured content (vision)."""
callback = HindsightCallback()
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "What's in this image?"},
{"type": "image_url", "image_url": {"url": "http://example.com/img.png"}},
],
},
]
query = callback._extract_user_query(messages)
assert query == "What's in this image?"
def test_extract_user_query_multiple_text_parts(self):
"""Test extracting query with multiple text parts."""
callback = HindsightCallback()
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": "First part."},
{"type": "text", "text": "Second part."},
],
},
]
query = callback._extract_user_query(messages)
assert query == "First part. Second part."
def test_format_memories(self):
"""Test formatting memories into context string."""
callback = HindsightCallback()
# Create config and defaults with new API
configure(hindsight_api_url="http://localhost:8888", verbose=False)
set_defaults(bank_id="test", max_memories=10)
config = get_config()
defaults = get_defaults()
memories = [
{"text": "User likes Python", "fact_type": "world", "weight": 0.95},
{"text": "User works at Google", "fact_type": "world", "weight": 0.8},
]
# Signature is: _format_memories(results, settings, config)
formatted = callback._format_memories(memories, defaults, config)
assert "Relevant Memories" in formatted
assert "User likes Python" in formatted
assert "User works at Google" in formatted
assert "[WORLD]" in formatted
def test_format_memories_with_verbose(self):
"""Test formatting memories with verbose mode shows weights."""
callback = HindsightCallback()
# Create config and defaults with new API
configure(hindsight_api_url="http://localhost:8888", verbose=True)
set_defaults(bank_id="test", max_memories=10)
config = get_config()
defaults = get_defaults()
memories = [
{"text": "User likes Python", "fact_type": "world", "weight": 0.95},
]
# Signature is: _format_memories(results, settings, config)
formatted = callback._format_memories(memories, defaults, config)
assert "relevance: 0.95" in formatted
def test_inject_memories_as_system_message(self):
"""Test injecting memories as system message."""
callback = HindsightCallback()
configure(
hindsight_api_url="http://localhost:8888",
injection_mode=MemoryInjectionMode.SYSTEM_MESSAGE,
)
set_defaults(bank_id="test")
config = get_config()
messages = [
{"role": "user", "content": "Hello"},
]
memory_context = "# Relevant Memories\n1. User is John"
result = callback._inject_memories_into_messages(messages, memory_context, config)
assert len(result) == 2
assert result[0]["role"] == "system"
assert "Relevant Memories" in result[0]["content"]
assert result[1]["role"] == "user"
def test_inject_memories_prepend_to_existing_system(self):
"""Test injecting memories appends to existing system message."""
callback = HindsightCallback()
configure(
hindsight_api_url="http://localhost:8888",
injection_mode=MemoryInjectionMode.SYSTEM_MESSAGE,
)
set_defaults(bank_id="test")
config = get_config()
messages = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
]
memory_context = "# Relevant Memories\n1. User is John"
result = callback._inject_memories_into_messages(messages, memory_context, config)
assert len(result) == 2
assert result[0]["role"] == "system"
assert "You are helpful." in result[0]["content"]
assert "Relevant Memories" in result[0]["content"]
def test_inject_memories_prepend_user_mode(self):
"""Test injecting memories in prepend_user mode."""
callback = HindsightCallback()
configure(
hindsight_api_url="http://localhost:8888",
injection_mode=MemoryInjectionMode.PREPEND_USER,
)
set_defaults(bank_id="test")
config = get_config()
messages = [
{"role": "user", "content": "What's my name?"},
]
memory_context = "# Relevant Memories\n1. User is John"
result = callback._inject_memories_into_messages(messages, memory_context, config)
assert len(result) == 1
assert result[0]["role"] == "user"
assert "Relevant Memories" in result[0]["content"]
assert "What's my name?" in result[0]["content"]
def test_should_skip_model_exact_match(self):
"""Test model exclusion with exact match."""
callback = HindsightCallback()
configure(
hindsight_api_url="http://localhost:8888",
excluded_models=["gpt-3.5-turbo"],
)
set_defaults(bank_id="test")
config = get_config()
assert callback._should_skip_model("gpt-3.5-turbo", config) is True
assert callback._should_skip_model("gpt-4", config) is False
def test_should_skip_model_wildcard(self):
"""Test model exclusion with wildcard pattern."""
callback = HindsightCallback()
configure(
hindsight_api_url="http://localhost:8888",
excluded_models=["gpt-3.5*", "claude-instant-*"],
)
set_defaults(bank_id="test")
config = get_config()
assert callback._should_skip_model("gpt-3.5-turbo", config) is True
assert callback._should_skip_model("gpt-3.5-turbo-16k", config) is True
assert callback._should_skip_model("claude-instant-1.2", config) is True
assert callback._should_skip_model("gpt-4", config) is False
assert callback._should_skip_model("claude-3-opus", config) is False
class TestDeduplication:
"""Tests for conversation deduplication."""
def setup_method(self):
"""Reset state before each test."""
cleanup()
def teardown_method(self):
"""Clean up after each test."""
cleanup()
def test_compute_conversation_hash(self):
"""Test computing conversation hash."""
callback = HindsightCallback()
hash1 = callback._compute_conversation_hash("Hello", "Hi there!")
hash2 = callback._compute_conversation_hash("Hello", "Hi there!")
hash3 = callback._compute_conversation_hash("Hello", "Different response")
# Same content should produce same hash
assert hash1 == hash2
# Different content should produce different hash
assert hash1 != hash3
def test_compute_conversation_hash_case_insensitive(self):
"""Test that hash is case insensitive."""
callback = HindsightCallback()
hash1 = callback._compute_conversation_hash("HELLO", "HI THERE!")
hash2 = callback._compute_conversation_hash("hello", "hi there!")
assert hash1 == hash2
def test_is_duplicate_first_time(self):
"""Test first occurrence is not a duplicate."""
callback = HindsightCallback()
result = callback._is_duplicate("abc123")
assert result is False
def test_is_duplicate_second_time(self):
"""Test second occurrence is a duplicate."""
callback = HindsightCallback()
callback._is_duplicate("abc123") # First time
result = callback._is_duplicate("abc123") # Second time
assert result is True
def test_is_duplicate_different_hashes(self):
"""Test different hashes are not duplicates."""
callback = HindsightCallback()
callback._is_duplicate("abc123")
result = callback._is_duplicate("xyz789")
assert result is False
class TestContextManager:
"""Tests for the hindsight_memory context manager."""
def setup_method(self):
"""Reset state before each test."""
cleanup()
def teardown_method(self):
"""Clean up after each test."""
cleanup()
def test_context_manager_enables_and_disables(self):
"""Test context manager enables and disables correctly."""
from hindsight_litellm import hindsight_memory
assert is_enabled() is False
with hindsight_memory(bank_id="test-agent"):
assert is_enabled() is True
defaults = get_defaults()
assert defaults.bank_id == "test-agent"
assert is_enabled() is False
def test_context_manager_restores_previous_config(self):
"""Test context manager restores previous configuration."""
from hindsight_litellm import hindsight_memory
# Set up initial config
configure(hindsight_api_url="http://localhost:8888")
set_defaults(bank_id="original-agent")
enable()
assert get_defaults().bank_id == "original-agent"
# Use context manager with different config
with hindsight_memory(bank_id="temporary-agent"):
assert get_defaults().bank_id == "temporary-agent"
# Should restore original config
assert get_defaults().bank_id == "original-agent"
assert is_enabled() is True
def test_context_manager_with_fact_types(self):
"""Test context manager with fact_types parameter."""
from hindsight_litellm import hindsight_memory
with hindsight_memory(bank_id="test-agent", fact_types=["world", "opinion"]):
defaults = get_defaults()
assert defaults.fact_types == ["world", "opinion"]
class TestFactTypes:
"""Tests for fact_types configuration."""
def setup_method(self):
"""Reset config before each test."""
reset_config()
def teardown_method(self):
"""Clean up after each test."""
cleanup()
def test_configure_with_fact_types(self):
"""Test configuring with fact_types."""
configure(hindsight_api_url="http://localhost:8888")
defaults = set_defaults(
bank_id="test-agent",
fact_types=["world", "agent", "opinion"],
)
assert defaults.fact_types == ["world", "agent", "opinion"]
def test_configure_without_fact_types(self):
"""Test configuring without fact_types defaults to None."""
configure(hindsight_api_url="http://localhost:8888")
defaults = set_defaults(bank_id="test-agent")
assert defaults.fact_types is None
class TestSetDefaults:
"""Tests for set_defaults functionality."""
def setup_method(self):
"""Reset config before each test."""
reset_config()
def teardown_method(self):
"""Clean up after each test."""
cleanup()
def test_set_defaults_creates_defaults(self):
"""Test set_defaults creates a defaults object."""
defaults = set_defaults(bank_id="test-agent")
assert defaults is not None
assert defaults.bank_id == "test-agent"
def test_set_defaults_with_all_options(self):
"""Test set_defaults with all options."""
defaults = set_defaults(
bank_id="test-agent",
document_id="doc-123",
budget="high",
fact_types=["world", "opinion"],
max_memories=10,
max_memory_tokens=2048,
use_reflect=True,
reflect_include_facts=True,
reflect_context="I am a helpful assistant.",
include_entities=False,
trace=True,
)
assert defaults.bank_id == "test-agent"
assert defaults.document_id == "doc-123"
assert defaults.budget == "high"
assert defaults.fact_types == ["world", "opinion"]
assert defaults.max_memories == 10
assert defaults.max_memory_tokens == 2048
assert defaults.use_reflect is True
assert defaults.reflect_include_facts is True
assert defaults.reflect_context == "I am a helpful assistant."
assert defaults.include_entities is False
assert defaults.trace is True
def test_set_defaults_updates_existing(self):
"""Test set_defaults updates existing defaults."""
set_defaults(bank_id="first-agent", budget="low")
defaults = set_defaults(budget="high") # Only update budget
assert defaults.bank_id == "first-agent" # Preserved
assert defaults.budget == "high" # Updated
def test_get_defaults_returns_none_initially(self):
"""Test get_defaults returns None when not set."""
assert get_defaults() is None