* feat: introduce hindsight-api-slim and hindsight-all-slim packages Closes #552 - Move all source code from hindsight-api/ to new hindsight-api-slim/ - hindsight-api-slim has heavy ML deps (torch, sentence-transformers, transformers, einops, flashrank, mlx, mlx-lm, safetensors) and pg0-embedded as optional extras: [local-ml], [embedded-db], [all] - hindsight-api becomes a zero-code meta-package depending on hindsight-api-slim[all] for full backward compatibility - Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed - hindsight-all updated to depend on hindsight-api-slim[all] - pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db] - Dockerfile: replace sed hack with proper uv sync --extra flags - Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and all path references throughout the repo * refactor: rename hindsight/ directory to hindsight-all/ * docs: document hindsight-api-slim and hindsight-all-slim package variants Add package variants table and extras explanation to installation.md * docs: remove emojis from installation.md, use professional tone * docs: link Docker slim variant to pip package variants section * docs: consolidate Docker image variants into single table * ci: fix working-directory paths after package restructure - Replace all hindsight-api → hindsight-api-slim in test.yml - Replace hindsight → hindsight-all in test.yml - Add --extra embedded-db to test-embed API install step * ci: add local-ml and embedded-db extras to API sync steps These extras were previously implicit in the old hindsight-api package (which bundled everything). Now that hindsight-api-slim uses optional extras, we must explicitly request local-ml and embedded-db in CI. * ci: add API install step with embedded-db to test-embed smoke test The smoke test starts hindsight-api as a daemon, which requires pg0-embedded. Add a dedicated install step for hindsight-api-slim with embedded-db extra so the daemon can start successfully. * ci: remove --no-install-project when using optional extras When --no-install-project is combined with --extra, the optional deps are not installed because extras require the project to be active. Remove --no-install-project from steps that need local-ml or embedded-db. * ci: fix ordering of uv sync steps to preserve optional extras When uv sync runs for a different workspace member, it removes optional extras installed for other members. Fix by always running extra-requiring API sync last, after other workspace member syncs. Also remove --no-install-project from embedded-db sync in test-embed, as --no-install-project prevents optional extras from being active. * ci: add local-ml extra to test-embed API install for smoke test The smoke test starts the full API server which needs sentence-transformers for local embeddings (default provider). Add local-ml extra to the install. * ci: simplify extras with --all-extras and add slim pip smoke test - Replace explicit --extra local-ml --extra embedded-db with --all-extras for cleaner, more maintainable sync steps - Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without local ML models, using Cohere for embeddings/reranking (mirrors Docker slim smoke test approach) * ci: simplify slim smoke test to health check only (mirrors Docker test)
189 lines
6.6 KiB
Python
189 lines
6.6 KiB
Python
"""
|
|
Integration test for API base path support.
|
|
|
|
Tests that the API works correctly when deployed with a base path (e.g., /hindsight)
|
|
for reverse proxy deployments.
|
|
"""
|
|
import os
|
|
import pytest
|
|
import pytest_asyncio
|
|
import httpx
|
|
from hindsight_api.api import create_app
|
|
from hindsight_api.config import clear_config_cache
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def api_client_with_base_path(memory):
|
|
"""Create an async test client for the FastAPI app with a base path."""
|
|
# Set base path in environment
|
|
base_path = "/hindsight"
|
|
os.environ["HINDSIGHT_API_BASE_PATH"] = base_path
|
|
|
|
# Clear config cache to force reload with new base_path
|
|
clear_config_cache()
|
|
|
|
# Memory is already initialized by the conftest fixture (with migrations)
|
|
app = create_app(memory, initialize_memory=False)
|
|
|
|
# Use base_url with base path
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(
|
|
transport=transport,
|
|
base_url=f"http://test{base_path}"
|
|
) as client:
|
|
yield client
|
|
|
|
# Cleanup: unset base path
|
|
os.environ.pop("HINDSIGHT_API_BASE_PATH", None)
|
|
clear_config_cache()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def api_client_without_base_path(memory):
|
|
"""Create an async test client for the FastAPI app without a base path (root)."""
|
|
# Ensure no base path is set
|
|
os.environ.pop("HINDSIGHT_API_BASE_PATH", None)
|
|
clear_config_cache()
|
|
|
|
app = create_app(memory, initialize_memory=False)
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
yield client
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_path_health_endpoint(api_client_with_base_path):
|
|
"""Test that health endpoint works with base path."""
|
|
# With base path set to /hindsight, health should be at /hindsight/health
|
|
# But since our client base_url is already http://test/hindsight, we request /health
|
|
response = await api_client_with_base_path.get("/health")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "status" in data
|
|
assert data["status"] in ["ok", "healthy"] # Accept both formats
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_path_banks_endpoint(api_client_with_base_path):
|
|
"""Test that banks endpoint works with base path."""
|
|
response = await api_client_with_base_path.get("/v1/default/banks")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "banks" in data
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_path_openapi_schema(api_client_with_base_path):
|
|
"""Test that OpenAPI schema includes correct base path in servers."""
|
|
response = await api_client_with_base_path.get("/openapi.json")
|
|
assert response.status_code == 200
|
|
openapi_schema = response.json()
|
|
|
|
# Check that servers array includes base path
|
|
assert "servers" in openapi_schema
|
|
servers = openapi_schema["servers"]
|
|
assert len(servers) > 0
|
|
# FastAPI should set server URL to the root_path
|
|
assert servers[0]["url"] == "/hindsight"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_path_docs_redirect(api_client_with_base_path):
|
|
"""Test that /docs redirects correctly with base path."""
|
|
# FastAPI docs endpoint should work
|
|
response = await api_client_with_base_path.get("/docs", follow_redirects=False)
|
|
# Should either return 200 (direct) or 307 (redirect to trailing slash)
|
|
assert response.status_code in [200, 307]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_path_metrics(api_client_with_base_path):
|
|
"""Test that metrics endpoint works with base path."""
|
|
response = await api_client_with_base_path.get("/metrics")
|
|
assert response.status_code == 200
|
|
# Metrics should be in Prometheus format
|
|
assert "# HELP" in response.text or "# TYPE" in response.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_base_path_full_workflow(api_client_with_base_path):
|
|
"""
|
|
Test a full retain/recall workflow with base path.
|
|
|
|
This ensures that all memory operations work correctly when the API
|
|
is deployed with a base path.
|
|
"""
|
|
bank_id = "test_base_path_bank"
|
|
|
|
# 1. Create/get bank
|
|
response = await api_client_with_base_path.get(f"/v1/default/banks/{bank_id}/profile")
|
|
assert response.status_code == 200
|
|
|
|
# 2. Store a memory
|
|
response = await api_client_with_base_path.post(
|
|
f"/v1/default/banks/{bank_id}/memories",
|
|
json={
|
|
"items": [
|
|
{
|
|
"content": "The API supports base path deployment for reverse proxy use cases.",
|
|
"context": "testing base path feature"
|
|
}
|
|
]
|
|
}
|
|
)
|
|
assert response.status_code == 200
|
|
result = response.json()
|
|
assert result["success"] is True
|
|
|
|
# 3. Recall the memory
|
|
response = await api_client_with_base_path.post(
|
|
f"/v1/default/banks/{bank_id}/memories/recall",
|
|
json={
|
|
"query": "base path support"
|
|
}
|
|
)
|
|
assert response.status_code == 200
|
|
recall_result = response.json()
|
|
# API returns "results" not "memories"
|
|
assert "results" in recall_result
|
|
assert len(recall_result["results"]) > 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_without_base_path_still_works(api_client_without_base_path):
|
|
"""
|
|
Regression test: ensure default behavior (no base path) still works.
|
|
|
|
This test verifies that when HINDSIGHT_API_BASE_PATH is not set,
|
|
the API works at the root path as before.
|
|
"""
|
|
# Health check at root
|
|
response = await api_client_without_base_path.get("/health")
|
|
assert response.status_code == 200
|
|
|
|
# Banks endpoint at root
|
|
response = await api_client_without_base_path.get("/v1/default/banks")
|
|
assert response.status_code == 200
|
|
|
|
# OpenAPI schema should have empty or "/" server path
|
|
response = await api_client_without_base_path.get("/openapi.json")
|
|
assert response.status_code == 200
|
|
openapi_schema = response.json()
|
|
servers = openapi_schema.get("servers", [])
|
|
if servers:
|
|
# Server URL should be empty string (root) or "/"
|
|
assert servers[0]["url"] in ["", "/"]
|
|
|
|
|
|
@pytest.mark.skip(reason="MCP endpoint routing with base path needs investigation")
|
|
@pytest.mark.asyncio
|
|
async def test_base_path_mcp_endpoint(api_client_with_base_path):
|
|
"""Test that MCP endpoint is accessible with base path."""
|
|
bank_id = "test_mcp_bank"
|
|
|
|
# MCP endpoint should be mounted at /mcp/{bank_id}/
|
|
# The MCP server uses a different protocol, so just check the root exists
|
|
response = await api_client_with_base_path.get(f"/mcp/{bank_id}/")
|
|
# MCP may return various status codes, but should not be 404 (not found)
|
|
# Accept 405 (method not allowed), 400 (bad request), etc.
|
|
assert response.status_code != 404, "MCP endpoint should exist"
|