import os from asyncio import Lock from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager from dataclasses import dataclass from threading import RLock from typing import TYPE_CHECKING, Annotated, Any, AsyncIterator, Callable, Optional from fastapi import Depends, FastAPI, Request from httpx import ASGITransport, AsyncClient, Timeout from loguru import logger import logfire from basic_memory.config import ConfigManager, ProjectMode if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker LocalDatabaseState = tuple["AsyncEngine", "async_sessionmaker[AsyncSession]"] _MISSING_STATE_VALUE = object() @dataclass class _PreparedLocalAsgiDatabase: active_count: int previous_engine: object previous_session_maker: object dependency_context: AbstractAsyncContextManager[LocalDatabaseState] _prepared_local_asgi_database_lock = RLock() _prepared_local_asgi_database_prepare_locks: dict[FastAPI, Lock] = {} _prepared_local_asgi_databases: dict[FastAPI, _PreparedLocalAsgiDatabase] = {} def _force_local_mode() -> bool: """Check if local mode is forced via environment variable.""" return os.environ.get("BASIC_MEMORY_FORCE_LOCAL", "").lower() in ("true", "1", "yes") def _force_cloud_mode() -> bool: """Check if cloud mode is forced via environment variable.""" return os.environ.get("BASIC_MEMORY_FORCE_CLOUD", "").lower() in ("true", "1", "yes") def _explicit_routing() -> bool: """Check if CLI --local/--cloud flag was explicitly passed.""" return os.environ.get("BASIC_MEMORY_EXPLICIT_ROUTING", "").lower() in ("true", "1", "yes") def _build_timeout() -> Timeout: """Create a standard timeout config used across all clients.""" return Timeout( connect=10.0, read=30.0, write=30.0, pool=30.0, ) def _build_asgi_client(app: FastAPI, timeout: Timeout) -> AsyncClient: """Create a local ASGI client for an already-prepared FastAPI app.""" from basic_memory.workspace_context import workspace_permalink_headers return AsyncClient( transport=ASGITransport(app=app), base_url="http://test", timeout=timeout, # Local ASGI calls still cross the HTTP boundary, so request handlers need # the same workspace permalink metadata that cloud proxy calls receive. headers=workspace_permalink_headers(), ) def _get_prepared_local_asgi_database_prepare_lock(app: FastAPI) -> Lock: """Get the async lock that serializes first-time DB preparation for an app.""" with _prepared_local_asgi_database_lock: prepare_lock = _prepared_local_asgi_database_prepare_locks.get(app) if prepare_lock is None: prepare_lock = Lock() _prepared_local_asgi_database_prepare_locks[app] = prepare_lock return prepare_lock @asynccontextmanager async def _resolve_local_asgi_database(app: FastAPI) -> AsyncIterator[LocalDatabaseState]: """Resolve database state for a local ASGI request.""" from fastapi.dependencies.utils import get_dependant, solve_dependencies from basic_memory.deps import get_engine_factory async def resolve_database_state( database_state: Annotated[LocalDatabaseState, Depends(get_engine_factory)], ) -> LocalDatabaseState: return database_state scope: dict[str, Any] = { "type": "http", "asgi": {"version": "3.0"}, "method": "GET", "scheme": "http", "path": "/", "raw_path": b"/", "root_path": "", "query_string": b"", "headers": [], "client": ("testclient", 50000), "server": ("testserver", 80), "app": app, "path_params": {}, } async with AsyncExitStack() as request_stack, AsyncExitStack() as function_stack: scope["fastapi_inner_astack"] = request_stack scope["fastapi_function_astack"] = function_stack request = Request(scope) dependant = get_dependant(path="/", call=resolve_database_state) solved = await solve_dependencies( request=request, dependant=dependant, dependency_overrides_provider=app, async_exit_stack=request_stack, embed_body_fields=False, ) if solved.errors: raise RuntimeError(f"Failed to resolve local ASGI database dependency: {solved.errors}") yield await resolve_database_state(**solved.values) def _retain_prepared_local_asgi_database(app: FastAPI) -> bool: """Retain an active local ASGI database preparation if one exists.""" with _prepared_local_asgi_database_lock: active = _prepared_local_asgi_databases.get(app) if active is None: return False active.active_count += 1 return True def _install_prepared_local_asgi_database( app: FastAPI, database_state: LocalDatabaseState, dependency_context: AbstractAsyncContextManager[LocalDatabaseState], ) -> None: """Install local ASGI database state after dependency resolution.""" with _prepared_local_asgi_database_lock: active = _prepared_local_asgi_databases.get(app) if active is not None: raise RuntimeError("Local ASGI database state installed while another state is active") previous_engine = getattr(app.state, "engine", _MISSING_STATE_VALUE) previous_session_maker = getattr(app.state, "session_maker", _MISSING_STATE_VALUE) engine, session_maker = database_state app.state.engine = engine app.state.session_maker = session_maker _prepared_local_asgi_databases[app] = _PreparedLocalAsgiDatabase( active_count=1, previous_engine=previous_engine, previous_session_maker=previous_session_maker, dependency_context=dependency_context, ) def _restore_local_asgi_state_attribute(app: FastAPI, name: str, previous_value: object) -> None: """Restore a FastAPI app.state attribute captured before local ASGI preparation.""" if previous_value is _MISSING_STATE_VALUE: if hasattr(app.state, name): delattr(app.state, name) else: setattr(app.state, name, previous_value) def _release_prepared_local_asgi_database( app: FastAPI, ) -> AbstractAsyncContextManager[LocalDatabaseState] | None: """Release local ASGI database state after a client context exits.""" with _prepared_local_asgi_database_lock: active = _prepared_local_asgi_databases.get(app) if active is None: raise RuntimeError("Local ASGI database state released without a matching retain") active.active_count -= 1 if active.active_count > 0: return None del _prepared_local_asgi_databases[app] _restore_local_asgi_state_attribute(app, "engine", active.previous_engine) _restore_local_asgi_state_attribute( app, "session_maker", active.previous_session_maker, ) return active.dependency_context @asynccontextmanager async def _prepared_local_asgi_database(app: FastAPI) -> AsyncIterator[None]: """Initialize local ASGI database state before the first request.""" prepare_lock = _get_prepared_local_asgi_database_prepare_lock(app) async with prepare_lock: if not _retain_prepared_local_asgi_database(app): database_context = _resolve_local_asgi_database(app) database_state = await database_context.__aenter__() try: _install_prepared_local_asgi_database(app, database_state, database_context) except Exception: await database_context.__aexit__(None, None, None) raise try: yield finally: database_context = _release_prepared_local_asgi_database(app) if database_context is not None: await database_context.__aexit__(None, None, None) @asynccontextmanager async def _asgi_client(timeout: Timeout) -> AsyncIterator[AsyncClient]: """Create a local ASGI client.""" # Import on first local-client use so CLI help/version paths can import # routing helpers without constructing the full FastAPI router graph. from basic_memory.api.app import app as fastapi_app # Trigger: local ASGITransport does not execute FastAPI lifespan startup. # Why: letting request dependencies initialize Postgres can run asyncpg DDL # under Starlette's request loop and trigger CPython's empty-ready-queue race. # Outcome: request handling sees the same app.state database objects as API # lifespan startup would have provided. async with _prepared_local_asgi_database(fastapi_app): async with _build_asgi_client(fastapi_app, timeout) as client: yield client async def _resolve_cloud_token(config) -> str: """Resolve cloud token with API key preferred, OAuth fallback.""" with logfire.span( "routing.resolve_cloud_credentials", has_api_key=bool(config.cloud_api_key), ): token = config.cloud_api_key if token: return token from basic_memory.cli.auth import CLIAuth auth = CLIAuth(client_id=config.cloud_client_id, authkit_domain=config.cloud_domain) token = await auth.get_valid_token() if token: return token logger.error("Cloud routing requested but no credentials were available") raise RuntimeError( "Cloud routing requested but no credentials found. " "Run 'bm cloud api-key save ' or 'bm cloud login' first." ) def resolve_configured_workspace( *, config=None, project_name: Optional[str] = None, workspace: Optional[str] = None, ) -> Optional[str]: """Resolve workspace from explicit input, per-project config, then global default.""" if workspace is not None: return workspace if config is None: config = ConfigManager().config if project_name is not None: project_entry = config.projects.get(project_name) if project_entry and project_entry.workspace_id: return project_entry.workspace_id return config.default_workspace @asynccontextmanager async def _cloud_client( config, timeout: Timeout, workspace: Optional[str] = None, ) -> AsyncIterator[AsyncClient]: """Create a cloud proxy client with resolved credentials.""" from basic_memory.workspace_context import workspace_permalink_headers token = await _resolve_cloud_token(config) proxy_base_url = f"{config.cloud_host}/proxy" headers = {"Authorization": f"Bearer {token}"} headers.update(workspace_permalink_headers()) if workspace: headers["X-Workspace-ID"] = workspace logger.info(f"Creating HTTP client for cloud proxy at: {proxy_base_url}") async with AsyncClient( base_url=proxy_base_url, headers=headers, timeout=timeout, ) as client: yield client @asynccontextmanager async def get_cloud_control_plane_client( workspace: Optional[str] = None, ) -> AsyncIterator[AsyncClient]: """Create a control-plane cloud client for endpoints outside /proxy.""" config = ConfigManager().config timeout = _build_timeout() token = await _resolve_cloud_token(config) headers = {"Authorization": f"Bearer {token}"} if workspace: headers["X-Workspace-ID"] = workspace logger.info(f"Creating HTTP client for cloud control plane at: {config.cloud_host}") async with AsyncClient( base_url=config.cloud_host, headers=headers, timeout=timeout, ) as client: yield client # Optional factory override for dependency injection. # The factory accepts an optional workspace keyword argument so that MCP tools # can route individual requests to a different workspace than the one set at # connection time. See basic-memory-cloud main.py tenant_asgi_client_factory. _client_factory: Optional[Callable[..., AbstractAsyncContextManager[AsyncClient]]] = None def set_client_factory(factory: Callable[..., AbstractAsyncContextManager[AsyncClient]]) -> None: """Override the default client factory (for cloud app, testing, etc).""" global _client_factory _client_factory = factory def is_factory_mode() -> bool: """Return True when a client factory override is active (e.g., cloud app).""" return _client_factory is not None @asynccontextmanager async def get_cloud_proxy_client( workspace: Optional[str] = None, ) -> AsyncIterator[AsyncClient]: """Create a cloud proxy client for project-level operations. Used by MCP tools to fetch cloud project lists independently of the default get_client() routing, which always goes through the local ASGI transport in stdio mode. """ config = ConfigManager().config timeout = _build_timeout() async with _cloud_client(config, timeout, workspace=workspace) as client: yield client @asynccontextmanager async def get_client( project_name: Optional[str] = None, workspace: Optional[str] = None, ) -> AsyncIterator[AsyncClient]: """Get an AsyncClient as a context manager. Routing priority: 1. Factory injection. 2. Explicit routing flags (--local/--cloud). 3. Per-project mode routing when project_name is provided. 4. Local ASGI transport by default. """ if _client_factory: async with _client_factory(workspace=workspace) as client: yield client return config = ConfigManager().config timeout = _build_timeout() # --- Explicit routing override --- # Trigger: user passed --local/--cloud. # Why: command-level override should be deterministic and bypass project mode. # Outcome: route strictly based on explicit flag. if _explicit_routing(): if _force_local_mode(): logger.debug("Explicit local routing enabled - using ASGI client") async with _asgi_client(timeout) as client: yield client return if _force_cloud_mode(): logger.debug("Explicit cloud routing enabled - using cloud proxy client") effective_workspace = resolve_configured_workspace( config=config, project_name=project_name, workspace=workspace, ) async with _cloud_client(config, timeout, workspace=effective_workspace) as client: yield client return # --- Per-project routing --- # Trigger: project_name provided without explicit routing override. # Why: project mode is the source of truth for project-scoped commands. # Outcome: route via project.mode (CLOUD/LOCAL). if project_name is not None and not _explicit_routing(): project_mode = config.get_project_mode(project_name) if project_mode == ProjectMode.CLOUD: logger.debug(f"Project '{project_name}' is cloud mode - using cloud proxy client") effective_workspace = resolve_configured_workspace( config=config, project_name=project_name, workspace=workspace, ) try: async with _cloud_client(config, timeout, workspace=effective_workspace) as client: yield client except RuntimeError as exc: raise RuntimeError( f"Project '{project_name}' is set to cloud mode but no credentials found. " "Run 'bm cloud api-key save ' or 'bm cloud login' first." ) from exc return logger.debug(f"Project '{project_name}' is local mode - using ASGI client") async with _asgi_client(timeout) as client: yield client return # --- Default fallback --- logger.debug("Default routing - using ASGI client for local Basic Memory API") async with _asgi_client(timeout) as client: yield client def create_client() -> AsyncClient: """Create an HTTP client based on explicit routing flags. DEPRECATED: Use get_client() context manager instead for proper resource management. """ timeout = _build_timeout() if _force_local_mode() or not _force_cloud_mode(): logger.info("Creating ASGI client for local Basic Memory API") # Deprecated sync path: create_client() cannot await the local ASGI # pre-initialization used by get_client(), so callers that need proper # resource setup should use the async context manager instead. from basic_memory.api.app import app as fastapi_app return _build_asgi_client(fastapi_app, timeout) logger.info("Creating HTTP client for cloud proxy (legacy create_client path)") config = ConfigManager().config proxy_base_url = f"{config.cloud_host}/proxy" return AsyncClient(base_url=proxy_base_url, timeout=timeout)