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
26 changes: 25 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,34 @@ jobs:
- run: python -m pip install -e ".[dev]"
- run: pytest tests/integration

e2e:
name: Playwright E2E
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: python -m pip install -e ".[dev]"
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
- if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 14

container:
name: Release image validation
runs-on: ubuntu-latest
needs: [unit, integration]
needs: [unit, integration, e2e]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ The complete architecture and design targets are documented in

## Development

Requires Python 3.11 or newer.
Requires Python 3.11 or newer and Node.js 20 or newer.

```bash
python -m venv .venv
python -m pip install -e ".[dev]"
npm ci
python -m uvicorn llm_router.app:app --app-dir src --reload
```

Expand Down Expand Up @@ -43,9 +44,14 @@ ruff format --check .
ruff check .
mypy
pytest tests/unit tests/integration --cov=llm_router --cov-report=term-missing
npx playwright install chromium
npm run test:e2e
docker build -t local-llm-router:dev .
```

CI reports unit/static analysis, integration, and Playwright end-to-end tests separately.
The release-image build starts only after all three test layers pass.

## Runtime settings

All settings use the `ROUTER_` prefix.
Expand Down
78 changes: 78 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "local-llm-router-e2e",
"private": true,
"version": "0.1.0",
"scripts": {
"test:e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.55.0"
}
}
29 changes: 29 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { defineConfig } from "@playwright/test";

const port = process.env.TEST_PORT ?? "8000";
const baseURL = `http://127.0.0.1:${port}`;

export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : "list",
use: {
baseURL,
extraHTTPHeaders: { Authorization: "Bearer e2e-key" },
trace: "retain-on-failure",
},
webServer: {
command: `python -m uvicorn llm_router.app:app --app-dir src --host 127.0.0.1 --port ${port}`,
url: `${baseURL}/healthz`,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
env: {
...process.env,
ROUTER_API_KEYS: "e2e-key",
ROUTER_ENVIRONMENT: "test",
},
},
});
43 changes: 43 additions & 0 deletions tests/e2e/api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect, test } from "@playwright/test";

test("serves health and authenticated model catalog", async ({ request }) => {
const health = await request.get("/healthz");
expect(health.ok()).toBeTruthy();
await expect(health.json()).resolves.toEqual({ status: "healthy" });

const models = await request.get("/v1/models");
expect(models.ok()).toBeTruthy();
expect((await models.json()).data).toEqual(
expect.arrayContaining([expect.objectContaining({ id: "general-local" })]),
);
});

test("routes an OpenAI-compatible extraction request end to end", async ({ request }) => {
const response = await request.post("/v1/chat/completions", {
data: {
model: "auto",
messages: [{ role: "user", content: "Extract the account fields as JSON" }],
routing: { privacy: "restricted", latency_tier: "interactive" },
},
});
expect(response.status()).toBe(200);
expect(response.headers()["x-route-model"]).toBe("small-specialist");
const body = await response.json();
expect(body).toMatchObject({
object: "chat.completion",
model: "small-specialist",
routing: { task: "extraction" },
});
expect(body.routing.model_revision).toBeTruthy();
expect(body.choices[0].message.role).toBe("assistant");
});

test("rejects invalid credentials", async ({ playwright }, testInfo) => {
const anonymous = await playwright.request.newContext({
baseURL: testInfo.project.use.baseURL,
extraHTTPHeaders: { Authorization: "Bearer invalid-key" },
});
const response = await anonymous.get("/v1/models");
expect(response.status()).toBe(401);
await anonymous.dispose();
});
Loading