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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ docker compose -f ../docker/docker-compose.yml up postgres redis temporal

# Start API (in a separate terminal)
cd backend
uvicorn app.main:create_app --reload --factory
uvicorn app.main:app --reload

# Start Frontend (in a separate terminal)
cd frontend
Expand Down
4 changes: 2 additions & 2 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ COPY . .
EXPOSE 8000

FROM base AS development
CMD ["uvicorn", "app.main:create_app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--factory"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

FROM base AS production
CMD ["uvicorn", "app.main:create_app", "--host", "0.0.0.0", "--port", "8000", "--factory"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
2 changes: 1 addition & 1 deletion backend/alembic/versions/006_create_attack_tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def upgrade() -> None:
"attack_runs",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("evaluation_run_id", sa.String(36), nullable=True, index=True),
sa.Column("status", sa.String(20), nullable=False, index=True),
sa.Column("status", sa.String(20), nullable=False),
sa.Column("attack_definition_ids", sa.JSON, nullable=False, server_default="[]"),
sa.Column("configuration", sa.JSON, nullable=False, server_default="{}"),
sa.Column("items_total", sa.Integer, nullable=False, server_default="0"),
Expand Down
2 changes: 1 addition & 1 deletion backend/alembic/versions/010_create_schedules_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from collections.abc import Sequence


revision: str = "010"
revision: str = "010_create_schedules"
down_revision: str | None = "009"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
Expand Down
2 changes: 1 addition & 1 deletion backend/alembic/versions/014_create_agent_runs_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

# revision identifiers
revision: str = "014"
down_revision: str | None = "013"
down_revision: str | None = "013_create_notifications"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None

Expand Down
12 changes: 10 additions & 2 deletions backend/app/agents/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@
RetryAgentRunHandler,
)
from app.agents.temporal.workflow import AgentRunWorkflow, AgentRunWorkflowInput
from app.core.dependencies import CurrentUser, get_current_user, get_db_session, get_temporal_client
from app.core.config import AppConfig
from app.core.dependencies import (
CurrentUser,
get_config_dependency,
get_current_user,
get_db_session,
get_temporal_client,
)
from app.infrastructure.database.repositories.agent_run_repository import (
SqlAlchemyAgentRunRepository,
)
Expand Down Expand Up @@ -114,6 +121,7 @@ async def create_agent_run(
current_user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
temporal_client: TemporalClient = Depends(get_temporal_client),
config: AppConfig = Depends(get_config_dependency),
) -> AgentRunResponse:
"""Create a new agent run and schedule its execution."""
repo = _get_repository(session)
Expand Down Expand Up @@ -143,7 +151,7 @@ async def create_agent_run(
total_steps=body.max_steps,
),
id=workflow_id,
task_queue="redops-agents",
task_queue=config.temporal_task_queue,
)

queue_handler = QueueAgentRunHandler(repo)
Expand Down
12 changes: 10 additions & 2 deletions backend/app/api/evaluation_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,14 @@
from sqlalchemy.ext.asyncio import AsyncSession
from temporalio.client import Client as TemporalClient

from app.core.dependencies import CurrentUser, get_current_user, get_db_session, get_temporal_client
from app.core.config import AppConfig
from app.core.dependencies import (
CurrentUser,
get_config_dependency,
get_current_user,
get_db_session,
get_temporal_client,
)
from app.evaluation.application.run_commands import (
CancelEvaluationRunCommand,
CreateEvaluationRunCommand,
Expand Down Expand Up @@ -141,6 +148,7 @@ async def create_run(
current_user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_db_session),
temporal_client: TemporalClient = Depends(get_temporal_client),
config: AppConfig = Depends(get_config_dependency),
) -> RunResponse:
"""Create a new evaluation run and schedule its execution."""
repo = _get_repository(session)
Expand Down Expand Up @@ -180,7 +188,7 @@ async def create_run(
system_prompt=body.system_prompt,
),
id=workflow_id,
task_queue="redops-evaluations",
task_queue=config.temporal_task_queue,
)

queue_handler = QueueEvaluationRunHandler(repo)
Expand Down
4 changes: 4 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ def app_logger(self) -> Any:
temporal_namespace: str = Field(default="default", alias="TEMPORAL_NAMESPACE")
temporal_task_queue: str = Field(default="redops-eval", alias="TEMPORAL_TASK_QUEUE")

# PROVIDER CREDENTIALS (optional; a provider is only registered when its key is set)
openai_api_key: str = Field(default="", alias="OPENAI_API_KEY")
anthropic_api_key: str = Field(default="", alias="ANTHROPIC_API_KEY")

# SECURITY
app_secret_key: str = Field(default="change-me", alias="APP_SECRET_KEY")

Expand Down
4 changes: 4 additions & 0 deletions backend/app/infrastructure/composition/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from app.infrastructure.composition.services import InfrastructureServices
from app.infrastructure.config.logging import LoggingConfiguration
from app.infrastructure.database.engine import DatabaseEngine
from app.infrastructure.observability.logging import configure_infrastructure_logging
from app.kernel.health.health import HealthRegistry, HealthReport
from app.kernel.service_registry.service_registry import ServiceRegistry
Expand Down Expand Up @@ -68,6 +69,9 @@ async def initialize(self) -> None:
self._service_registry = ServiceRegistry()
self._health_registry = HealthRegistry()

database_engine = self._di_container.resolve(DatabaseEngine)
await database_engine.initialize()

services = InfrastructureServices(
di_container=self._di_container,
service_registry=self._service_registry,
Expand Down
31 changes: 26 additions & 5 deletions backend/app/infrastructure/composition/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,10 @@
from app.kernel.container.di_container import DIContainer
from app.kernel.health.health import HealthRegistry
from app.kernel.registry.plugin import Plugin, PluginRegistry
from app.providers.anthropic.provider import AnthropicProvider
from app.providers.cost.calculator import CostCalculator
from app.providers.cost.defaults import build_default_cost_calculator
from app.providers.openai.provider import OpenAIProvider
from app.providers.registry.registry import ProviderRegistry

if TYPE_CHECKING:
Expand Down Expand Up @@ -250,14 +252,18 @@ def _register_temporal(self) -> None:
def _register_evaluation(self) -> None:
"""Register evaluation engine singletons.

ProviderRegistry and MetricEngine start empty; concrete
providers and metrics register themselves at startup via
plugin discovery. The CostCalculator ships with real
default pricing so cost estimates are never faked.
The ProviderRegistry is populated with the concrete providers whose
credentials are configured; providers without a key are simply not
registered so startup never fails on a missing optional key. The
MetricEngine starts empty; metrics are registered separately (Phase
B.2). The CostCalculator ships with real default pricing so cost
estimates are never faked.
"""
provider_registry = ProviderRegistry()
self._register_providers(provider_registry)
self._container.register_singleton(
ProviderRegistry,
lambda _c: ProviderRegistry(),
lambda _c: provider_registry,
)
self._container.register_singleton(
MetricEngine,
Expand All @@ -268,6 +274,21 @@ def _register_evaluation(self) -> None:
lambda _c: build_default_cost_calculator(),
)

def _register_providers(self, registry: ProviderRegistry) -> None:
"""Register configured providers into the shared registry.

Only providers whose API key is present are registered. OpenAI and
Anthropic read their keys from configuration (which loads the
OPENAI_API_KEY / ANTHROPIC_API_KEY environment variables), so an
absent optional key simply omits that provider rather than failing
startup.
"""
cfg = self._app_config
if cfg.openai_api_key:
registry.register(OpenAIProvider(api_key=cfg.openai_api_key))
if cfg.anthropic_api_key:
registry.register(AnthropicProvider(api_key=cfg.anthropic_api_key))

def _register_plugins(self) -> None:
"""Register plugin infrastructure components."""
self._container.register_singleton(
Expand Down
3 changes: 2 additions & 1 deletion backend/app/infrastructure/observability/prometheus.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

from typing import TYPE_CHECKING

from fastapi.responses import Response

if TYPE_CHECKING:
from fastapi import FastAPI

Expand All @@ -20,7 +22,6 @@ def setup_prometheus_metrics(app: FastAPI) -> None:
return

try:
from fastapi.responses import Response
from prometheus_client import (
Counter,
Gauge,
Expand Down
3 changes: 3 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ def create_app() -> FastAPI:
app = create_application()
setup_observability(app)
return app


app = create_app()
67 changes: 67 additions & 0 deletions backend/tests/integration/test_provider_registration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Tests for provider registry wiring via the existing provider architecture.

These tests prove that OpenAI/Anthropic are registered through the shared
ProviderRegistry when their credentials are configured, that missing
credentials do not crash startup, and that exactly one registry is used by
the evaluation execution path.
"""

from __future__ import annotations

import pytest

from app.core.config import AppConfig
from app.evaluation.temporal import activities as eval_activities
from app.infrastructure.composition.container import InfrastructureContainer
from app.providers.anthropic.provider import AnthropicProvider
from app.providers.openai.provider import OpenAIProvider
from app.providers.registry.registry import ProviderRegistry


def _build_registry(openai_key: str = "", anthropic_key: str = "") -> ProviderRegistry:
# AppConfig honors env *aliases* (OPENAI_API_KEY / ANTHROPIC_API_KEY),
# not field names, so pass the aliases explicitly.
cfg = AppConfig(OPENAI_API_KEY=openai_key, ANTHROPIC_API_KEY=anthropic_key)
container = InfrastructureContainer(cfg)
registry = ProviderRegistry()
container._register_providers(registry)
return registry


def test_openai_registered_when_key_present() -> None:
registry = _build_registry(openai_key="sk-test-openai")
assert registry.is_registered("openai")
assert isinstance(registry.resolve("openai"), OpenAIProvider)


def test_anthropic_registered_when_key_present() -> None:
registry = _build_registry(anthropic_key="sk-test-anthropic")
assert registry.is_registered("anthropic")
assert isinstance(registry.resolve("anthropic"), AnthropicProvider)


def test_both_registered_when_keys_present() -> None:
registry = _build_registry(openai_key="sk-openai", anthropic_key="sk-ant")
assert registry.count() == 2
assert isinstance(registry.resolve("openai"), OpenAIProvider)
assert isinstance(registry.resolve("anthropic"), AnthropicProvider)


def test_missing_credentials_do_not_crash_startup() -> None:
# No keys configured: registration is simply skipped, startup proceeds.
registry = _build_registry()
assert registry.count() == 0


def test_unknown_provider_raises_existing_error() -> None:
registry = ProviderRegistry()
registry.register(OpenAIProvider(api_key="sk-test-openai"))
with pytest.raises(KeyError, match="not registered"):
registry.resolve("anthropic")


def test_exactly_one_runtime_registry_used_by_execution_path() -> None:
registry = ProviderRegistry()
registry.register(OpenAIProvider(api_key="sk-test-openai"))
eval_activities.configure_provider_registry(registry)
assert eval_activities._provider_registry is registry
62 changes: 62 additions & 0 deletions backend/tests/integration/test_provider_resolution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Integration test for the evaluation runtime provider boundary.

Proves that ``execute_item_activity`` resolves the requested provider from the
shared ProviderRegistry and invokes its chat boundary, without requiring a
real LLM or a running Temporal server.
"""

from __future__ import annotations

import asyncio

from app.evaluation.temporal.activities import (
ExecuteItemInput,
configure_provider_registry,
execute_item_activity,
)
from app.providers.models.responses import ChatResponse, Usage
from app.providers.registry.registry import ProviderRegistry


class FakeProvider:
"""Minimal provider that records invocation and returns a canned response."""

provider_name = "openai"

def __init__(self) -> None:
self.called = False

async def chat(self, messages, *, model: str, options=None) -> ChatResponse:
self.called = True
return ChatResponse(
model=model,
provider="openai",
usage=Usage(input_tokens=10, output_tokens=5),
content="hello from fake provider",
)


def test_runtime_resolves_and_invokes_provider() -> None:
registry = ProviderRegistry()
fake = FakeProvider()
registry.register(fake)
configure_provider_registry(registry)

result = asyncio.run(
execute_item_activity(
ExecuteItemInput(
run_id="run-1",
item_index=0,
provider_name="openai",
model_id="gpt-4o",
prompt="hi",
prompt_template="{prompt}",
)
)
)

assert result.failed is False
assert result.response == "hello from fake provider"
assert result.tokens_input == 10
assert result.tokens_output == 5
assert fake.called is True
Loading
Loading