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
8 changes: 2 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,15 @@ jobs:
coverage:
name: Coverage
runs-on: ubuntu-latest
# Runs the suite with coverage. Until ticket #17 lands real source under
# src/, the template has no measurable coverage; pyproject.toml's
# [tool.coverage.report].fail_under stays at 75 (the eventual target),
# while CI uses --cov-fail-under=0 so the empty scaffold doesn't fail.
# When #17 + #18 ship real source + tests, drop the override here.
# Enforces [tool.coverage.report].fail_under from pyproject.toml (75%).
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.14"
- run: uv sync --frozen --extra dev
- run: uv run pytest tests/ --cov=src --cov-report=term-missing --cov-fail-under=0
- run: uv run pytest tests/ --cov=src --cov-report=term-missing

architecture:
name: Architecture (import-linter)
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ classifiers = [
]
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.34.0",
"pydantic>=2.11.0",
"pydantic-settings>=2.9.0",
"httpx>=0.28.1",
]

[project.optional-dependencies]
Expand Down
63 changes: 63 additions & 0 deletions src/api/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""harness-python-react — FastAPI application entry point."""

from __future__ import annotations

import logging
from contextlib import asynccontextmanager
from importlib.metadata import PackageNotFoundError, version
from typing import TYPE_CHECKING

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from src.api.routes import router as v1_router
from src.api.sessions import SessionStore

if TYPE_CHECKING:
from collections.abc import AsyncIterator

logger = logging.getLogger(__name__)


def _package_version() -> str:
"""Resolve the running package version, falling back when not installed.

`[tool.uv] package = false` skips installing the workspace as a Python
package, so `importlib.metadata.version()` raises in local dev. Tests +
`uvicorn --reload` should still boot; callers see ``0.0.0+local`` and the
Docker image (which DOES install the package) reports the real value.
"""
try:
return version("harness-python-react")
except PackageNotFoundError:
return "0.0.0+local"


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Application lifespan: initialise process-wide services on startup."""
app.state.session_store = SessionStore()
logger.info("harness-python-react API started (v%s)", _package_version())
yield
logger.info("harness-python-react API stopped")


app = FastAPI(
title="harness-python-react",
description="Production-quality LLM-driven coding harness — backend scaffold.",
version=_package_version(),
lifespan=lifespan,
)

# CORS — wide-open in the scaffold so the Vite dev server on :5173 can hit
# the backend on :8000 without preflight friction. Tighten via config in a
# real deployment.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)

app.include_router(v1_router)
56 changes: 56 additions & 0 deletions src/api/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Versioned API routes under /api/v1/.

Two endpoints in the scaffold:

- ``GET /api/v1/health`` — liveness + running package version.
- ``GET /api/v1/echo`` — returns the ``msg`` query param wrapped in a
``StrictModel``. Demonstrates the request/response
contract pattern that downstream tickets follow.

Session-store wiring is plumbed via ``request.app.state.session_store`` for
endpoints that need it (none in the scaffold; pattern is preserved for the
agent / chat endpoint a real project would add).
"""

from __future__ import annotations

from importlib.metadata import PackageNotFoundError, version

from fastapi import APIRouter
from pydantic import Field

from src.models._base import StrictModel
from src.models.health import HealthResponse


def _package_version() -> str:
try:
return version("harness-python-react")
except PackageNotFoundError:
return "0.0.0+local"


router = APIRouter(prefix="/api/v1")


class EchoResponse(StrictModel, strict=True):
"""Response body for `GET /api/v1/echo`."""

echoed: str = Field(description="Whatever the client sent in `?msg=`")


@router.get("/health")
async def health() -> HealthResponse:
"""Liveness signal + the running package version.

The version is sourced from ``importlib.metadata`` so the deployed
container can be correlated with a ``pyproject.toml`` revision and a
release tag without inspecting the image.
"""
return HealthResponse(status="ok", version=_package_version())


@router.get("/echo")
async def echo(msg: str) -> EchoResponse:
"""Echo the ``msg`` query parameter back as a typed response."""
return EchoResponse(echoed=msg)
63 changes: 63 additions & 0 deletions src/api/sessions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""In-memory session store — a portable harness pattern.

Single-process, GIL-protected. Each session holds its conversation history
as a list of message dicts compatible with the typical OpenAI chat
completions format. Not safe for multi-process deployments; replace with
Redis or a database for persistence and true concurrency safety.
"""

from __future__ import annotations

from datetime import UTC, datetime
from typing import Any
from uuid import UUID, uuid4

from src.models.session import SessionInfo


class SessionStore:
"""In-memory session store."""

def __init__(self) -> None:
self._sessions: dict[str, dict[str, Any]] = {}

def create(self) -> SessionInfo:
"""Create a new session with an empty conversation history."""
session_id = str(uuid4())
self._sessions[session_id] = {
"session_id": session_id,
"created_at": datetime.now(tz=UTC),
"messages": [],
}
return SessionInfo(
session_id=UUID(session_id),
created_at=self._sessions[session_id]["created_at"],
message_count=0,
)

def get(self, session_id: str) -> SessionInfo | None:
"""Get session info, or None if not found."""
data = self._sessions.get(session_id)
if data is None:
return None
return SessionInfo(
session_id=UUID(data["session_id"]),
created_at=data["created_at"],
message_count=len(data["messages"]),
)

def get_messages(self, session_id: str) -> list[dict[str, Any]] | None:
"""Get conversation history for a session, or None if not found."""
data = self._sessions.get(session_id)
if data is None:
return None
return list(data["messages"])

def set_messages(self, session_id: str, messages: list[dict[str, Any]]) -> None:
"""Replace the conversation history for a session."""
if session_id in self._sessions:
self._sessions[session_id]["messages"] = messages

def exists(self, session_id: str) -> bool:
"""Check if a session exists."""
return session_id in self._sessions
22 changes: 22 additions & 0 deletions src/models/_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Base class for every Pydantic contract that crosses a module or process seam.

Inheriting from ``StrictModel`` gives a contract ``extra="forbid"``: unknown
keys raise ``ValidationError`` at construction. Typos and renamed fields fail
immediately at the seam instead of silently surfacing three calls deep.

Classes that additionally want strict type checking (no implicit coercion —
e.g. rejecting ``"3.14"`` for a ``float`` field) opt in per-class via the
``strict=True`` keyword, e.g. ``class Foo(StrictModel, strict=True)``. This is
deliberate: models that cross the HTTP boundary need JSON coercion for UUIDs
and integers, while internal result contracts do not.
"""

from __future__ import annotations

from pydantic import BaseModel, ConfigDict


class StrictModel(BaseModel):
"""Base for every contract that crosses a module or process seam."""

model_config = ConfigDict(extra="forbid")
75 changes: 75 additions & 0 deletions src/models/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Application configuration — pluggable LLM provider via environment variables.

The agent / eval harness call into an LLM through a single seam configured
at startup. Keep the seam provider-agnostic so the template doesn't lock
users into a specific vendor: the four ``LLM_*`` environment variables below
are all that's required to point at OpenAI, Anthropic, Azure OpenAI, a
self-hosted vLLM endpoint, or any OpenAI-compatible gateway.

Wired through `pydantic-settings` so the same value precedence applies for
local dev (`.env`), Docker (env), and CI (secrets):

1. environment variable
2. .env file
3. default declared on the field below

A real production deployment would also want a vault-backed secret-manager
fetch for `LLM_API_KEY`; that's intentionally out of scope for the scaffold.
"""

from __future__ import annotations

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
"""Process-wide configuration loaded from environment variables."""

model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)

llm_provider: str = Field(
default="openai",
description=(
"Identifier for the LLM provider — used by the LLM-client seam to"
" select the right SDK adapter. Examples: openai, anthropic,"
" azure-openai, vllm."
),
)
llm_api_key: str = Field(
default="",
description=(
"API key passed to the LLM provider. Empty in local dev when no"
" LLM call is exercised; required when the eval harness or agent"
" loop runs."
),
)
llm_base_url: str | None = Field(
default=None,
description=(
"Optional base URL — set when pointing at Azure OpenAI, a"
" self-hosted vLLM endpoint, or any OpenAI-compatible gateway."
" Leave None to use the provider SDK's default."
),
)
llm_model: str = Field(
default="gpt-4o-mini",
description=(
"Model identifier passed to the provider. Defaults to a small,"
" inexpensive model so an accidental run doesn't burn credits."
),
)


def get_settings() -> Settings:
"""Construct a fresh Settings instance.

Not memoised — tests need to override env variables and re-construct.
Production callers should hold a single instance at app-startup.
"""
return Settings()
24 changes: 24 additions & 0 deletions src/models/health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Pydantic model for the `/api/v1/health` endpoint."""

from __future__ import annotations

from typing import Literal

from pydantic import Field

from src.models._base import StrictModel


class HealthResponse(StrictModel, strict=True):
"""Response body for `GET /api/v1/health`.

The `version` field is populated at request time from
`importlib.metadata.version("harness-python-react")` so the running
container can be correlated with a `pyproject.toml` revision and a
release tag without inspecting the image.
"""

status: Literal["ok"] = Field(
description="Liveness signal — always 'ok' if the process is responsive"
)
version: str = Field(description="Package version reported by importlib.metadata")
24 changes: 24 additions & 0 deletions src/models/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Pydantic models for session management."""

from __future__ import annotations

from datetime import datetime
from uuid import UUID

from pydantic import Field

from src.models._base import StrictModel


class SessionCreate(StrictModel, strict=True):
"""Request body for creating a new session (currently empty)."""


class SessionInfo(StrictModel, strict=True):
"""Public information about an existing session."""

session_id: UUID = Field(description="Unique session identifier")
created_at: datetime = Field(description="When the session was created")
message_count: int = Field(
default=0, description="Number of messages in the session"
)
20 changes: 20 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Pytest fixtures shared across the suite."""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest
from fastapi.testclient import TestClient

from src.api.main import app

if TYPE_CHECKING:
from collections.abc import Iterator


@pytest.fixture()
def client() -> Iterator[TestClient]:
"""FastAPI TestClient with the app's full lifespan exercised."""
with TestClient(app) as test_client:
yield test_client
Loading
Loading