Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 39 additions & 28 deletions agentkit/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
98 changes: 98 additions & 0 deletions tests/integration/test_api_deps_strict.py
Original file line number Diff line number Diff line change
@@ -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"
7 changes: 6 additions & 1 deletion tests/integration/test_api_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}

Expand Down Expand Up @@ -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"}
Expand Down
Loading