diff --git a/agentkit/api/deps.py b/agentkit/api/deps.py index b09fea3..cdf6287 100644 --- a/agentkit/api/deps.py +++ b/agentkit/api/deps.py @@ -14,11 +14,10 @@ from agentkit.core.agent import Agent, AgentConfig from agentkit.core.errors import ServiceUnavailableError from agentkit.core.pg_trace import PostgresTraceSink -from agentkit.core.trace import InMemoryTraceSink, TraceSink +from agentkit.core.trace import TraceSink from agentkit.llm.base import LLMClient from agentkit.llm.factory import create_llm_client_from_settings -from agentkit.llm.fake import FakeLLMClient -from agentkit.memory.base import InMemoryMemoryStore, MemoryStore +from agentkit.memory.base import MemoryStore from agentkit.memory.pg_store import PostgresMemoryStore from agentkit.memory.redis_store import RedisMemoryStore from agentkit.memory.tiered import TieredMemoryStore @@ -61,42 +60,56 @@ def get_redis_client(request: Request) -> Redis | None: def get_memory_store(request: Request) -> PostgresMemoryStore | MemoryStore: - """Retrieve or construct the persistence memory store.""" + """Retrieve or construct the persistence memory store. + + Requires live PostgreSQL and Redis infrastructure in production. Explicit test + overrides can be supplied via request.app.state.memory_store. + """ if hasattr(request.app.state, "memory_store") and request.app.state.memory_store is not None: store: PostgresMemoryStore | MemoryStore = request.app.state.memory_store return store factory = get_session_factory(request) + if factory is None: + raise ServiceUnavailableError( + "database", + "Database is not configured. Session factory is absent from application state.", + ) + redis_client = get_redis_client(request) + if redis_client is None: + raise ServiceUnavailableError( + "redis", + "Redis is not configured. Redis client is absent from application state.", + ) + settings: Settings = getattr(request.app.state, "settings", None) or get_current_settings( request ) - run_store: PostgresMemoryStore | InMemoryMemoryStore = ( - PostgresMemoryStore(factory) if factory is not None else InMemoryMemoryStore() - ) - - if redis_client is not None: - session_store = RedisMemoryStore(redis_client, ttl_s=settings.SESSION_TTL_S) - return TieredMemoryStore(session_store=session_store, run_store=run_store) - - if isinstance(run_store, PostgresMemoryStore): - return TieredMemoryStore(session_store=InMemoryMemoryStore(), run_store=run_store) - - return run_store + run_store = PostgresMemoryStore(factory) + session_store = RedisMemoryStore(redis_client, ttl_s=settings.SESSION_TTL_S) + return TieredMemoryStore(session_store=session_store, run_store=run_store) def get_trace_sink(request: Request) -> TraceSink: - """Retrieve or construct the step trace telemetry sink.""" + """Retrieve or construct the step trace telemetry sink. + + Requires live PostgreSQL infrastructure in production. Explicit test overrides + can be supplied via request.app.state.trace_sink. + """ if hasattr(request.app.state, "trace_sink") and request.app.state.trace_sink is not None: sink: TraceSink = request.app.state.trace_sink return sink factory = get_session_factory(request) - if factory is not None: - return PostgresTraceSink(factory) + if factory is None: + raise ServiceUnavailableError( + "database", + "Database is not configured. Session factory is absent from application state.", + ) - return InMemoryTraceSink() + return PostgresTraceSink(factory) def create_default_tool_registry() -> ToolRegistry: @@ -125,18 +138,16 @@ def get_llm_client( request: Request, settings: Settings = Depends(get_current_settings), ) -> LLMClient: - """Resolve configured LLM client.""" + """Resolve configured LLM client. + + Instantiates the configured provider from settings. Explicit test overrides + can be supplied via request.app.state.llm_client. + """ if hasattr(request.app.state, "llm_client") and request.app.state.llm_client is not None: client: LLMClient = request.app.state.llm_client return client - try: - return create_llm_client_from_settings(settings) - except Exception as exc: - logger.warning( - "Failed to initialize configured LLM client (%s); falling back to FakeLLMClient", exc - ) - return FakeLLMClient() + return create_llm_client_from_settings(settings) def get_agent( diff --git a/tests/integration/test_api_deps_strict.py b/tests/integration/test_api_deps_strict.py new file mode 100644 index 0000000..188eb0f --- /dev/null +++ b/tests/integration/test_api_deps_strict.py @@ -0,0 +1,98 @@ +"""Integration tests for strict infrastructure resolution in FastAPI dependencies.""" + +import uuid +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from agentkit.api.deps import ( + get_llm_client, + get_memory_store, + get_trace_sink, +) +from agentkit.api.main import create_app +from agentkit.config import Settings +from agentkit.core.errors import AuthenticationError, ServiceUnavailableError +from agentkit.llm.fake import FakeLLMClient + + +class MockRequest: + """Minimal mock request with customizable app state.""" + + def __init__(self, app_state: dict[str, Any] | None = None) -> None: + class State: + pass + + self.app = type("App", (), {"state": State()})() + if app_state: + for k, v in app_state.items(): + setattr(self.app.state, k, v) + + +def test_memory_store_raises_when_database_factory_absent() -> None: + """get_memory_store must raise ServiceUnavailableError for database if session_factory is missing.""" + req = MockRequest() + with pytest.raises(ServiceUnavailableError) as exc_info: + get_memory_store(req) # type: ignore[arg-type] + assert exc_info.value.service == "database" + + +def test_memory_store_raises_when_redis_client_absent() -> None: + """get_memory_store must raise ServiceUnavailableError for redis if redis_client is missing.""" + dummy_factory = object() + req = MockRequest(app_state={"session_factory": dummy_factory}) + with pytest.raises(ServiceUnavailableError) as exc_info: + get_memory_store(req) # type: ignore[arg-type] + assert exc_info.value.service == "redis" + + +def test_trace_sink_raises_when_database_factory_absent() -> None: + """get_trace_sink must raise ServiceUnavailableError if session_factory is missing.""" + req = MockRequest() + with pytest.raises(ServiceUnavailableError) as exc_info: + get_trace_sink(req) # type: ignore[arg-type] + assert exc_info.value.service == "database" + + +def test_llm_client_does_not_silently_fallback_to_fake() -> None: + """get_llm_client must raise error if live provider fails rather than returning FakeLLMClient.""" + settings = Settings( + API_KEYS="secret_token", + LLM_PROVIDER="gemini", + GEMINI_API_KEY="", + ) + req = MockRequest() + # If GEMINI_API_KEY is empty/invalid and ADC fails, it must raise AuthenticationError, not return FakeLLMClient + try: + client = get_llm_client(req, settings=settings) # type: ignore[arg-type] + assert not isinstance(client, FakeLLMClient), ( + "get_llm_client must not return FakeLLMClient when LLM_PROVIDER is 'gemini'" + ) + except AuthenticationError: + pass # Expected authentic error, not swallowed into FakeLLMClient + + +def test_llm_client_returns_fake_when_explicitly_configured() -> None: + """get_llm_client returns FakeLLMClient only when LLM_PROVIDER='fake' is explicitly chosen.""" + settings = Settings( + API_KEYS="secret_token", + LLM_PROVIDER="fake", + ) + req = MockRequest() + client = get_llm_client(req, settings=settings) # type: ignore[arg-type] + assert isinstance(client, FakeLLMClient) + + +def test_http_endpoint_returns_503_when_infrastructure_missing() -> None: + """HTTP requests against endpoints requiring memory or trace return 503 if services are missing.""" + settings = Settings(API_KEYS="secret_token") + app = create_app(settings=settings) + client = TestClient(app, raise_server_exceptions=False) + headers = {"X-API-Key": "secret_token"} + + # GET /runs/{run_id} needs get_memory_store -> should fail with 503 SERVICE_UNAVAILABLE + resp = client.get(f"/runs/{uuid.uuid4()}", headers=headers) + assert resp.status_code == 503 + data = resp.json() + assert data["error"]["code"] == "SERVICE_UNAVAILABLE" diff --git a/tests/integration/test_api_runs.py b/tests/integration/test_api_runs.py index 7a1be03..b79a639 100644 --- a/tests/integration/test_api_runs.py +++ b/tests/integration/test_api_runs.py @@ -5,11 +5,12 @@ from fastapi.testclient import TestClient from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine -from agentkit.api.deps import get_agent, get_memory_store +from agentkit.api.deps import get_agent, get_llm_client, get_memory_store, get_trace_sink from agentkit.api.main import create_app from agentkit.config import Settings from agentkit.core.agent import Agent, AgentConfig from agentkit.core.pg_trace import PostgresTraceSink +from agentkit.core.trace import InMemoryTraceSink from agentkit.db.models import Base from agentkit.llm.base import LLMResponse from agentkit.llm.fake import FakeLLMClient @@ -48,6 +49,9 @@ def test_runs_auth_required() -> None: def test_runs_validation_error() -> None: settings = Settings(API_KEYS="secret_token") app = create_app(settings=settings) + app.dependency_overrides[get_memory_store] = lambda: InMemoryMemoryStore() + app.dependency_overrides[get_trace_sink] = lambda: InMemoryTraceSink() + app.dependency_overrides[get_llm_client] = lambda: FakeLLMClient() client = TestClient(app, raise_server_exceptions=False) headers = {"X-API-Key": "secret_token"} @@ -144,6 +148,7 @@ def test_get_nonexistent_run() -> None: app = create_app(settings=settings) memory_store = InMemoryMemoryStore() app.dependency_overrides[get_memory_store] = lambda: memory_store + app.dependency_overrides[get_trace_sink] = lambda: InMemoryTraceSink() client = TestClient(app, raise_server_exceptions=False) headers = {"X-API-Key": "secret_token"}