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: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,5 @@ __marimo__/

# Streamlit
.streamlit/secrets.toml
ai_engine/gateway/.env
ai_engine/ollama/models/
26 changes: 26 additions & 0 deletions ai_engine/README.md
Original file line number Diff line number Diff line change
@@ -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`.
20 changes: 20 additions & 0 deletions ai_engine/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions ai_engine/gateway/.env.example
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions ai_engine/gateway/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
34 changes: 34 additions & 0 deletions ai_engine/gateway/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 5 additions & 0 deletions ai_engine/gateway/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions ai_engine/gateway/src/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""LoopHarvest AI gateway package."""
24 changes: 24 additions & 0 deletions ai_engine/gateway/src/config.py
Original file line number Diff line number Diff line change
@@ -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()
12 changes: 12 additions & 0 deletions ai_engine/gateway/src/dependencies.py
Original file line number Diff line number Diff line change
@@ -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(),
)
144 changes: 144 additions & 0 deletions ai_engine/gateway/src/main.py
Original file line number Diff line number Diff line change
@@ -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())
31 changes: 31 additions & 0 deletions ai_engine/gateway/src/models.py
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 24 additions & 0 deletions ai_engine/gateway/src/utils/image.py
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions ai_engine/gateway/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading