Skip to content
Open
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jobs:
- name: Run typecheck
run: bun run typecheck

- name: Run tests
run: bun test

- name: Run knip
run: bun run knip
env:
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ this is a lightweight LLM proxy, that amongst other things, implements:
- spending limits
- replicate support (optional, behind a feature flag)
- posthog analytics + feature flags
- agent-readable discovery: `/openapi.json`, `/llms.txt`, `/sitemap.xml`, `/robots.txt`, JSON-LD on every page
- structured JSON errors (OpenAI-compatible, plus `error.hint` and `error.docs`)

is it the best code? probably not. but hey, it works!

Expand Down Expand Up @@ -68,6 +70,60 @@ POSTHOG_API_HOST=https://us.i.posthog.com/
SENTRY_DSN=
```

## machine-readable endpoints

everything below is public and unauthenticated. they're built at request time
from `env.BASE_URL` and the configured model lists, so they stay in sync with
whatever this deployment actually allows.

| path | what it is |
| --- | --- |
| `/openapi.json` (also `/.well-known/openapi.json`) | OpenAPI 3.1 description of the proxy API. built in `src/lib/openapi.ts`. |
| `/llms.txt` | [llmstxt.org](https://llmstxt.org) index of the site, for agents. |
| `/sitemap.xml` | indexable URLs. bump `SITE_LAST_MODIFIED` in `src/lib/site.ts` when public content changes. |
| `/robots.txt` | crawler policy + sitemap pointer. |

the homepage (and every other page) carries JSON-LD describing Hack Club and
this service - see `buildStructuredData` in `src/lib/site.ts`.

adding a proxy endpoint? add it to `src/lib/openapi.ts` too, and add a case to
`src/lib/openapi.test.ts`.

## errors

every error goes through `src/lib/errors.ts`, which renders one shape:

```json
{
"error": {
"message": "Authentication required",
"type": "authentication_error",
"code": "unauthorized",
"status": 401,
"hint": "Send `Authorization: Bearer sk-hc-v1-...`. Create a key at https://ai.hackclub.com/keys.",
"docs": "https://docs.ai.hackclub.com/guide/authentication"
},
"request_id": "..."
}
```

`error.message`/`type`/`code` are the OpenAI error shape, so OpenAI-compatible
SDKs surface something useful. `hint` and `docs` are ours.

404s are content-negotiated: API paths and non-GET requests get that JSON,
browsers get a branded HTML page, and everything else (curl, crawlers, agents)
gets a short markdown body pointing at `/llms.txt` and `/openapi.json`.

## tests

```
bun test
```

`bunfig.toml` preloads `src/test/setup.ts`, which fills in placeholder env vars
so tests that import a route don't trip `src/env.ts`'s validation. no database
is needed.

## tech stack

- bun as the runtime
Expand Down
2 changes: 2 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
preload = ["./src/test/setup.ts"]
54 changes: 14 additions & 40 deletions drizzle/meta/0016_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -97,12 +97,8 @@
"name": "api_keys_user_id_users_id_fk",
"tableFrom": "api_keys",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
Expand All @@ -112,9 +108,7 @@
"api_keys_key_unique": {
"name": "api_keys_key_unique",
"nullsNotDistinct": false,
"columns": [
"key"
]
"columns": ["key"]
}
},
"policies": {},
Expand Down Expand Up @@ -180,12 +174,8 @@
"name": "pending_charges_user_id_users_id_fk",
"tableFrom": "pending_charges",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
Expand Down Expand Up @@ -403,25 +393,17 @@
"name": "request_logs_api_key_id_api_keys_id_fk",
"tableFrom": "request_logs",
"tableTo": "api_keys",
"columnsFrom": [
"api_key_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["api_key_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"request_logs_user_id_users_id_fk": {
"name": "request_logs_user_id_users_id_fk",
"tableFrom": "request_logs",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
Expand Down Expand Up @@ -506,12 +488,8 @@
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
Expand All @@ -521,9 +499,7 @@
"sessions_token_unique": {
"name": "sessions_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
"columns": ["token"]
}
},
"policies": {},
Expand Down Expand Up @@ -685,9 +661,7 @@
"users_slack_id_unique": {
"name": "users_slack_id_unique",
"nullsNotDistinct": false,
"columns": [
"slack_id"
]
"columns": ["slack_id"]
}
},
"policies": {},
Expand All @@ -706,4 +680,4 @@
"schemas": {},
"tables": {}
}
}
}
2 changes: 1 addition & 1 deletion drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,4 @@
"breakpoints": true
}
]
}
}
4 changes: 3 additions & 1 deletion knip.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
{
"ignore": ["scripts/**"]
"ignore": ["scripts/**"],
"entry": ["src/test/setup.ts", "src/**/*.test.{ts,tsx}"],
"project": ["src/**/*.{ts,tsx}"]
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"format:check": "biome format",
"format": "biome format --fix",
"typecheck": "tsc --noEmit",
"test": "bun test",
"ty": "npm run typecheck",
"lint": "biome check",
"knip": "knip",
Expand Down
32 changes: 18 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import "./instrument"; // Sentry
import * as Sentry from "@sentry/bun";
import { dns } from "bun";
import type { Context } from "hono";
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { cors } from "hono/cors";
import { showRoutes } from "hono/dev";
import { HTTPException } from "hono/http-exception";
import { logger } from "hono/logger";
import type { RequestIdVariables } from "hono/request-id";
import { requestId } from "hono/request-id";
import { secureHeaders } from "hono/secure-headers";
import { trimTrailingSlash } from "hono/trailing-slash";

import { env } from "./env";
import {
createErrorHandler,
createNotFoundHandler,
type ErrorHandlerOptions,
} from "./lib/errors";
import { runMigrations } from "./migrate";
import activity from "./routes/activity";
import api from "./routes/api";
import auth from "./routes/auth";
import dashboard from "./routes/dashboard";
import discovery from "./routes/discovery";
import docs from "./routes/docs";
import ghss from "./routes/ghss";
import global from "./routes/global";
Expand All @@ -28,6 +34,7 @@ import proxy from "./routes/proxy";
import replicate from "./routes/replicate";
import up from "./routes/up";
import type { AppVariables } from "./types";
import { NotFound } from "./views/not-found";

await runMigrations();
dns.prefetch(env.OPENAI_API_URL, 443);
Expand Down Expand Up @@ -65,15 +72,17 @@ if (env.NODE_ENV === "development") {

app.use("/*", serveStatic({ root: "./public" }));

app.onError((err, c) => {
if (err instanceof HTTPException) {
return err.getResponse();
}
console.error("Unhandled error:", err);
Sentry.captureException(err);
return c.json({ error: "Internal server error" }, 500);
});
const errorHandlerOptions: ErrorHandlerOptions = {
baseUrl: env.BASE_URL,
renderNotFoundPage: (c: Context, path: string) =>
c.html(NotFound({ path }), 404),
onUnhandled: (err: Error) => Sentry.captureException(err),
};

app.onError(createErrorHandler(errorHandlerOptions));
app.notFound(createNotFoundHandler(errorHandlerOptions));

app.route("/", discovery);
app.route("/", dashboard);
app.route("/", activity);
app.route("/auth", auth);
Expand All @@ -88,11 +97,6 @@ app.route("/models", models);
app.route("/replicate", replicate);
app.route("/up", up);

app.post("*", (c) => {
console.warn(`[404 POST] ${c.req.path} from ${c.get("ip")}`);
return c.json({ error: "Not found" }, 404);
});

showRoutes(app);

console.log(`Server running on http://localhost:${env.PORT}`);
Expand Down
Loading
Loading