diff --git a/.gitignore b/.gitignore index 620150d..b6930c0 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,5 @@ __marimo__/ # Streamlit .streamlit/secrets.toml +ai_engine/gateway/.env +ai_engine/ollama/models/ diff --git a/ai_engine/README.md b/ai_engine/README.md new file mode 100644 index 0000000..b322a4e --- /dev/null +++ b/ai_engine/README.md @@ -0,0 +1,26 @@ +# LoopHarvest AI Engine + +Base local AI API stack for LoopHarvest using FastAPI, Ollama, and Docker Compose. + +## Structure + +```text +ai_engine/ + docker-compose.yml + gateway/ + Dockerfile + requirements.txt + pyproject.toml + .env.example + src/ + tests/ + ollama/ + models/ +``` + +## Quick start + +1. Copy `gateway/.env.example` to `gateway/.env` and set `MY_API_KEY`. +2. Run `docker compose up -d` from `ai_engine/`. +3. Pull a model with `docker exec loopharvest-ollama ollama pull qwen2.5:1.5b`. +4. Call `GET /health`, `GET /models`, or `POST /v1/chat` on `http://localhost:8000`. diff --git a/ai_engine/docker-compose.yml b/ai_engine/docker-compose.yml new file mode 100644 index 0000000..b8785d8 --- /dev/null +++ b/ai_engine/docker-compose.yml @@ -0,0 +1,20 @@ +services: + ollama: + image: ollama/ollama:latest + container_name: loopharvest-ollama + ports: + - "11434:11434" + volumes: + - ./ollama/models:/root/.ollama + restart: unless-stopped + + gateway: + build: ./gateway + container_name: loopharvest-ai-gateway + ports: + - "8000:8000" + env_file: + - ./gateway/.env + depends_on: + - ollama + restart: unless-stopped diff --git a/ai_engine/gateway/.env.example b/ai_engine/gateway/.env.example new file mode 100644 index 0000000..a7a764e --- /dev/null +++ b/ai_engine/gateway/.env.example @@ -0,0 +1,5 @@ +MY_API_KEY=replace-with-a-long-random-secret +DEFAULT_MODEL=qwen2.5:1.5b +FALLBACK_MODEL=gemma3:1b +OLLAMA_URL=http://ollama:11434 +REQUEST_TIMEOUT_SECONDS=120 diff --git a/ai_engine/gateway/Dockerfile b/ai_engine/gateway/Dockerfile new file mode 100644 index 0000000..08d3878 --- /dev/null +++ b/ai_engine/gateway/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src ./src + +CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/ai_engine/gateway/pyproject.toml b/ai_engine/gateway/pyproject.toml new file mode 100644 index 0000000..5b5860e --- /dev/null +++ b/ai_engine/gateway/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "loopharvest-ai-gateway" +version = "0.1.0" +description = "Local AI gateway for Ollama-backed chat requests" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115,<1.0", + "httpx>=0.27,<1.0", + "pydantic-settings>=2.4,<3.0", + "python-dotenv>=1.0,<2.0", + "uvicorn[standard]>=0.32,<1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3,<9.0", + "pytest-asyncio>=0.24,<1.0", +] + +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" diff --git a/ai_engine/gateway/requirements.txt b/ai_engine/gateway/requirements.txt new file mode 100644 index 0000000..ab1249e --- /dev/null +++ b/ai_engine/gateway/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.115,<1.0 +httpx>=0.27,<1.0 +pydantic-settings>=2.4,<3.0 +python-dotenv>=1.0,<2.0 +uvicorn[standard]>=0.32,<1.0 diff --git a/ai_engine/gateway/src/__init__.py b/ai_engine/gateway/src/__init__.py new file mode 100644 index 0000000..d68df01 --- /dev/null +++ b/ai_engine/gateway/src/__init__.py @@ -0,0 +1 @@ +"""LoopHarvest AI gateway package.""" diff --git a/ai_engine/gateway/src/config.py b/ai_engine/gateway/src/config.py new file mode 100644 index 0000000..ab2ec7a --- /dev/null +++ b/ai_engine/gateway/src/config.py @@ -0,0 +1,24 @@ +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + app_name: str = "LoopHarvest AI Gateway" + app_version: str = "1.0.0" + ollama_url: str = Field(default="http://ollama:11434", alias="OLLAMA_URL") + api_key: str = Field(alias="MY_API_KEY") + default_model: str = Field(default="qwen2.5:1.5b", alias="DEFAULT_MODEL") + request_timeout_seconds: float = Field(default=120.0, alias="REQUEST_TIMEOUT_SECONDS") + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/ai_engine/gateway/src/dependencies.py b/ai_engine/gateway/src/dependencies.py new file mode 100644 index 0000000..4f11b82 --- /dev/null +++ b/ai_engine/gateway/src/dependencies.py @@ -0,0 +1,12 @@ +from fastapi import HTTPException, status + +from src.config import Settings +from src.models import ErrorDetail + + +def verify_api_key(x_api_key: str | None, settings: Settings) -> None: + if x_api_key != settings.api_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ErrorDetail(code="unauthorized", message="Invalid API key.").model_dump(), + ) diff --git a/ai_engine/gateway/src/main.py b/ai_engine/gateway/src/main.py new file mode 100644 index 0000000..94a656f --- /dev/null +++ b/ai_engine/gateway/src/main.py @@ -0,0 +1,144 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import httpx +from fastapi import Depends, FastAPI, HTTPException, Header, Request, status +from fastapi.responses import JSONResponse + +from src.config import Settings, get_settings +from src.dependencies import verify_api_key +from src.models import ChatRequest, ErrorEnvelope, ErrorDetail, SuccessEnvelope + + +def create_app( + settings: Settings, + ollama_client: httpx.AsyncClient | None = None, +) -> FastAPI: + @asynccontextmanager + async def lifespan(_: FastAPI) -> AsyncIterator[None]: + managed_client: httpx.AsyncClient | None = None + if ollama_client is None: + managed_client = httpx.AsyncClient( + base_url=settings.ollama_url, + timeout=settings.request_timeout_seconds, + ) + app.state.ollama_client = managed_client + else: + app.state.ollama_client = ollama_client + + try: + yield + finally: + if managed_client is not None: + await managed_client.aclose() + + app = FastAPI(title=settings.app_name, version=settings.app_version, lifespan=lifespan) + app.state.settings = settings + if ollama_client is not None: + app.state.ollama_client = ollama_client + + def settings_dependency() -> Settings: + return app.state.settings + + def require_api_key( + x_api_key: str | None = Header(default=None), + settings: Settings = Depends(settings_dependency), + ) -> None: + verify_api_key(x_api_key, settings) + + async def get_ollama_client() -> httpx.AsyncClient: + return app.state.ollama_client + + @app.exception_handler(HTTPException) + async def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse: + detail = exc.detail + if isinstance(detail, dict) and {"code", "message"} <= set(detail): + payload = ErrorEnvelope(error=ErrorDetail(**detail)) + else: + payload = ErrorEnvelope( + error=ErrorDetail(code="http_error", message=str(detail)), + ) + return JSONResponse(status_code=exc.status_code, content=payload.model_dump()) + + @app.exception_handler(Exception) + async def unhandled_exception_handler(_: Request, __: Exception) -> JSONResponse: + payload = ErrorEnvelope( + error=ErrorDetail( + code="internal_server_error", + message="An unexpected error occurred.", + ) + ) + return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content=payload.model_dump()) + + @app.get("/health", response_model=SuccessEnvelope) + async def health() -> SuccessEnvelope: + return SuccessEnvelope( + data={ + "status": "ok", + "service": settings.app_name, + "version": settings.app_version, + } + ) + + @app.get("/models", response_model=SuccessEnvelope) + async def list_models( + _: None = Depends(require_api_key), + client: httpx.AsyncClient = Depends(get_ollama_client), + ) -> SuccessEnvelope: + data = await _ollama_get(client, "/api/tags") + return SuccessEnvelope(data=data) + + @app.post("/v1/chat", response_model=SuccessEnvelope) + async def chat( + body: ChatRequest, + _: None = Depends(require_api_key), + client: httpx.AsyncClient = Depends(get_ollama_client), + ) -> SuccessEnvelope: + payload = { + "model": body.model or settings.default_model, + "messages": [message.model_dump() for message in body.messages], + "stream": False, + "options": { + "temperature": body.temperature, + "num_predict": body.max_tokens, + }, + } + if body.system is not None: + payload["system"] = body.system + + data = await _ollama_post(client, "/api/chat", payload) + return SuccessEnvelope( + data={ + "model": data.get("model", payload["model"]), + "content": data.get("message", {}).get("content", ""), + } + ) + + return app + + +async def _ollama_get(client: httpx.AsyncClient, path: str) -> dict: + try: + response = await client.get(path) + response.raise_for_status() + return response.json() + except (httpx.HTTPError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=ErrorDetail(code="ollama_error", message="Ollama request failed.").model_dump(), + ) from exc + + +async def _ollama_post(client: httpx.AsyncClient, path: str, payload: dict) -> dict: + try: + response = await client.post(path, json=payload) + response.raise_for_status() + return response.json() + except (httpx.HTTPError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=ErrorDetail(code="ollama_error", message="Ollama request failed.").model_dump(), + ) from exc + + +app = create_app(get_settings()) diff --git a/ai_engine/gateway/src/models.py b/ai_engine/gateway/src/models.py new file mode 100644 index 0000000..0d0dbc0 --- /dev/null +++ b/ai_engine/gateway/src/models.py @@ -0,0 +1,31 @@ +from typing import Any, Literal + +from pydantic import BaseModel, Field + + +class ErrorDetail(BaseModel): + code: str + message: str + + +class SuccessEnvelope(BaseModel): + success: Literal[True] = True + data: dict[str, Any] + + +class ErrorEnvelope(BaseModel): + success: Literal[False] = False + error: ErrorDetail + + +class ChatMessage(BaseModel): + role: str = Field(min_length=1, max_length=32) + content: str = Field(min_length=1) + + +class ChatRequest(BaseModel): + messages: list[ChatMessage] = Field(min_length=1) + model: str | None = Field(default=None, min_length=1) + max_tokens: int = Field(default=1024, ge=1, le=16384) + temperature: float = Field(default=0.7, ge=0.0, le=2.0) + system: str | None = Field(default=None, min_length=1) diff --git a/ai_engine/gateway/src/utils/image.py b/ai_engine/gateway/src/utils/image.py new file mode 100644 index 0000000..d3560c9 --- /dev/null +++ b/ai_engine/gateway/src/utils/image.py @@ -0,0 +1,24 @@ +import base64, httpx +from pathlib import Path + +async def to_base64(source: str) -> str: + """Accept a URL, file path, or raw base64 string — always return base64.""" + + # Already base64 + if source.startswith("data:image"): + return source.split(",")[1] + + # Remote URL — download it + if source.startswith("http"): + async with httpx.AsyncClient(timeout=30) as client: + r = await client.get(source) + return base64.b64encode(r.content).decode("utf-8") + + # Local file path + return base64.b64encode(Path(source).read_bytes()).decode("utf-8") + +def detect_media_type(source: str) -> str: + if ".png" in source: return "image/png" + if ".gif" in source: return "image/gif" + if ".webp" in source: return "image/webp" + return "image/jpeg" # safe default \ No newline at end of file diff --git a/ai_engine/gateway/tests/conftest.py b/ai_engine/gateway/tests/conftest.py new file mode 100644 index 0000000..d0f99eb --- /dev/null +++ b/ai_engine/gateway/tests/conftest.py @@ -0,0 +1,28 @@ +import os + +import pytest +from httpx import ASGITransport, AsyncClient + +os.environ.setdefault("MY_API_KEY", "test-secret") + +from src.config import Settings +from src.main import create_app + + +@pytest.fixture +def settings() -> Settings: + return Settings( + app_name="LoopHarvest AI Gateway", + api_key="test-secret", + default_model="qwen2.5:1.5b", + ollama_url="http://ollama:11434", + request_timeout_seconds=30.0, + ) + + +@pytest.fixture +async def client(settings: Settings) -> AsyncClient: + app = create_app(settings) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as async_client: + yield async_client diff --git a/ai_engine/gateway/tests/test_gateway.py b/ai_engine/gateway/tests/test_gateway.py new file mode 100644 index 0000000..8d47b68 --- /dev/null +++ b/ai_engine/gateway/tests/test_gateway.py @@ -0,0 +1,131 @@ +import pytest +from fastapi import status +from httpx import ASGITransport, AsyncClient + +from src.config import Settings +from src.main import create_app + + +class OllamaMockTransport(ASGITransport): + def __init__(self) -> None: + from fastapi import FastAPI, Response + + app = FastAPI() + + @app.get("/api/tags") + async def list_models(): + return {"models": [{"name": "qwen2.5:1.5b"}, {"name": "gemma3:1b"}]} + + @app.post("/api/chat") + async def chat(payload: dict): + if payload["model"] == "missing-model": + return Response( + content='{"error":"model not found"}', + media_type="application/json", + status_code=status.HTTP_404_NOT_FOUND, + ) + + return { + "model": payload["model"], + "message": {"content": "mocked reply"}, + } + + super().__init__(app=app) + + +@pytest.fixture +async def upstream_client() -> AsyncClient: + async with AsyncClient(transport=OllamaMockTransport(), base_url="http://ollama") as client: + yield client + + +@pytest.fixture +async def app_client(settings: Settings, upstream_client: AsyncClient) -> AsyncClient: + app = create_app(settings, ollama_client=upstream_client) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + yield client + + +@pytest.mark.asyncio +async def test_health_endpoint_is_public(client: AsyncClient) -> None: + response = await client.get("/health") + + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "success": True, + "data": { + "status": "ok", + "service": "LoopHarvest AI Gateway", + "version": "1.0.0", + }, + } + + +@pytest.mark.asyncio +async def test_models_requires_api_key(client: AsyncClient) -> None: + response = await client.get("/models") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert response.json() == { + "success": False, + "error": { + "code": "unauthorized", + "message": "Invalid API key.", + }, + } + + +@pytest.mark.asyncio +async def test_models_returns_upstream_models(app_client: AsyncClient) -> None: + response = await app_client.get("/models", headers={"x-api-key": "test-secret"}) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "success": True, + "data": { + "models": [{"name": "qwen2.5:1.5b"}, {"name": "gemma3:1b"}], + }, + } + + +@pytest.mark.asyncio +async def test_chat_uses_default_model_and_returns_content(app_client: AsyncClient) -> None: + response = await app_client.post( + "/v1/chat", + headers={"x-api-key": "test-secret"}, + json={ + "messages": [{"role": "user", "content": "Hello"}], + "temperature": 0.5, + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == { + "success": True, + "data": { + "model": "qwen2.5:1.5b", + "content": "mocked reply", + }, + } + + +@pytest.mark.asyncio +async def test_chat_surfaces_upstream_not_found_as_bad_gateway(app_client: AsyncClient) -> None: + response = await app_client.post( + "/v1/chat", + headers={"x-api-key": "test-secret"}, + json={ + "model": "missing-model", + "messages": [{"role": "user", "content": "Hello"}], + }, + ) + + assert response.status_code == status.HTTP_502_BAD_GATEWAY + assert response.json() == { + "success": False, + "error": { + "code": "ollama_error", + "message": "Ollama request failed.", + }, + }