Compare commits
No commits in common. "eb96f7073627b6e38ae90dd80866701c249ac9e3" and "1f354e8b5ceec6a32d4a87c412d3f31ef0ce45bd" have entirely different histories.
eb96f70736
...
1f354e8b5c
5 changed files with 41 additions and 383 deletions
117
README.md
117
README.md
|
|
@ -1,12 +1,12 @@
|
|||
# Local AI Platform
|
||||
|
||||
A self-hosted AI application built to demonstrate production-minded backend engineering, local LLM integration, retrieval-augmented generation (RAG), and reliable API design.
|
||||
A self-hosted AI application designed to demonstrate production-grade backend engineering, local LLM integration, retrieval-augmented generation (RAG), observability, security, and reliability.
|
||||
|
||||
The project emphasizes measurable behavior, reproducible development, automated testing, and documented architectural trade-offs.
|
||||
The project emphasizes measurable behavior, reproducible deployments, automated testing, and documented architectural trade-offs.
|
||||
|
||||
## Project Goals
|
||||
|
||||
- Build a well-defined API for local LLM inference.
|
||||
- Build and expose a well-defined API for local LLM inference.
|
||||
- Integrate retrieval-augmented generation with source-grounded responses.
|
||||
- Measure latency, throughput, errors, and retrieval quality.
|
||||
- Implement structured logging, metrics, and distributed tracing.
|
||||
|
|
@ -15,45 +15,30 @@ The project emphasizes measurable behavior, reproducible development, automated
|
|||
- Automate testing and deployment.
|
||||
- Document architectural decisions and operational procedures.
|
||||
|
||||
These goals will be implemented incrementally.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
**Currently implemented**
|
||||
Planned technologies:
|
||||
|
||||
- **Runtime:** Node.js
|
||||
- **Language:** TypeScript
|
||||
- **API framework:** Fastify
|
||||
- **Request validation:** Zod
|
||||
- **Validation:** Zod
|
||||
- **Local inference:** Ollama
|
||||
- **Testing:** Node.js test runner with `tsx`
|
||||
|
||||
**Planned**
|
||||
|
||||
- **Database and retrieval:** PostgreSQL with pgvector
|
||||
- **Containers:** Docker and Docker Compose
|
||||
- **Testing:** Automated unit, integration, and failure-injection tests
|
||||
- **Observability:** OpenTelemetry and compatible monitoring tools
|
||||
- **CI/CD:** Automated build, test, and deployment workflows
|
||||
- **Evaluation:** Performance benchmarks and retrieval-quality tests
|
||||
|
||||
Technologies will be introduced as project requirements justify them.
|
||||
Technologies will be introduced incrementally as project requirements justify them.
|
||||
|
||||
## Current Status
|
||||
|
||||
**Status:** Initial API foundation and local inference integration implemented.
|
||||
**Status:** Initial development
|
||||
|
||||
The API currently provides:
|
||||
The initial milestone is to establish a working API with health and readiness endpoints, then integrate local model inference and baseline performance measurements.
|
||||
|
||||
- `GET /health` — reports API health.
|
||||
- `GET /ready` — checks whether Ollama is available.
|
||||
- `POST /echo` — demonstrates request validation and API behavior.
|
||||
- `POST /generate` — sends a prompt to a local Ollama model and returns generated text.
|
||||
- Centralized error handling and consistent responses for unknown routes.
|
||||
- Automated tests for API behavior, validation, inference responses, and failure handling.
|
||||
|
||||
The inference client currently uses the `qwen3:8b` model and connects to Ollama at `http://127.0.0.1:11434`.
|
||||
|
||||
**Not yet implemented:** RAG, PostgreSQL persistence, authentication, production deployment, metrics, distributed tracing, and CI/CD.
|
||||
Features listed in the project goals are planned capabilities, not claims that they have already been implemented.
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
|
@ -61,14 +46,15 @@ The inference client currently uses the `qwen3:8b` model and connects to Ollama
|
|||
|
||||
- Node.js and npm
|
||||
- Git
|
||||
- [Ollama](https://ollama.com/) for local inference
|
||||
|
||||
Additional dependencies, including Ollama and PostgreSQL, will be introduced as the project evolves.
|
||||
|
||||
### Installation
|
||||
|
||||
Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://git.brandonwolfgang.com/brandon/local-ai-platform.git
|
||||
git clone <repository-url>
|
||||
cd local-ai-platform
|
||||
```
|
||||
|
||||
|
|
@ -78,85 +64,28 @@ Install dependencies:
|
|||
npm ci
|
||||
```
|
||||
|
||||
Ensure Ollama is running and the configured model is available:
|
||||
|
||||
```bash
|
||||
ollama pull qwen3:8b
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The API listens on `http://127.0.0.1:3000`.
|
||||
|
||||
Check API health:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:3000/health
|
||||
```
|
||||
|
||||
Check inference-service readiness:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:3000/ready
|
||||
```
|
||||
|
||||
Generate a response:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3000/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"message":"Explain what a REST API does in one sentence."}'
|
||||
```
|
||||
|
||||
### Available Scripts
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `npm run dev` | Start the development server with automatic restarts |
|
||||
| `npm run build` | Compile TypeScript into `dist/` |
|
||||
| `npm start` | Run the compiled application |
|
||||
| `npm test` | Run the automated test suite |
|
||||
The development server and available scripts will be documented here as they are implemented.
|
||||
|
||||
## Architecture
|
||||
|
||||
The current architecture consists of a TypeScript/Fastify HTTP API and a local Ollama inference service.
|
||||
The initial architecture will consist of an HTTP API and a local model inference service.
|
||||
|
||||
The API validates incoming requests, delegates generation to Ollama, and returns generated text. Readiness checks verify the inference service is reachable, while error handling prevents internal exception details from being returned in API responses.
|
||||
|
||||
Future iterations will introduce document ingestion, retrieval, persistence, security controls, observability, and deployment automation.
|
||||
Subsequent iterations will introduce retrieval, persistence, observability, security controls, and deployment automation.
|
||||
|
||||
Architecture diagrams and significant design decisions will be maintained alongside the code.
|
||||
|
||||
## Testing and Quality
|
||||
|
||||
The automated test suite currently covers:
|
||||
The project will progressively introduce:
|
||||
|
||||
- API health and readiness behavior
|
||||
- Request validation and input limits
|
||||
- Successful inference responses
|
||||
- Inference failure handling
|
||||
- Unknown routes and generic server errors
|
||||
- Ollama client response handling
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Verify the production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Performance benchmarks, retrieval evaluations, security regression tests, and recovery tests remain planned work.
|
||||
- Automated unit and integration tests
|
||||
- API contract validation
|
||||
- Performance benchmarks
|
||||
- Retrieval and answer-quality evaluations
|
||||
- Security regression tests
|
||||
- Failure-recovery tests
|
||||
|
||||
Performance and quality claims will be backed by reproducible measurements.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { it } from "node:test";
|
||||
|
||||
import { checkOllamaHealth, generateResponse } from "./ollama.js";
|
||||
|
||||
it("returns generated text from Ollama", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
response: "A REST API lets software systems communicate.",
|
||||
thinking: "Internal reasoning that should not be returned",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await generateResponse("Explain a REST API.");
|
||||
|
||||
assert.equal(result, "A REST API lets software systems communicate.");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("throws an error when Ollama returns a non-OK status", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response("Service unavailable", {
|
||||
status: 503,
|
||||
});
|
||||
|
||||
try {
|
||||
await assert.rejects(generateResponse("Explain a REST API."), {
|
||||
message: "Ollama request failed with status 503",
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates an error when Ollama is unreachable", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
throw new TypeError("fetch failed");
|
||||
};
|
||||
|
||||
try {
|
||||
await assert.rejects(generateResponse("Explain a REST API."), {
|
||||
name: "TypeError",
|
||||
message: "fetch failed",
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates an error when an Ollama request times out", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
assert.ok(init?.signal);
|
||||
throw new DOMException("The operation was aborted", "AbortError");
|
||||
};
|
||||
|
||||
try {
|
||||
await assert.rejects(generateResponse("Explain a REST API."), {
|
||||
name: "AbortError",
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns true when Ollama is healthy", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response("{}", { status: 200 });
|
||||
|
||||
try {
|
||||
assert.equal(await checkOllamaHealth(), true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false when Ollama returns a non-OK health response", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response("Unavailable", { status: 503 });
|
||||
|
||||
try {
|
||||
assert.equal(await checkOllamaHealth(), false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns false when Ollama is unreachable during a health check", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new TypeError("fetch failed");
|
||||
};
|
||||
|
||||
try {
|
||||
assert.equal(await checkOllamaHealth(), false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
const OLLAMA_BASE_URL = "http://127.0.0.1:11434";
|
||||
|
||||
interface OllamaGenerateResponse {
|
||||
response: string;
|
||||
}
|
||||
|
||||
export const generateResponse = async (prompt: string): Promise<string> => {
|
||||
const response = await fetch(`${OLLAMA_BASE_URL}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "qwen3:8b",
|
||||
prompt,
|
||||
stream: false,
|
||||
}),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as OllamaGenerateResponse;
|
||||
|
||||
return result.response;
|
||||
};
|
||||
|
||||
export const checkOllamaHealth = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${OLLAMA_BASE_URL}/api/tags`, {
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
|
@ -24,45 +24,18 @@ describe("API endpoints", () => {
|
|||
});
|
||||
|
||||
it("GET /ready returns 200 and dependency status", async () => {
|
||||
const app = buildApp(undefined, async () => true);
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/ready",
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/ready",
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(response.json(), {
|
||||
status: "ready",
|
||||
dependencies: {
|
||||
ollama: "available",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("GET /ready returns 503 when Ollama is unavailable", async () => {
|
||||
const app = buildApp(undefined, async () => false);
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/ready",
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.deepEqual(response.json(), {
|
||||
status: "not-ready",
|
||||
dependencies: {
|
||||
ollama: "unavailable",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(response.json(), {
|
||||
status: "ready",
|
||||
dependencies: {
|
||||
ollama: "not-integrated",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("POST /echo returns the validated message", async () => {
|
||||
|
|
@ -93,54 +66,6 @@ describe("API endpoints", () => {
|
|||
assert.deepEqual(response.json(), { message });
|
||||
});
|
||||
|
||||
it("POST /generate returns generated text", async () => {
|
||||
const app = buildApp(async (prompt) => {
|
||||
assert.equal(prompt, "Explain a REST API.");
|
||||
return "A REST API enables software systems to communicate.";
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/generate",
|
||||
payload: {
|
||||
message: "Explain a REST API.",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(response.json(), {
|
||||
response: "A REST API enables software systems to communicate.",
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /generate returns 503 when generation fails", async () => {
|
||||
const app = buildApp(async () => {
|
||||
throw new Error("Ollama is unavailable");
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/generate",
|
||||
payload: {
|
||||
message: "Explain a REST API.",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.deepEqual(response.json(), {
|
||||
error: "Generation service unavailable",
|
||||
});
|
||||
assert.equal(response.body.includes("Ollama is unavailable"), false);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("POST /echo rejects an empty message", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -1,16 +1,11 @@
|
|||
import Fastify, { type FastifyError } from "fastify";
|
||||
import { z } from "zod";
|
||||
|
||||
import { checkOllamaHealth, generateResponse } from "./ollama.js";
|
||||
|
||||
const echoSchema = z.object({
|
||||
message: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
export const buildApp = (
|
||||
generate = generateResponse,
|
||||
checkHealth = checkOllamaHealth,
|
||||
) => {
|
||||
export const buildApp = () => {
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
});
|
||||
|
|
@ -39,25 +34,12 @@ export const buildApp = (
|
|||
status: "ok",
|
||||
}));
|
||||
|
||||
app.get("/ready", async (_request, reply) => {
|
||||
const ollamaReady = await checkHealth();
|
||||
|
||||
if (!ollamaReady) {
|
||||
return reply.code(503).send({
|
||||
status: "not-ready",
|
||||
dependencies: {
|
||||
ollama: "unavailable",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
status: "ready",
|
||||
dependencies: {
|
||||
ollama: "available",
|
||||
},
|
||||
};
|
||||
});
|
||||
app.get("/ready", async () => ({
|
||||
status: "ready",
|
||||
dependencies: {
|
||||
ollama: "not-integrated",
|
||||
},
|
||||
}));
|
||||
|
||||
app.post("/echo", async (request, reply) => {
|
||||
const result = echoSchema.safeParse(request.body);
|
||||
|
|
@ -73,28 +55,6 @@ export const buildApp = (
|
|||
};
|
||||
});
|
||||
|
||||
app.post("/generate", async (request, reply) => {
|
||||
const result = echoSchema.safeParse(request.body);
|
||||
|
||||
if (!result.success) {
|
||||
return reply.code(400).send({
|
||||
error: "Invalid request body",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await generate(result.data.message);
|
||||
|
||||
return { response };
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "Ollama generation failed");
|
||||
|
||||
return reply.code(503).send({
|
||||
error: "Generation service unavailable",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/test-error", async () => {
|
||||
throw new Error("Sensitive internal detail");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue