fleet-memory/hindsight/hindsight/server.py
csfet9 787ed60763
feat: Add Anthropic Claude and LM Studio provider support (#36)
* feat: Add Anthropic Claude and LM Studio provider support

- Add Anthropic as LLM provider with full async support
- Add LM Studio provider for local model inference
- Fix JSON response format compatibility for local models
- Update .env.example with configuration examples
- Update docstrings with all supported providers

Tested with:
- Claude Sonnet 4 (claude-sonnet-4-20250514)
- Claude Haiku 4.5 (claude-haiku-4-5-20251001)
- Qwen 30B via LM Studio

* feat: Add dynamic timeout for local LLM providers

Add configurable timeout support for LLM API calls:
- Environment variable override via HINDSIGHT_API_LLM_TIMEOUT
- Dynamic heuristic for lmstudio/ollama: 20 mins for large models
  (30b, 33b, 34b, 65b, 70b, 72b, 8x7b, 8x22b), 5 mins for others
- Pass timeout to Anthropic, OpenAI, and local model clients

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

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

* fix: Address PR review feedback

- Remove CLAUDE.md from .gitignore (should stay in repository)
- Pass max_completion_tokens to _call_anthropic instead of hardcoding 4096

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

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

* chore: Remove deleted AI assistant files from .gitignore

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

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

* docs: Add CLAUDE.md for Claude Code integration

Provides project context and development commands for AI-assisted coding.

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

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

* chore: Include local dev files and sync changes

- Add docker-compose.yml for local development
- Add test_internal.py for local testing
- Sync uv.lock and llm_wrapper.py changes

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

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

* fix: Address PR review feedback for LLM provider support

- Move LLM config to config.py with HINDSIGHT_API_ prefix
  - Add HINDSIGHT_API_LLM_MAX_CONCURRENT (default: 32)
  - Add HINDSIGHT_API_LLM_TIMEOUT (default: 120s)
- Remove fragile model-size timeout heuristic
- Apply markdown JSON extraction to all providers, not just local
- Fix Anthropic markdown extraction bug (missing split)
- Change LLM request/response logs from info to debug level

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

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

* chore: Remove local dev docker-compose.yml

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

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

* chore: Add local dev docker-compose.yml

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

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

* fix: Update LM Studio port to 2222 in docker-compose

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

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

* chore: Remove obsolete version attribute from docker-compose

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

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

* fix: Remove test file and docker-compose per PR review

- Remove test_internal.py (debug file)
- Remove docker-compose.yml (to be moved to hindsight-cookbook repo)

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

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:34:11 +01:00

280 lines
8.1 KiB
Python

"""
Server module for running Hindsight in a background thread.
Provides a simple way to start and stop the Hindsight HTTP API server
without blocking the main thread.
"""
import asyncio
import logging
import socket
import threading
import time
from typing import Optional
import uvicorn
from uvicorn import Config
from hindsight_api import MemoryEngine
from hindsight_api.api import create_app
logger = logging.getLogger(__name__)
def _find_free_port() -> int:
"""Find a free port on localhost."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("", 0))
s.listen(1)
port = s.getsockname()[1]
return port
class Server:
"""
Hindsight server that runs in a background thread.
Example:
```python
from hindsight import Server
server = Server(
db_url="pg0",
llm_provider="groq",
llm_api_key="your-api-key",
llm_model="openai/gpt-oss-120b"
)
server.start()
print(f"Server running at {server.url}")
# Use the server...
server.stop()
```
"""
def __init__(
self,
db_url: str = "pg0",
llm_provider: str = "groq",
llm_api_key: str = "",
llm_model: str = "openai/gpt-oss-120b",
llm_base_url: Optional[str] = None,
host: str = "127.0.0.1",
port: Optional[int] = None,
mcp_enabled: bool = False,
log_level: str = "info",
):
"""
Initialize the Hindsight server.
Args:
db_url: Database URL. Use "pg0" for embedded PostgreSQL.
llm_provider: LLM provider ("groq", "openai", "ollama", "gemini", "anthropic", "lmstudio")
llm_api_key: API key for the LLM provider
llm_model: Model name to use
llm_base_url: Optional custom base URL for LLM API
host: Host to bind to (default: 127.0.0.1)
port: Port to bind to (default: auto-select free port)
mcp_enabled: Whether to enable MCP server
log_level: Uvicorn log level (default: warning)
"""
self.db_url = db_url
self.llm_provider = llm_provider
self.llm_api_key = llm_api_key
self.llm_model = llm_model
self.llm_base_url = llm_base_url
self.host = host
self.port = port or _find_free_port()
self.mcp_enabled = mcp_enabled
self.log_level = log_level
self._memory: Optional[MemoryEngine] = None
self._server: Optional[uvicorn.Server] = None
self._thread: Optional[threading.Thread] = None
self._started = threading.Event()
self._stopped = threading.Event()
@property
def url(self) -> str:
"""Get the server URL."""
return f"http://{self.host}:{self.port}"
def _run_server(self):
"""Run the server in a background thread."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
# Create MemoryEngine
self._memory = MemoryEngine(
db_url=self.db_url,
memory_llm_provider=self.llm_provider,
memory_llm_api_key=self.llm_api_key,
memory_llm_model=self.llm_model,
memory_llm_base_url=self.llm_base_url,
)
# Create FastAPI app
app = create_app(
memory=self._memory,
mcp_api_enabled=self.mcp_enabled,
initialize_memory=True,
)
# Create uvicorn config and server
config = Config(
app=app,
host=self.host,
port=self.port,
log_level=self.log_level,
loop="asyncio",
)
self._server = uvicorn.Server(config)
# Signal that we're starting
self._started.set()
# Run the server
loop.run_until_complete(self._server.serve())
except Exception as e:
logger.error(f"Server error: {e}")
raise
finally:
# Cleanup
if self._memory:
loop.run_until_complete(self._memory.close())
loop.close()
self._stopped.set()
def start(self, timeout: float = 30.0) -> "Server":
"""
Start the server in a background thread.
Args:
timeout: Maximum time to wait for server to start (seconds)
Returns:
self (for chaining)
Raises:
RuntimeError: If server fails to start within timeout
"""
if self._thread is not None and self._thread.is_alive():
raise RuntimeError("Server is already running")
self._started.clear()
self._stopped.clear()
self._thread = threading.Thread(target=self._run_server, daemon=True)
self._thread.start()
# Wait for server to start
self._started.wait(timeout=timeout)
# Give uvicorn a moment to actually bind to the port
start_time = time.time()
while time.time() - start_time < timeout:
try:
with socket.create_connection((self.host, self.port), timeout=1):
logger.info(f"Hindsight server started at {self.url}")
return self
except (ConnectionRefusedError, socket.timeout, OSError):
time.sleep(0.1)
raise RuntimeError(f"Server failed to start within {timeout} seconds")
def stop(self, timeout: float = 10.0) -> None:
"""
Stop the server.
Args:
timeout: Maximum time to wait for server to stop (seconds)
"""
if self._server is None:
return
# Signal uvicorn to shutdown
self._server.should_exit = True
# Wait for thread to finish
if self._thread is not None:
self._thread.join(timeout=timeout)
if self._thread.is_alive():
logger.warning("Server thread did not stop cleanly")
self._server = None
self._thread = None
logger.info("Hindsight server stopped")
def __enter__(self) -> "Server":
"""Context manager entry."""
return self.start()
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
"""Context manager exit."""
self.stop()
def start_server(
db_url: str = "pg0",
llm_provider: str = "groq",
llm_api_key: str = "",
llm_model: str = "openai/gpt-oss-120b",
llm_base_url: Optional[str] = None,
host: str = "127.0.0.1",
port: Optional[int] = None,
mcp_enabled: bool = False,
log_level: str = "warning",
timeout: float = 30.0,
) -> Server:
"""
Start a Hindsight server in a background thread.
This is a convenience function that creates and starts a Server instance.
Args:
db_url: Database URL. Use "pg0" for embedded PostgreSQL.
llm_provider: LLM provider ("groq", "openai", "ollama", "gemini", "anthropic", "lmstudio")
llm_api_key: API key for the LLM provider
llm_model: Model name to use
llm_base_url: Optional custom base URL for LLM API
host: Host to bind to (default: 127.0.0.1)
port: Port to bind to (default: auto-select free port)
mcp_enabled: Whether to enable MCP server
log_level: Uvicorn log level (default: warning)
timeout: Maximum time to wait for server to start (seconds)
Returns:
Running Server instance
Example:
```python
from hindsight import start_server, Client
server = start_server(
db_url="pg0",
llm_provider="groq",
llm_api_key="your-api-key",
llm_model="openai/gpt-oss-120b"
)
client = Client(base_url=server.url)
client.put(agent_id="assistant", content="User likes Python")
server.stop()
```
"""
server = Server(
db_url=db_url,
llm_provider=llm_provider,
llm_api_key=llm_api_key,
llm_model=llm_model,
llm_base_url=llm_base_url,
host=host,
port=port,
mcp_enabled=mcp_enabled,
log_level=log_level,
)
return server.start(timeout=timeout)