diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d3f6f9..26ff11b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/README.md b/README.md index 47a354b..b8add95 100644 --- a/README.md +++ b/README.md @@ -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! @@ -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 diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..3fc87eb --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./src/test/setup.ts"] diff --git a/drizzle/meta/0016_snapshot.json b/drizzle/meta/0016_snapshot.json index 4438eaa..2d1c948 100644 --- a/drizzle/meta/0016_snapshot.json +++ b/drizzle/meta/0016_snapshot.json @@ -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" } @@ -112,9 +108,7 @@ "api_keys_key_unique": { "name": "api_keys_key_unique", "nullsNotDistinct": false, - "columns": [ - "key" - ] + "columns": ["key"] } }, "policies": {}, @@ -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" } @@ -403,12 +393,8 @@ "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" }, @@ -416,12 +402,8 @@ "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" } @@ -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" } @@ -521,9 +499,7 @@ "sessions_token_unique": { "name": "sessions_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -685,9 +661,7 @@ "users_slack_id_unique": { "name": "users_slack_id_unique", "nullsNotDistinct": false, - "columns": [ - "slack_id" - ] + "columns": ["slack_id"] } }, "policies": {}, @@ -706,4 +680,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ff191f2..ac93c25 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -122,4 +122,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/knip.json b/knip.json index 5da2a9f..6fee42f 100644 --- a/knip.json +++ b/knip.json @@ -1,3 +1,5 @@ { - "ignore": ["scripts/**"] + "ignore": ["scripts/**"], + "entry": ["src/test/setup.ts", "src/**/*.test.{ts,tsx}"], + "project": ["src/**/*.{ts,tsx}"] } diff --git a/package.json b/package.json index f28059f..da54b93 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/index.ts b/src/index.ts index 168ed5c..9a5837b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,11 @@ 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"; @@ -13,11 +13,17 @@ 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"; @@ -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); @@ -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); @@ -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}`); diff --git a/src/lib/errors.test.ts b/src/lib/errors.test.ts new file mode 100644 index 0000000..2e2ee32 --- /dev/null +++ b/src/lib/errors.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; + +import type { ErrorEnvelope } from "./errors"; +import { + buildErrorEnvelope, + buildNotFoundMarkdown, + createErrorHandler, + createNotFoundHandler, + negotiateErrorFormat, +} from "./errors"; + +const BASE_URL = "https://ai.hackclub.com"; + +const makeApp = () => { + const options = { baseUrl: BASE_URL }; + const app = new Hono(); + app.onError(createErrorHandler(options)); + app.notFound(createNotFoundHandler(options)); + app.get("/boom", () => { + throw new HTTPException(401, { message: "Authentication required" }); + }); + app.post("/proxy/v1/chat/completions", () => { + throw new HTTPException(401, { message: "Authentication required" }); + }); + app.get("/explode", () => { + throw new Error("kaboom"); + }); + return app; +}; + +describe("buildErrorEnvelope", () => { + test("carries a message, code, status, hint and docs link", () => { + const envelope: ErrorEnvelope = buildErrorEnvelope( + 401, + "Authentication required", + ); + + expect(envelope.error.message).toBe("Authentication required"); + expect(envelope.error.type).toBe("authentication_error"); + expect(envelope.error.code).toBe("unauthorized"); + expect(envelope.error.status).toBe(401); + expect(envelope.error.hint).toContain("Authorization: Bearer"); + expect(envelope.error.docs).toStartWith("https://docs.ai.hackclub.com"); + }); + + test("includes the request id only when there is one", () => { + expect(buildErrorEnvelope(500, "nope").request_id).toBeUndefined(); + expect(buildErrorEnvelope(500, "nope", "req_1").request_id).toBe("req_1"); + }); + + test("falls back to a generic descriptor for unmapped statuses", () => { + const envelope = buildErrorEnvelope(418, "I'm a teapot"); + expect(envelope.error.code).toBe("api_error"); + expect(envelope.error.status).toBe(418); + }); +}); + +describe("negotiateErrorFormat", () => { + test("answers API paths with JSON whatever the client accepts", () => { + expect(negotiateErrorFormat("GET", "/proxy/v1/models", "text/html")).toBe( + "json", + ); + expect(negotiateErrorFormat("GET", "/up", "text/html")).toBe("json"); + expect(negotiateErrorFormat("GET", "/api/keys", undefined)).toBe("json"); + }); + + test("treats a path that merely starts with an API prefix as a page", () => { + expect(negotiateErrorFormat("GET", "/apitude", "text/html")).toBe("html"); + expect(negotiateErrorFormat("GET", "/proxying", undefined)).toBe( + "markdown", + ); + }); + + test("answers non-GET requests with JSON", () => { + expect(negotiateErrorFormat("POST", "/anything", "text/html")).toBe("json"); + expect(negotiateErrorFormat("DELETE", "/anything", undefined)).toBe("json"); + }); + + test("gives browsers HTML and everyone else markdown", () => { + expect( + negotiateErrorFormat( + "GET", + "/missing", + "text/html,application/xhtml+xml,*/*;q=0.8", + ), + ).toBe("html"); + expect(negotiateErrorFormat("GET", "/missing", "*/*")).toBe("markdown"); + expect(negotiateErrorFormat("GET", "/missing", undefined)).toBe("markdown"); + expect(negotiateErrorFormat("GET", "/missing", "application/json")).toBe( + "json", + ); + }); +}); + +describe("buildNotFoundMarkdown", () => { + const body = buildNotFoundMarkdown(BASE_URL, "/nope"); + + test("names the missing path", () => { + expect(body).toContain("`/nope`"); + }); + + test("points at the recovery resources agents need", () => { + expect(body).toContain(`${BASE_URL}/llms.txt`); + expect(body).toContain(`${BASE_URL}/openapi.json`); + expect(body).toContain(`${BASE_URL}/sitemap.xml`); + expect(body).toContain("https://docs.ai.hackclub.com"); + }); + + test("is short enough to be cheap for an agent to read", () => { + expect(body.length).toBeLessThan(1500); + }); +}); + +describe("404 handling", () => { + test("returns 404 with a markdown body for a bare Accept header", async () => { + const res = await makeApp().request("/some-path-that-does-not-exist"); + + expect(res.status).toBe(404); + expect(res.headers.get("Content-Type")).toBe( + "text/markdown; charset=utf-8", + ); + + const body = await res.text(); + expect(body).toStartWith("# 404"); + expect(body).toContain("/llms.txt"); + }); + + test("returns a JSON envelope under API prefixes", async () => { + const res = await makeApp().request("/proxy/v1/nope"); + + expect(res.status).toBe(404); + expect(res.headers.get("Content-Type")).toContain("application/json"); + + const body = (await res.json()) as { error: Record }; + expect(body.error.code).toBe("not_found"); + expect(body.error.status).toBe(404); + expect(body.error.message).toBe("No route matches GET /proxy/v1/nope"); + expect(body.error.hint).toContain("/openapi.json"); + }); + + test("returns a JSON envelope for a POST to an unknown page", async () => { + const res = await makeApp().request("/nope", { method: "POST" }); + + expect(res.status).toBe(404); + expect(res.headers.get("Content-Type")).toContain("application/json"); + }); + + test("uses the HTML renderer for browsers when one is supplied", async () => { + const app = new Hono(); + app.notFound( + createNotFoundHandler({ + baseUrl: BASE_URL, + renderNotFoundPage: (c, path) => c.html(`

gone: ${path}

`, 404), + }), + ); + + const res = await app.request("/missing", { + headers: { Accept: "text/html" }, + }); + + expect(res.status).toBe(404); + expect(res.headers.get("Content-Type")).toContain("text/html"); + expect(await res.text()).toContain("gone: /missing"); + }); + + test("falls back to markdown for browsers when no renderer is supplied", async () => { + const res = await makeApp().request("/missing", { + headers: { Accept: "text/html" }, + }); + + expect(res.status).toBe(404); + expect(res.headers.get("Content-Type")).toBe( + "text/markdown; charset=utf-8", + ); + }); +}); + +describe("error handling", () => { + test("renders an HTTPException as the JSON envelope", async () => { + const res = await makeApp().request("/boom"); + + expect(res.status).toBe(401); + expect(res.headers.get("Content-Type")).toContain("application/json"); + + const body = (await res.json()) as { error: Record }; + expect(body.error.message).toBe("Authentication required"); + expect(body.error.code).toBe("unauthorized"); + expect(body.error.docs).toBe( + "https://docs.ai.hackclub.com/guide/authentication", + ); + }); + + test("renders an API 401 as JSON, not text/plain", async () => { + const res = await makeApp().request("/proxy/v1/chat/completions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + + expect(res.status).toBe(401); + const body = (await res.json()) as { error: { type: string } }; + // OpenAI SDKs read error.type / error.message. + expect(body.error.type).toBe("authentication_error"); + }); + + test("hides unhandled errors behind a 500 envelope and reports them", async () => { + const reported: Error[] = []; + const app = new Hono(); + app.onError( + createErrorHandler({ + baseUrl: BASE_URL, + onUnhandled: (err) => reported.push(err), + }), + ); + app.get("/explode", () => { + throw new Error("kaboom with a secret in it"); + }); + + const res = await app.request("/explode"); + + expect(res.status).toBe(500); + const body = (await res.json()) as { error: { message: string } }; + expect(body.error.message).toBe("Internal server error"); + expect(body.error.message).not.toContain("secret"); + expect(reported).toHaveLength(1); + expect(reported[0].message).toBe("kaboom with a secret in it"); + }); + + test("propagates the request id set by hono's requestId middleware", async () => { + const app = new Hono<{ Variables: { requestId: string } }>(); + app.onError(createErrorHandler({ baseUrl: BASE_URL })); + app.get("/boom", (c) => { + c.set("requestId", "req_abc"); + throw new HTTPException(400, { message: "bad" }); + }); + + const res = await app.request("/boom"); + const body = (await res.json()) as { request_id?: string }; + expect(body.request_id).toBe("req_abc"); + }); +}); diff --git a/src/lib/errors.ts b/src/lib/errors.ts new file mode 100644 index 0000000..7381a78 --- /dev/null +++ b/src/lib/errors.ts @@ -0,0 +1,261 @@ +import type { Context, ErrorHandler, NotFoundHandler } from "hono"; +import { HTTPException } from "hono/http-exception"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +export const DOCS_URL = "https://docs.ai.hackclub.com"; + +/** + * Structured error body. The shape is deliberately OpenAI-compatible + * (`error.message` / `error.type` / `error.code`) so that the OpenAI, OpenRouter + * and Vercel AI SDKs surface a useful message instead of "undefined", and it + * carries two extra machine-readable fields — `status` and `hint` — so an agent + * can decide what to do next without scraping prose. + */ +export type ErrorEnvelope = { + error: { + message: string; + type: string; + code: string; + status: number; + hint: string; + docs: string; + }; + request_id?: string; +}; + +type ErrorDescriptor = { + type: string; + code: string; + hint: string; + docs: string; +}; + +const GENERIC: ErrorDescriptor = { + type: "api_error", + code: "api_error", + hint: "Retry the request. If it keeps failing, ask in #hackclub-ai on the Hack Club Slack.", + docs: DOCS_URL, +}; + +// Status -> stable machine-readable code plus a resolution hint. Keyed by +// status because that is all a thrown HTTPException reliably carries. +const DESCRIPTORS: Record = { + 400: { + type: "invalid_request_error", + code: "invalid_request", + hint: "Check the request body against the OpenAPI spec at /openapi.json.", + docs: `${DOCS_URL}/api/chat-completions`, + }, + 401: { + type: "authentication_error", + code: "unauthorized", + hint: "Send `Authorization: Bearer sk-hc-v1-...`. Create a key at https://ai.hackclub.com/keys.", + docs: `${DOCS_URL}/guide/authentication`, + }, + 403: { + type: "permission_error", + code: "forbidden", + hint: "Your account, model or client is not permitted to make this request. The message says which.", + docs: `${DOCS_URL}/guide/rules`, + }, + 404: { + type: "invalid_request_error", + code: "not_found", + hint: "Check /openapi.json for the endpoints that exist, or /llms.txt for a map of the site.", + docs: DOCS_URL, + }, + 405: { + type: "invalid_request_error", + code: "method_not_allowed", + hint: "Check /openapi.json for the methods this path accepts.", + docs: DOCS_URL, + }, + 413: { + type: "invalid_request_error", + code: "payload_too_large", + hint: "Send a smaller request body.", + docs: DOCS_URL, + }, + 422: { + type: "invalid_request_error", + code: "unprocessable_entity", + hint: "The body parsed but failed validation. Check /openapi.json for the expected schema.", + docs: DOCS_URL, + }, + 429: { + type: "rate_limit_error", + code: "rate_limit_exceeded", + hint: "Back off and retry later. Limits are documented at https://docs.ai.hackclub.com/guide/rules.", + docs: `${DOCS_URL}/guide/rules`, + }, + 500: GENERIC, + 502: { + type: "api_error", + code: "upstream_error", + hint: "The upstream model provider failed. Retry with backoff; check https://ai.hackclub.com/up for service status.", + docs: `${DOCS_URL}/api/healthcheck`, + }, + 503: { + type: "api_error", + code: "service_unavailable", + hint: "The service is temporarily unavailable. Retry with backoff; check https://ai.hackclub.com/up for service status.", + docs: `${DOCS_URL}/api/healthcheck`, + }, + 504: { + type: "api_error", + code: "upstream_timeout", + hint: "The upstream model provider did not respond in time. Retry, or set a smaller max_tokens.", + docs: `${DOCS_URL}/api/healthcheck`, + }, +}; + +const describeStatus = (status: number): ErrorDescriptor => + DESCRIPTORS[status] ?? GENERIC; + +export const buildErrorEnvelope = ( + status: number, + message: string, + requestId?: string, +): ErrorEnvelope => { + const descriptor = describeStatus(status); + return { + error: { + message, + type: descriptor.type, + code: descriptor.code, + status, + hint: descriptor.hint, + docs: descriptor.docs, + }, + ...(requestId ? { request_id: requestId } : {}), + }; +}; + +// Everything under these prefixes is machine-facing, so it always answers in +// JSON regardless of what the client said it accepts. +const API_PREFIXES = ["/proxy", "/api", "/internal", "/up"]; + +const isApiPath = (path: string): boolean => + API_PREFIXES.some( + (prefix) => path === prefix || path.startsWith(`${prefix}/`), + ); + +/** + * Decide the representation for an error. Browsers get HTML, API clients and + * anything non-GET get JSON, and everyone else (curl, crawlers, agents sending + * a bare wildcard Accept header) gets markdown they can actually read. + */ +type ErrorFormat = "json" | "html" | "markdown"; + +export const negotiateErrorFormat = ( + method: string, + path: string, + accept: string | undefined, +): ErrorFormat => { + if (method !== "GET" && method !== "HEAD") return "json"; + if (isApiPath(path)) return "json"; + + const header = (accept ?? "").toLowerCase(); + if (header.includes("application/json")) return "json"; + if (header.includes("text/html")) return "html"; + return "markdown"; +}; + +const requestIdOf = (c: Context): string | undefined => { + // Set by hono's requestId() middleware; falls back to the inbound header. + const fromContext = (c as Context<{ Variables: { requestId?: string } }>).get( + "requestId", + ); + return fromContext ?? c.req.header("X-Request-Id"); +}; + +const jsonError = (c: Context, status: number, message: string): Response => + c.json( + buildErrorEnvelope(status, message, requestIdOf(c)), + status as ContentfulStatusCode, + ); + +/** + * Short markdown body for a 404, so an agent that lands on a dead URL can + * recover on its own instead of guessing. + */ +export const buildNotFoundMarkdown = ( + baseUrl: string, + path: string, +): string => `# 404 — Not Found + +\`${path}\` does not exist on ${baseUrl}. + +## Where to look next + +- [${baseUrl}/llms.txt](${baseUrl}/llms.txt) — map of this site, written for agents +- [${baseUrl}/openapi.json](${baseUrl}/openapi.json) — the complete HTTP API surface +- [${baseUrl}/sitemap.xml](${baseUrl}/sitemap.xml) — every indexable URL +- [${DOCS_URL}](${DOCS_URL}) — guides and API reference +- [${baseUrl}/](${baseUrl}/) — sign in with Hack Club to get an API key + +## Common entry points + +- \`GET ${baseUrl}/proxy/v1/models\` — list available models (no auth required) +- \`POST ${baseUrl}/proxy/v1/chat/completions\` — OpenAI-compatible chat completions +- \`GET ${baseUrl}/up\` — health check +`; + +export type ErrorHandlerOptions = { + baseUrl: string; + /** + * Renders the branded HTML 404 page. Must return a 404 response. Omitted in + * tests and by any caller that has no views to render. + */ + renderNotFoundPage?: ( + c: Context, + path: string, + ) => Response | Promise; + /** Called for unhandled (non-HTTPException) errors, e.g. to report to Sentry. */ + onUnhandled?: (err: Error, c: Context) => void; +}; + +export const createNotFoundHandler = + ({ baseUrl, renderNotFoundPage }: ErrorHandlerOptions): NotFoundHandler => + async (c) => { + const path = c.req.path; + const format = negotiateErrorFormat( + c.req.method, + path, + c.req.header("Accept"), + ); + const message = `No route matches ${c.req.method} ${path}`; + + if (format !== "json") { + console.warn(`[404 ${c.req.method}] ${path}`); + } + + if (format === "html" && renderNotFoundPage) { + return await renderNotFoundPage(c, path); + } + + if (format === "json") { + return jsonError(c, 404, message); + } + + return c.text(buildNotFoundMarkdown(baseUrl, path), 404, { + "Content-Type": "text/markdown; charset=utf-8", + }); + }; + +export const createErrorHandler = + ({ onUnhandled }: ErrorHandlerOptions): ErrorHandler => + (err, c) => { + if (err instanceof HTTPException) { + // Deliberately ignores err.res: HTTPException's own response is + // text/plain, and callers that attach a custom one only ever attach an + // ad-hoc JSON shape. Both are replaced by the single envelope below. + const status = err.status; + const message = err.message || describeStatus(status).code; + return jsonError(c, status, message); + } + + console.error("Unhandled error:", err); + onUnhandled?.(err, c); + return jsonError(c, 500, "Internal server error"); + }; diff --git a/src/lib/models.ts b/src/lib/models.ts index a228d3d..5eca352 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -1,9 +1,4 @@ -import { - allowedEmbeddingModels, - allowedImageModels, - allowedLanguageModels, - env, -} from "../env"; +import { env } from "../env"; export type OpenRouterModel = { id: string; @@ -59,7 +54,6 @@ export const openRouterHeaders = { function createModelsFetcher( key: string, endpoint: string, - allowedModels: string[], ): () => Promise { return async () => { const state = cacheState[key]; @@ -107,19 +101,13 @@ function createModelsFetcher( export const fetchLanguageModels = createModelsFetcher( "language", "/v1/models", - allowedLanguageModels, ); -export const fetchImageModels = createModelsFetcher( - "image", - "/v1/models", - allowedImageModels, -); +export const fetchImageModels = createModelsFetcher("image", "/v1/models"); export const fetchEmbeddingModels = createModelsFetcher( "embedding", "/v1/embeddings/models", - allowedEmbeddingModels, ); type AllModelsResponse = { diff --git a/src/lib/openapi.test.ts b/src/lib/openapi.test.ts new file mode 100644 index 0000000..0f7a13c --- /dev/null +++ b/src/lib/openapi.test.ts @@ -0,0 +1,252 @@ +import { describe, expect, test } from "bun:test"; + +import { buildOpenApiDocument } from "./openapi"; + +const BASE_URL = "https://ai.hackclub.com"; + +const doc = buildOpenApiDocument({ + baseUrl: BASE_URL, + languageModels: ["qwen/qwen3-32b", "openai/gpt-5-mini"], + imageModels: ["google/gemini-2.5-flash-image"], + embeddingModels: ["qwen/qwen3-embedding-8b"], +}); + +type Operation = Record & { + parameters?: unknown[]; + operationId?: string; + responses?: Record; + tags?: string[]; +}; + +const paths = doc.paths as Record>; +const components = doc.components as { + schemas: Record; + securitySchemes: Record; +}; + +const METHODS = ["get", "post", "put", "patch", "delete"]; + +const operations = (): Array<[string, string, Operation]> => + Object.entries(paths).flatMap(([path, item]) => + Object.entries(item) + .filter(([method]) => METHODS.includes(method)) + .map(([method, op]) => [path, method, op] as [string, string, Operation]), + ); + +describe("document envelope", () => { + test("declares OpenAPI 3.2", () => { + expect(doc.openapi).toBe("3.1.0"); + }); + + test("has the required info fields", () => { + const info = doc.info as Record; + expect(info.title).toBe("Hack Club AI"); + expect(info.version).toBeTruthy(); + expect(info.description).toBeTruthy(); + expect((info.contact as { email: string }).email).toContain("@"); + }); + + test("points at the production server, without a trailing slash", () => { + expect(doc.servers).toEqual([{ url: BASE_URL, description: "Production" }]); + expect( + buildOpenApiDocument({ + baseUrl: "https://ai.hackclub.com/", + languageModels: [], + imageModels: [], + embeddingModels: [], + }).servers, + ).toEqual([{ url: BASE_URL, description: "Production" }]); + }); + + test("defaults to bearer auth and defines the scheme", () => { + expect(doc.security).toEqual([{ bearerAuth: [] }]); + expect(components.securitySchemes.bearerAuth).toMatchObject({ + type: "http", + scheme: "bearer", + }); + }); + + test("survives a round trip through JSON", () => { + expect(() => JSON.parse(JSON.stringify(doc))).not.toThrow(); + }); +}); + +describe("paths", () => { + test("documents every endpoint an API consumer can call", () => { + for (const path of [ + "/proxy/v1/models", + "/proxy/v1/embeddings/models", + "/proxy/v1/chat/completions", + "/proxy/v1/responses", + "/proxy/v1/embeddings", + "/proxy/v1/images/generations", + "/proxy/v1/moderations", + "/proxy/v1/ocr", + "/proxy/v1/stats", + "/proxy/v1/exa/search", + "/proxy/v1/exa/findSimilar", + "/proxy/v1/exa/contents", + "/proxy/v1/exa/answer", + "/proxy/v1/replicate/predictions", + "/proxy/v1/replicate/predictions/{id}", + "/proxy/v1/replicate/predictions/{id}/cancel", + "/proxy/v1/replicate/models/{owner}/{model}", + "/proxy/v1/replicate/models/{owner}/{model}/predictions", + "/proxy/v1/replicate/models/{owner}/{model}/versions", + "/proxy/v1/replicate/models/{owner}/{model}/versions/{id}", + "/proxy/v1/replicate/deployments", + "/proxy/v1/replicate/deployments/{owner}/{name}", + "/proxy/v1/replicate/deployments/{owner}/{name}/predictions", + "/proxy/v1/replicate/files", + "/proxy/v1/replicate/files/{id}", + "/up", + "/robots.txt", + "/openapi.json", + "/llms.txt", + "/sitemap.xml", + ]) { + // Array form: these paths contain dots, which the string form treats + // as nested-property separators. + expect(paths).toHaveProperty([path]); + } + }); + + test("every path is server-relative and starts with a slash", () => { + for (const path of Object.keys(paths)) { + expect(path).toStartWith("/"); + expect(path).not.toContain(BASE_URL); + } + }); + + test("every operation has a unique operationId, a summary and a tag", () => { + const seen = new Set(); + for (const [path, method, op] of operations()) { + const where = `${method.toUpperCase()} ${path}`; + expect(op.operationId, where).toBeTruthy(); + expect(seen.has(op.operationId as string), where).toBe(false); + seen.add(op.operationId as string); + expect(op.summary, where).toBeTruthy(); + expect(op.tags?.length, where).toBeGreaterThan(0); + } + }); + + test("every operation tag is declared at the top level", () => { + const declared = new Set( + (doc.tags as Array<{ name: string }>).map((t) => t.name), + ); + for (const [, , op] of operations()) { + for (const tag of op.tags ?? []) expect(declared.has(tag)).toBe(true); + } + }); + + test("every operation documents a 200 response", () => { + for (const [path, method, op] of operations()) { + expect(op.responses, `${method.toUpperCase()} ${path}`).toHaveProperty( + "200", + ); + } + }); + + test("every path parameter in a template is declared", () => { + for (const [path, method, op] of operations()) { + const templated = [...path.matchAll(/\{(\w+)\}/g)].map((m) => m[1]); + const declared = ((op.parameters ?? []) as Array<{ name: string }>).map( + (p) => p.name, + ); + for (const name of templated) { + expect(declared, `${method.toUpperCase()} ${path}`).toContain(name); + } + } + }); + + test("public endpoints opt out of auth, authenticated ones do not", () => { + for (const path of [ + "/proxy/v1/models", + "/proxy/v1/embeddings/models", + "/up", + "/robots.txt", + "/openapi.json", + "/llms.txt", + "/sitemap.xml", + ]) { + expect(paths[path].get?.security, path).toEqual([]); + } + + expect(paths["/proxy/v1/chat/completions"].post?.security).toBeUndefined(); + expect(paths["/proxy/v1/stats"].get?.security).toBeUndefined(); + }); + + test("authenticated operations document 401 and 429 with the error schema", () => { + for (const path of [ + "/proxy/v1/chat/completions", + "/proxy/v1/embeddings", + "/proxy/v1/stats", + ]) { + const op = paths[path].get ?? paths[path].post; + const responses = op?.responses as Record< + string, + { content: Record } + >; + for (const status of ["401", "429"]) { + expect( + responses[status].content["application/json"].schema.$ref, + `${path} ${status}`, + ).toBe("#/components/schemas/ErrorResponse"); + } + } + }); +}); + +describe("schemas", () => { + test("every $ref resolves to a defined component schema", () => { + const refs = [...JSON.stringify(doc).matchAll(/"\$ref":"([^"]+)"/g)].map( + (m) => m[1], + ); + expect(refs.length).toBeGreaterThan(0); + for (const ref of refs) { + expect(ref).toStartWith("#/components/schemas/"); + expect(components.schemas).toHaveProperty([ + ref.replace("#/components/schemas/", ""), + ]); + } + }); + + test("ErrorResponse mirrors what the server actually sends", () => { + const schema = components.schemas.ErrorResponse as { + properties: { error: { required: string[] } }; + }; + expect(schema.properties.error.required).toEqual([ + "message", + "type", + "code", + "status", + "hint", + "docs", + ]); + }); + + test("the configured models appear as examples so agents can copy one", () => { + const chat = components.schemas.ChatCompletionRequest as { + properties: { model: { examples: string[] } }; + }; + expect(chat.properties.model.examples).toContain("qwen/qwen3-32b"); + + const embedding = components.schemas.EmbeddingRequest as { + properties: { model: { examples: string[] } }; + }; + expect(embedding.properties.model.examples).toContain( + "qwen/qwen3-embedding-8b", + ); + }); + + test("does not fall over when no models are configured", () => { + expect(() => + buildOpenApiDocument({ + baseUrl: BASE_URL, + languageModels: [], + imageModels: [], + embeddingModels: [], + }), + ).not.toThrow(); + }); +}); diff --git a/src/lib/openapi.ts b/src/lib/openapi.ts new file mode 100644 index 0000000..9fe89a1 --- /dev/null +++ b/src/lib/openapi.ts @@ -0,0 +1,1121 @@ +import { DOCS_URL } from "./errors"; +import { SITE_DESCRIPTION } from "./site"; + +type OpenApiOptions = { + baseUrl: string; + languageModels: string[]; + imageModels: string[]; + embeddingModels: string[]; +}; + +const trimTrailingSlash = (url: string): string => url.replace(/\/+$/, ""); + +/** + * Hand-written OpenAPI 3.2 description of the public HTTP surface. + * + * The proxy forwards most bodies to OpenRouter, Mistral, Exa or Replicate + * largely untouched, so request/response schemas here are deliberately open + * (`additionalProperties: true`) and document the fields this service reads, + * validates or bills on rather than restating each upstream's full schema. + */ +export const buildOpenApiDocument = ({ + baseUrl, + languageModels, + imageModels, + embeddingModels, +}: OpenApiOptions): Record => { + const base = trimTrailingSlash(baseUrl); + + const errorResponse = (description: string) => ({ + description, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }); + + const commonErrors = { + "400": errorResponse( + "The request body was malformed or failed validation.", + ), + "401": errorResponse("Missing or invalid API key."), + "403": errorResponse( + "The account is banned, not identity-verified, or the client is a blocked AI coding agent.", + ), + "429": errorResponse( + "Rate limit or daily spending limit exceeded for this account.", + ), + "500": errorResponse("Unexpected server error."), + "504": errorResponse("The upstream provider did not respond in time."), + }; + + const passthroughRequest = (description: string, example?: unknown) => ({ + required: true, + content: { + "application/json": { + schema: { + type: "object", + additionalProperties: true, + description, + }, + ...(example === undefined ? {} : { example }), + }, + }, + }); + + const pathParam = (name: string, description: string) => ({ + name, + in: "path", + required: true, + schema: { type: "string" }, + description, + }); + + const predictionIdParam = pathParam( + "id", + "Replicate prediction id (lowercase alphanumeric).", + ); + const fileIdParam = pathParam("id", "Replicate file id."); + const ownerParam = pathParam( + "owner", + "Replicate model owner, e.g. `openai`.", + ); + const modelParam = pathParam( + "model", + "Replicate model name, optionally with a `:version` suffix.", + ); + const deploymentNameParam = pathParam("name", "Replicate deployment name."); + + const passthroughResponse = (description: string) => ({ + description, + content: { + "application/json": { + schema: { type: "object", additionalProperties: true }, + }, + }, + }); + + return { + openapi: "3.1.0", + info: { + title: "Hack Club AI", + version: "1.0.0", + summary: "Free, OpenAI-compatible AI API for Hack Clubbers.", + description: [ + SITE_DESCRIPTION, + "", + `Point any OpenAI-compatible SDK at \`${base}/proxy/v1\` and pass a Hack Club AI key as a bearer token.`, + `Create a key at ${base}/keys after signing in with a Hack Club account.`, + "", + "Errors are always JSON in the OpenAI error shape, with two extra fields: `error.hint` (what to do about it) and `error.docs` (where to read more).", + ].join("\n"), + termsOfService: `${DOCS_URL}/guide/rules`, + contact: { + name: "Hack Club", + email: "team@hackclub.com", + url: "https://hackclub.com/slack", + }, + license: { + name: "MIT", + identifier: "MIT", + }, + }, + externalDocs: { + description: "Hack Club AI documentation", + url: DOCS_URL, + }, + servers: [{ url: base, description: "Production" }], + security: [{ bearerAuth: [] }], + tags: [ + { name: "Models", description: "Discover which models are available." }, + { name: "Chat", description: "Text generation, OpenAI-compatible." }, + { name: "Embeddings", description: "Vector embeddings." }, + { name: "Images", description: "Image generation." }, + { name: "Moderation", description: "Content classification." }, + { name: "OCR", description: "Text extraction from images and PDFs." }, + { name: "Search", description: "Web search and retrieval via Exa." }, + { + name: "Replicate", + description: + "Replicate models for image, speech-to-text and text-to-speech. Gated behind a per-account feature flag.", + }, + { name: "Account", description: "Usage statistics for the calling key." }, + { name: "Service", description: "Health and status." }, + ], + paths: { + "/proxy/v1/models": { + get: { + tags: ["Models"], + operationId: "listModels", + summary: "List chat and image models", + description: + "Returns every language and image model this proxy will accept, in the OpenAI `GET /v1/models` shape. No authentication required.", + security: [], + responses: { + "200": { + description: "The available chat and image models.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ModelList" }, + }, + }, + }, + "403": commonErrors["403"], + "500": commonErrors["500"], + }, + }, + }, + "/proxy/v1/embeddings/models": { + get: { + tags: ["Models"], + operationId: "listEmbeddingModels", + summary: "List embedding models", + description: + "Returns every embedding model this proxy will accept, in the OpenRouter models shape. No authentication required.", + security: [], + responses: { + "200": { + description: "The available embedding models.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ModelList" }, + }, + }, + }, + "403": commonErrors["403"], + "500": commonErrors["500"], + }, + }, + }, + "/proxy/v1/chat/completions": { + post: { + tags: ["Chat"], + operationId: "createChatCompletion", + summary: "Create a chat completion", + description: + "OpenAI-compatible chat completions. Supports streaming (`stream: true`, server-sent events), vision inputs, PDF inputs and image output modalities. Usage and cost are recorded against the calling key.", + externalDocs: { url: `${DOCS_URL}/api/chat-completions` }, + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ChatCompletionRequest" }, + example: { + model: languageModels[0] ?? "qwen/qwen3-32b", + messages: [{ role: "user", content: "Tell me a joke." }], + }, + }, + }, + }, + responses: { + "200": { + description: + "A chat completion. `text/event-stream` when `stream` is true.", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/ChatCompletionResponse", + }, + }, + "text/event-stream": { + schema: { + type: "string", + description: + "OpenAI-style SSE chunks, terminated by `data: [DONE]`.", + }, + }, + }, + }, + ...commonErrors, + }, + }, + }, + "/proxy/v1/responses": { + post: { + tags: ["Chat"], + operationId: "createResponse", + summary: "Create a response (Responses API)", + description: + "OpenAI Responses API equivalent. Accepts simple text input, structured messages and streaming.", + externalDocs: { url: `${DOCS_URL}/api/responses` }, + requestBody: passthroughRequest( + "An OpenAI Responses API request. `model` is required.", + { + model: languageModels[0] ?? "qwen/qwen3-32b", + input: "Write a haiku about Vermont.", + }, + ), + responses: { + "200": passthroughResponse( + "A response object. `text/event-stream` when `stream` is true.", + ), + ...commonErrors, + }, + }, + }, + "/proxy/v1/embeddings": { + post: { + tags: ["Embeddings"], + operationId: "createEmbedding", + summary: "Create embeddings", + description: + "OpenAI-compatible embeddings. Pass a single string or an array of strings.", + externalDocs: { url: `${DOCS_URL}/api/embeddings` }, + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/EmbeddingRequest" }, + example: { + model: embeddingModels[0] ?? "qwen/qwen3-embedding-8b", + input: "The quick brown fox jumps over the lazy dog", + }, + }, + }, + }, + responses: { + "200": { + description: "The embedding vectors.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/EmbeddingResponse" }, + }, + }, + }, + ...commonErrors, + }, + }, + }, + "/proxy/v1/images/generations": { + post: { + tags: ["Images"], + operationId: "createImage", + summary: "Generate an image", + description: + "OpenAI-compatible image generation. `size` is mapped to the nearest supported aspect ratio (1:1, 16:9, 9:16).", + externalDocs: { url: `${DOCS_URL}/api/image-generation` }, + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ImageRequest" }, + example: { + model: imageModels[0] ?? "google/gemini-2.5-flash-image", + prompt: "A pixel-art orpheus the dinosaur waving a flag", + size: "1024x1024", + }, + }, + }, + }, + responses: { + "200": { + description: "The generated images.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ImageResponse" }, + }, + }, + }, + ...commonErrors, + }, + }, + }, + "/proxy/v1/moderations": { + post: { + tags: ["Moderation"], + operationId: "createModeration", + summary: "Classify text or images", + description: + "OpenAI-compatible moderation. Classifies whether input is potentially harmful.", + externalDocs: { url: `${DOCS_URL}/api/moderations` }, + requestBody: passthroughRequest("An OpenAI moderations request.", { + model: "omni-moderation-latest", + input: "I want to hurt someone.", + }), + responses: { + "200": passthroughResponse("The moderation result."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/ocr": { + post: { + tags: ["OCR"], + operationId: "createOcr", + summary: "Extract text from an image or PDF", + description: + "Runs OCR over an image or document and returns markdown per page. Gated behind the `enable_ocr` feature flag; accounts without it get 403.", + externalDocs: { url: `${DOCS_URL}/api/ocr` }, + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/OcrRequest" }, + example: { + document: { + type: "document_url", + document_url: "https://example.com/invoice.pdf", + }, + }, + }, + }, + }, + responses: { + "200": passthroughResponse("Pages of extracted markdown."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/exa/search": { + post: { + tags: ["Search"], + operationId: "exaSearch", + summary: "Search the web", + description: + "Neural or keyword web search via Exa, optionally returning page contents. Gated behind the `enable_exa` feature flag; accounts without it get 403.", + externalDocs: { url: `${DOCS_URL}/api/exa` }, + requestBody: passthroughRequest("An Exa `/search` request.", { + query: "best resources for learning Rust", + numResults: 5, + }), + responses: { + "200": passthroughResponse("Exa search results."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/exa/findSimilar": { + post: { + tags: ["Search"], + operationId: "exaFindSimilar", + summary: "Find pages similar to a URL", + externalDocs: { url: `${DOCS_URL}/api/exa` }, + requestBody: passthroughRequest("An Exa `/findSimilar` request.", { + url: "https://hackclub.com", + numResults: 5, + }), + responses: { + "200": passthroughResponse("Similar pages."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/exa/contents": { + post: { + tags: ["Search"], + operationId: "exaContents", + summary: "Fetch live page contents", + externalDocs: { url: `${DOCS_URL}/api/exa` }, + requestBody: passthroughRequest("An Exa `/contents` request.", { + urls: ["https://hackclub.com"], + text: true, + }), + responses: { + "200": passthroughResponse("Page contents."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/exa/answer": { + post: { + tags: ["Search"], + operationId: "exaAnswer", + summary: "Answer a question with cited sources", + description: + "Supports `stream: true`, in which case the response is `text/event-stream`.", + externalDocs: { url: `${DOCS_URL}/api/exa` }, + requestBody: passthroughRequest("An Exa `/answer` request.", { + query: "When was Hack Club founded?", + }), + responses: { + "200": passthroughResponse( + "An answer with sources. `text/event-stream` when `stream` is true.", + ), + ...commonErrors, + }, + }, + }, + "/proxy/v1/replicate/predictions": { + get: { + tags: ["Replicate"], + operationId: "listReplicatePredictions", + summary: "List your Replicate predictions", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + responses: { + "200": passthroughResponse("A page of predictions."), + ...commonErrors, + }, + }, + post: { + tags: ["Replicate"], + operationId: "createReplicatePrediction", + summary: "Create a prediction", + description: + "Send either `model` (owner/name) or a `version` that maps to an allowed model. Models outside the allow-list are rejected with 403.", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + requestBody: passthroughRequest("A Replicate prediction request.", { + model: "black-forest-labs/flux-schnell", + input: { prompt: "a red flag on a mountain" }, + }), + responses: { + "200": passthroughResponse("The created prediction."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/replicate/predictions/{id}": { + get: { + tags: ["Replicate"], + operationId: "getReplicatePrediction", + summary: "Get a prediction", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + parameters: [predictionIdParam], + responses: { + "200": passthroughResponse("The prediction."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/replicate/predictions/{id}/cancel": { + post: { + tags: ["Replicate"], + operationId: "cancelReplicatePrediction", + summary: "Cancel a running prediction", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + parameters: [predictionIdParam], + responses: { + "200": passthroughResponse("The cancelled prediction."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/replicate/models/{owner}/{model}": { + get: { + tags: ["Replicate"], + operationId: "getReplicateModel", + summary: "Get an allowed Replicate model", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + parameters: [ownerParam, modelParam], + responses: { + "200": passthroughResponse("The model."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/replicate/models/{owner}/{model}/predictions": { + post: { + tags: ["Replicate"], + operationId: "createReplicateModelPrediction", + summary: "Run an allowed Replicate model", + description: + "`model` may carry a `:version` suffix, which must match the allow-list.", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + parameters: [ownerParam, modelParam], + requestBody: passthroughRequest("A Replicate prediction input.", { + input: { prompt: "a red flag on a mountain" }, + }), + responses: { + "200": passthroughResponse("The created prediction."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/replicate/models/{owner}/{model}/versions": { + get: { + tags: ["Replicate"], + operationId: "listReplicateModelVersions", + summary: "List versions of an allowed model", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + parameters: [ownerParam, modelParam], + responses: { + "200": passthroughResponse("The model's versions."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/replicate/models/{owner}/{model}/versions/{id}": { + get: { + tags: ["Replicate"], + operationId: "getReplicateModelVersion", + summary: "Get one version of an allowed model", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + parameters: [ + ownerParam, + modelParam, + pathParam("id", "Replicate model version id."), + ], + responses: { + "200": passthroughResponse("The model version."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/replicate/deployments": { + get: { + tags: ["Replicate"], + operationId: "listReplicateDeployments", + summary: "List available deployments", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + responses: { + "200": passthroughResponse("The deployments."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/replicate/deployments/{owner}/{name}": { + get: { + tags: ["Replicate"], + operationId: "getReplicateDeployment", + summary: "Get a deployment", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + parameters: [ownerParam, deploymentNameParam], + responses: { + "200": passthroughResponse("The deployment."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/replicate/deployments/{owner}/{name}/predictions": { + post: { + tags: ["Replicate"], + operationId: "createReplicateDeploymentPrediction", + summary: "Run a deployment", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + parameters: [ownerParam, deploymentNameParam], + requestBody: passthroughRequest("A Replicate prediction input.", { + input: { prompt: "a red flag on a mountain" }, + }), + responses: { + "200": passthroughResponse("The created prediction."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/replicate/files": { + post: { + tags: ["Replicate"], + operationId: "uploadReplicateFile", + summary: "Upload a file for use as model input", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + required: ["content"], + properties: { + content: { type: "string", format: "binary" }, + }, + }, + }, + }, + }, + responses: { + "200": passthroughResponse("The uploaded file."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/replicate/files/{id}": { + get: { + tags: ["Replicate"], + operationId: "getReplicateFile", + summary: "Get an uploaded file", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + parameters: [fileIdParam], + responses: { + "200": passthroughResponse("The file."), + ...commonErrors, + }, + }, + delete: { + tags: ["Replicate"], + operationId: "deleteReplicateFile", + summary: "Delete an uploaded file", + externalDocs: { url: `${DOCS_URL}/guide/replicate` }, + parameters: [fileIdParam], + responses: { + "200": passthroughResponse("The deletion result."), + ...commonErrors, + }, + }, + }, + "/proxy/v1/stats": { + get: { + tags: ["Account"], + operationId: "getStats", + summary: "Get usage statistics", + description: + "Lifetime token and request totals for the account that owns the calling API key.", + externalDocs: { url: `${DOCS_URL}/api/stats` }, + responses: { + "200": { + description: "Usage totals.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Stats" }, + }, + }, + }, + "401": commonErrors["401"], + "403": commonErrors["403"], + "429": commonErrors["429"], + }, + }, + }, + "/up": { + get: { + tags: ["Service"], + operationId: "getHealth", + summary: "Health check", + description: + 'Returns 200 with `status: "up"` when the service can reach its upstream providers and has credit, and 503 with `status: "down"` otherwise. Cached for 30 seconds.', + externalDocs: { url: `${DOCS_URL}/api/healthcheck` }, + security: [], + responses: { + "200": { + description: "The service is up.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/HealthUp" }, + }, + }, + }, + "503": { + description: "The service is down.", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/HealthDown" }, + }, + }, + }, + "429": commonErrors["429"], + }, + }, + }, + "/openapi.json": { + get: { + tags: ["Service"], + operationId: "getOpenApiDocument", + summary: "This document", + description: + "Also served, byte for byte, from `/.well-known/openapi.json`.", + security: [], + responses: { + "200": passthroughResponse( + "The OpenAPI 3.2 description of this API.", + ), + }, + }, + }, + "/robots.txt": { + get: { + tags: ["Service"], + operationId: "getRobotsTxt", + summary: "Crawler policy", + security: [], + responses: { + "200": { + description: "The robots.txt document.", + content: { "text/plain": { schema: { type: "string" } } }, + }, + }, + }, + }, + "/llms.txt": { + get: { + tags: ["Service"], + operationId: "getLlmsTxt", + summary: "Agent-readable index of this site", + description: "An llms.txt document, per https://llmstxt.org.", + security: [], + responses: { + "200": { + description: "The llms.txt document.", + content: { "text/markdown": { schema: { type: "string" } } }, + }, + }, + }, + }, + "/sitemap.xml": { + get: { + tags: ["Service"], + operationId: "getSitemap", + summary: "XML sitemap", + security: [], + responses: { + "200": { + description: "The sitemap.", + content: { "application/xml": { schema: { type: "string" } } }, + }, + }, + }, + }, + }, + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + description: + "A Hack Club AI API key, sent as `Authorization: Bearer sk-hc-v1-...`. Create one at /keys.", + }, + }, + schemas: { + ErrorResponse: { + type: "object", + required: ["error"], + description: + "Every error from this API uses this shape. It is a superset of the OpenAI error object.", + properties: { + error: { + type: "object", + required: ["message", "type", "code", "status", "hint", "docs"], + properties: { + message: { + type: "string", + description: "Human-readable description of what went wrong.", + }, + type: { + type: "string", + description: "OpenAI-compatible error class.", + examples: [ + "invalid_request_error", + "authentication_error", + "permission_error", + "rate_limit_error", + "api_error", + ], + }, + code: { + type: "string", + description: "Stable machine-readable error code.", + examples: [ + "invalid_request", + "unauthorized", + "forbidden", + "not_found", + "rate_limit_exceeded", + "upstream_timeout", + ], + }, + status: { + type: "integer", + description: + "The HTTP status code, repeated for convenience.", + }, + hint: { + type: "string", + description: "What to do to resolve the error.", + }, + docs: { + type: "string", + format: "uri", + description: "Documentation covering this error.", + }, + }, + }, + request_id: { + type: "string", + description: + "Correlation id for this request; quote it when asking for support.", + }, + }, + example: { + 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: `${DOCS_URL}/guide/authentication`, + }, + }, + }, + Model: { + type: "object", + additionalProperties: true, + properties: { + id: { type: "string", examples: languageModels.slice(0, 3) }, + object: { type: "string", const: "model" }, + created: { type: "integer" }, + owned_by: { type: "string" }, + }, + }, + ModelList: { + type: "object", + required: ["data"], + properties: { + object: { type: "string", const: "list" }, + data: { + type: "array", + items: { $ref: "#/components/schemas/Model" }, + }, + }, + }, + ChatMessage: { + type: "object", + required: ["role"], + additionalProperties: true, + properties: { + role: { + type: "string", + enum: ["system", "developer", "user", "assistant", "tool"], + }, + content: { + description: + "A string, or an array of content parts for vision and PDF inputs.", + anyOf: [ + { type: "string" }, + { + type: "array", + items: { type: "object", additionalProperties: true }, + }, + ], + }, + }, + }, + ChatCompletionRequest: { + type: "object", + required: ["model", "messages"], + additionalProperties: true, + description: + "Any OpenAI chat completion field may be sent; unlisted fields are forwarded upstream unchanged.", + properties: { + model: { + type: "string", + description: + "Model id. Call GET /proxy/v1/models for the current list.", + examples: languageModels.slice(0, 5), + }, + messages: { + type: "array", + minItems: 1, + items: { $ref: "#/components/schemas/ChatMessage" }, + }, + stream: { + type: "boolean", + default: false, + description: "Stream the response as server-sent events.", + }, + temperature: { type: "number", minimum: 0, maximum: 2, default: 1 }, + top_p: { type: "number", minimum: 0, maximum: 1, default: 1 }, + max_tokens: { + type: "integer", + minimum: 1, + description: + "Setting this lowers the cost reserved against your daily limit before the request runs.", + }, + modalities: { + type: "array", + items: { type: "string", enum: ["text", "image"] }, + description: "Request image output from an image-capable model.", + }, + tools: { + type: "array", + items: { type: "object", additionalProperties: true }, + }, + }, + }, + ChatCompletionResponse: { + type: "object", + additionalProperties: true, + properties: { + id: { type: "string" }, + object: { type: "string", const: "chat.completion" }, + created: { type: "integer" }, + model: { type: "string" }, + choices: { + type: "array", + items: { type: "object", additionalProperties: true }, + }, + usage: { $ref: "#/components/schemas/Usage" }, + }, + }, + EmbeddingRequest: { + type: "object", + required: ["model", "input"], + additionalProperties: true, + properties: { + model: { + type: "string", + description: + "Embedding model id. Call GET /proxy/v1/embeddings/models for the current list.", + examples: embeddingModels.slice(0, 3), + }, + input: { + description: "Text to embed.", + anyOf: [ + { type: "string" }, + { type: "array", items: { type: "string" } }, + ], + }, + encoding_format: { type: "string", enum: ["float", "base64"] }, + }, + }, + EmbeddingResponse: { + type: "object", + additionalProperties: true, + properties: { + object: { type: "string", const: "list" }, + model: { type: "string" }, + data: { + type: "array", + items: { + type: "object", + properties: { + object: { type: "string", const: "embedding" }, + index: { type: "integer" }, + embedding: { type: "array", items: { type: "number" } }, + }, + }, + }, + usage: { $ref: "#/components/schemas/Usage" }, + }, + }, + ImageRequest: { + type: "object", + required: ["prompt"], + additionalProperties: true, + properties: { + prompt: { type: "string" }, + model: { + type: "string", + examples: imageModels.slice(0, 3), + description: `Defaults to ${imageModels[0] ?? "the first configured image model"}.`, + }, + size: { + type: "string", + enum: [ + "256x256", + "512x512", + "1024x1024", + "1792x1024", + "1024x1792", + ], + default: "1024x1024", + description: "Mapped to the nearest supported aspect ratio.", + }, + response_format: { + type: "string", + enum: ["url", "b64_json"], + default: "b64_json", + description: + "Images are returned inline as data URLs; `url` returns that data URL in the `url` field.", + }, + }, + }, + ImageResponse: { + type: "object", + properties: { + created: { type: "integer" }, + data: { + type: "array", + items: { + type: "object", + properties: { + b64_json: { type: "string" }, + url: { type: "string" }, + }, + }, + }, + }, + }, + OcrRequest: { + type: "object", + required: ["document"], + additionalProperties: true, + properties: { + model: { + type: "string", + description: "Defaults to the Mistral OCR model.", + }, + document: { + description: + "The document to read. HTTPS URLs and base64 data URLs are accepted.", + oneOf: [ + { + type: "object", + required: ["type", "image_url"], + properties: { + type: { type: "string", const: "image_url" }, + image_url: { type: "string" }, + }, + }, + { + type: "object", + required: ["type", "document_url"], + properties: { + type: { type: "string", const: "document_url" }, + document_url: { type: "string" }, + }, + }, + { + type: "object", + required: ["type", "file_id"], + properties: { + type: { type: "string", const: "file" }, + file_id: { type: "string" }, + }, + }, + ], + }, + pages: { type: "array", items: { type: "integer" } }, + include_image_base64: { type: "boolean" }, + table_format: { type: "string", enum: ["markdown", "html"] }, + }, + }, + Usage: { + type: "object", + additionalProperties: true, + properties: { + prompt_tokens: { type: "integer" }, + completion_tokens: { type: "integer" }, + total_tokens: { type: "integer" }, + cost: { + type: "number", + description: + "Cost of the request in USD, billed against your daily limit.", + }, + }, + }, + Stats: { + type: "object", + required: [ + "totalRequests", + "totalTokens", + "totalPromptTokens", + "totalCompletionTokens", + ], + properties: { + totalRequests: { type: "integer" }, + totalTokens: { type: "integer" }, + totalPromptTokens: { type: "integer" }, + totalCompletionTokens: { type: "integer" }, + }, + }, + HealthUp: { + type: "object", + required: ["status"], + properties: { + status: { type: "string", const: "up" }, + balanceRemaining: { type: "number" }, + dailyKeyUsageRemaining: { type: "number" }, + replicateUnusedCredit: { type: "number" }, + timestamp: { type: "integer" }, + }, + }, + HealthDown: { + type: "object", + required: ["status"], + properties: { + status: { type: "string", const: "down" }, + timestamp: { type: "integer" }, + }, + }, + }, + }, + }; +}; diff --git a/src/lib/site.test.ts b/src/lib/site.test.ts new file mode 100644 index 0000000..39ecb4f --- /dev/null +++ b/src/lib/site.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, test } from "bun:test"; + +import { + buildLlmsTxt, + buildRobotsTxt, + buildSitemap, + buildStructuredData, + SITE_LAST_MODIFIED, + sitemapEntries, +} from "./site"; + +const BASE_URL = "https://ai.hackclub.com"; + +type JsonLdNode = Record & { "@type": string }; + +const graphOf = (baseUrl = BASE_URL) => + buildStructuredData(baseUrl)["@graph"] as JsonLdNode[]; + +const nodeOfType = (type: string, baseUrl = BASE_URL): JsonLdNode => { + const node = graphOf(baseUrl).find((n) => n["@type"] === type); + if (!node) throw new Error(`no ${type} node in the JSON-LD graph`); + return node; +}; + +describe("buildSitemap", () => { + const xml = buildSitemap(BASE_URL); + + test("is a well-formed urlset", () => { + expect(xml).toStartWith(''); + expect(xml).toContain( + '', + ); + expect(xml.trimEnd()).toEndWith(""); + expect(xml.match(//g)).toHaveLength(sitemapEntries(BASE_URL).length); + expect(xml.match(//g)?.length).toBe(xml.match(/<\/url>/g)?.length); + }); + + test("lists the homepage and the machine-readable entry points", () => { + expect(xml).toContain(`${BASE_URL}/`); + expect(xml).toContain(`${BASE_URL}/llms.txt`); + expect(xml).toContain(`${BASE_URL}/openapi.json`); + }); + + test("gives every url a lastmod in W3C date format", () => { + const lastmods = xml.match(/([^<]+)<\/lastmod>/g) ?? []; + expect(lastmods).toHaveLength(sitemapEntries(BASE_URL).length); + expect(SITE_LAST_MODIFIED).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(Number.isNaN(Date.parse(SITE_LAST_MODIFIED))).toBe(false); + }); + + test("never emits a double slash from a base url with a trailing slash", () => { + expect(buildSitemap("https://ai.hackclub.com/")).toBe(xml); + }); + + test("stays far under the 50MB / 50,000 URL sitemap limits", () => { + expect(sitemapEntries(BASE_URL).length).toBeLessThan(50_000); + expect(Buffer.byteLength(xml)).toBeLessThan(50 * 1024 * 1024); + }); +}); + +describe("buildRobotsTxt", () => { + const txt = buildRobotsTxt(BASE_URL); + + test("allows crawling and advertises the sitemap", () => { + expect(txt).toContain("User-agent: *"); + expect(txt).toContain("Allow: /"); + expect(txt).toContain(`Sitemap: ${BASE_URL}/sitemap.xml`); + }); + + test("keeps crawlers out of authenticated-only namespaces", () => { + expect(txt).toContain("Disallow: /auth/"); + expect(txt).toContain("Disallow: /internal/"); + }); + + test("does not block the API or the discovery files", () => { + expect(txt).not.toContain("Disallow: /proxy"); + expect(txt).not.toContain("Disallow: /llms.txt"); + expect(txt).not.toContain("Disallow: /openapi.json"); + }); +}); + +describe("buildLlmsTxt", () => { + const txt = buildLlmsTxt(BASE_URL); + + test("follows the llms.txt layout: H1, blockquote, then link sections", () => { + const lines = txt.split("\n"); + expect(lines[0]).toBe("# Hack Club AI"); + expect(lines[2]).toStartWith("> "); + expect(txt).toContain("\n## Machine-readable\n"); + expect(txt).toContain("\n## Documentation\n"); + expect(txt).toContain("\n## Optional\n"); + }); + + test("links the openapi spec, the sitemap and the docs", () => { + expect(txt).toContain(`(${BASE_URL}/openapi.json)`); + expect(txt).toContain(`(${BASE_URL}/sitemap.xml)`); + expect(txt).toContain("(https://docs.ai.hackclub.com)"); + }); + + test("every bullet is a markdown link with a description", () => { + const bullets = txt.split("\n").filter((l) => l.startsWith("- ")); + expect(bullets.length).toBeGreaterThan(10); + for (const bullet of bullets) { + expect(bullet).toMatch(/^- \[[^\]]+\]\([^)]+\): .+$/); + } + }); +}); + +describe("buildStructuredData", () => { + test("is a schema.org graph", () => { + const data = buildStructuredData(BASE_URL); + expect(data["@context"]).toBe("https://schema.org"); + expect(Array.isArray(data["@graph"])).toBe(true); + }); + + test("describes the Organization, the WebSite and the product", () => { + const types = graphOf().map((n) => n["@type"]); + expect(types).toContain("Organization"); + expect(types).toContain("WebSite"); + expect(types).toContain("SoftwareApplication"); + }); + + test("Organization carries a contactPoint with an email and contactType", () => { + const org = nodeOfType("Organization"); + const contactPoints = org.contactPoint as Array>; + + expect(contactPoints.length).toBeGreaterThan(0); + for (const point of contactPoints) { + expect(point["@type"]).toBe("ContactPoint"); + expect(point.contactType).toBeTruthy(); + expect(point.email).toContain("@"); + } + expect(contactPoints.some((p) => Boolean(p.telephone))).toBe(true); + }); + + test("Organization carries a PostalAddress", () => { + const address = nodeOfType("Organization").address as Record< + string, + string + >; + + expect(address["@type"]).toBe("PostalAddress"); + for (const field of [ + "streetAddress", + "addressLocality", + "addressRegion", + "postalCode", + "addressCountry", + ]) { + expect(address[field]).toBeTruthy(); + } + }); + + test("Organization has the identity fields an agent needs", () => { + const org = nodeOfType("Organization"); + expect(org.name).toBe("Hack Club"); + expect(org.url).toBe("https://hackclub.com"); + expect(org.description).toBeTruthy(); + expect(org.logo).toBeTruthy(); + expect((org.sameAs as string[]).length).toBeGreaterThan(2); + }); + + test("SoftwareApplication states it is free and points back to the Organization", () => { + const app = nodeOfType("SoftwareApplication"); + const offers = app.offers as Record; + + expect(app.name).toBe("Hack Club AI"); + expect(app.url).toBe(`${BASE_URL}/`); + expect(app.isAccessibleForFree).toBe(true); + expect(offers.price).toBe("0"); + expect(offers.priceCurrency).toBe("USD"); + expect(app.provider).toEqual({ "@id": nodeOfType("Organization")["@id"] }); + }); + + test("every @id reference resolves to a node in the graph", () => { + const ids = new Set(graphOf().map((n) => n["@id"])); + for (const node of graphOf()) { + for (const value of Object.values(node)) { + const ref = (value as { "@id"?: string })?.["@id"]; + if (ref && node["@id"] !== ref) expect(ids.has(ref)).toBe(true); + } + } + }); + + test("serialises to JSON with no script-breaking sequences", () => { + const json = JSON.stringify(buildStructuredData(BASE_URL)); + expect(json).not.toContain(" JSON.parse(json)).not.toThrow(); + }); + + test("normalises a base url with a trailing slash", () => { + expect(nodeOfType("WebSite", "https://ai.hackclub.com/")["@id"]).toBe( + `${BASE_URL}/#website`, + ); + }); +}); diff --git a/src/lib/site.ts b/src/lib/site.ts new file mode 100644 index 0000000..d5d5143 --- /dev/null +++ b/src/lib/site.ts @@ -0,0 +1,226 @@ +import { DOCS_URL } from "./errors"; + +/** + * Last time the public, indexable content of this site changed. Bump this when + * the homepage copy, llms.txt or the published API surface changes — it is what + * in the sitemap reports. + */ +export const SITE_LAST_MODIFIED = "2026-09-01"; + +const SITE_NAME = "Hack Club AI"; +export const SITE_DESCRIPTION = + "Free, OpenAI-compatible AI API access for Hack Clubbers. Chat completions, embeddings, image generation, moderation, OCR and web search across 30+ models."; + +const ORGANIZATION_ID = "https://hackclub.com/#organization"; + +const trimTrailingSlash = (url: string): string => url.replace(/\/+$/, ""); + +const xmlEscape = (value: string): string => + value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +/** + * URLs on this host that are worth crawling. Every other page (/dashboard, + * /keys, /activity, /models, /global, /replicate) redirects anonymous visitors + * to `/`, so listing them would only advertise redirects. + */ +export const sitemapEntries = ( + baseUrl: string, +): Array<{ loc: string; changefreq: string; priority: string }> => { + const base = trimTrailingSlash(baseUrl); + return [ + { loc: `${base}/`, changefreq: "weekly", priority: "1.0" }, + { loc: `${base}/llms.txt`, changefreq: "weekly", priority: "0.8" }, + { loc: `${base}/openapi.json`, changefreq: "weekly", priority: "0.8" }, + ]; +}; + +export const buildSitemap = (baseUrl: string): string => { + const urls = sitemapEntries(baseUrl) + .map( + ({ loc, changefreq, priority }) => + ` \n` + + ` ${xmlEscape(loc)}\n` + + ` ${SITE_LAST_MODIFIED}\n` + + ` ${changefreq}\n` + + ` ${priority}\n` + + ` `, + ) + .join("\n"); + + return ` + +${urls} + +`; +}; + +export const buildRobotsTxt = (baseUrl: string): string => { + const base = trimTrailingSlash(baseUrl); + return `User-agent: * +Allow: / +Disallow: /auth/ +Disallow: /internal/ +Disallow: /api/ + +Sitemap: ${base}/sitemap.xml +`; +}; + +/** https://llmstxt.org — an agent-readable index of this site. */ +export const buildLlmsTxt = (baseUrl: string): string => { + const base = trimTrailingSlash(baseUrl); + return `# ${SITE_NAME} + +> ${SITE_DESCRIPTION} Run by Hack Club, a nonprofit network of high-school hackers. Sign in with a Hack Club account to get an API key; there is no charge and no credit card. + +The API is OpenAI-compatible: point any OpenAI SDK at \`${base}/proxy/v1\` and pass your Hack Club AI key as a bearer token. Errors come back as JSON in the OpenAI error shape, with an extra \`error.hint\` and \`error.docs\` for recovery. + +## Machine-readable + +- [OpenAPI specification](${base}/openapi.json): Complete HTTP API surface, OpenAPI 3.2. +- [Sitemap](${base}/sitemap.xml): Indexable URLs on this host. +- [Health check](${base}/up): JSON service status. 200 when up, 503 when down. +- [Model list](${base}/proxy/v1/models): Live list of chat and image models. No auth required. +- [Embedding model list](${base}/proxy/v1/embeddings/models): Live list of embedding models. No auth required. + +## Documentation + +- [Documentation home](${DOCS_URL}): Guides and full API reference. +- [Documentation index for LLMs](${DOCS_URL}/llms.txt): The docs site's own llms.txt. +- [Authentication](${DOCS_URL}/guide/authentication): How to create and send an API key. +- [Rules and rate limiting](${DOCS_URL}/guide/rules): What is allowed, and the limits that apply. +- [Chat completions](${DOCS_URL}/api/chat-completions): POST /proxy/v1/chat/completions. +- [Responses API](${DOCS_URL}/api/responses): POST /proxy/v1/responses. +- [Embeddings](${DOCS_URL}/api/embeddings): POST /proxy/v1/embeddings. +- [Image generation](${DOCS_URL}/api/image-generation): POST /proxy/v1/images/generations. +- [Moderations](${DOCS_URL}/api/moderations): POST /proxy/v1/moderations. +- [OCR](${DOCS_URL}/api/ocr): POST /proxy/v1/ocr. +- [Web search with Exa](${DOCS_URL}/api/exa): POST /proxy/v1/exa/search and friends. +- [Replicate models](${DOCS_URL}/guide/replicate): Image, speech-to-text and text-to-speech models. + +## Account + +- [Home](${base}/): Product overview and sign-in. +- [Dashboard](${base}/dashboard): Usage and spending. Requires a signed-in session. +- [API keys](${base}/keys): Create and revoke keys. Requires a signed-in session. + +## Optional + +- [Source code](https://github.com/hackclub/ai): This proxy is open source. +- [Hack Club](https://hackclub.com): The nonprofit behind this service. +- [Hack Club Slack](https://hackclub.com/slack): Support lives in #hackclub-ai. +`; +}; + +/** + * JSON-LD identity graph for the homepage: who runs this, how to reach them, + * and what the product is. Kept as a @graph so the Organization node can be + * referenced by @id from both the WebSite and the SoftwareApplication. + */ +export const buildStructuredData = ( + baseUrl: string, +): Record => { + const base = trimTrailingSlash(baseUrl); + return { + "@context": "https://schema.org", + "@graph": [ + { + "@type": "Organization", + "@id": ORGANIZATION_ID, + name: "Hack Club", + legalName: "The Hack Foundation", + description: + "Hack Club is the world's largest nonprofit movement of teenagers making cool projects.", + url: "https://hackclub.com", + logo: "https://assets.hackclub.com/flag-standalone.png", + email: "team@hackclub.com", + telephone: "+1-855-625-4225", + sameAs: [ + "https://github.com/hackclub", + "https://twitter.com/hackclub", + "https://www.youtube.com/c/HackClubHQ", + "https://www.instagram.com/starthackclub", + "https://en.wikipedia.org/wiki/Hack_Club", + "https://www.wikidata.org/wiki/Q98127305", + ], + address: { + "@type": "PostalAddress", + streetAddress: "212 Battery St", + addressLocality: "Burlington", + addressRegion: "VT", + postalCode: "05401", + addressCountry: "US", + }, + contactPoint: [ + { + "@type": "ContactPoint", + contactType: "general inquiries", + email: "team@hackclub.com", + telephone: "+1-855-625-4225", + areaServed: "Worldwide", + availableLanguage: "English", + }, + { + "@type": "ContactPoint", + contactType: "technical support", + email: "team@hackclub.com", + url: "https://hackclub.com/slack", + areaServed: "Worldwide", + availableLanguage: "English", + }, + ], + }, + { + "@type": "WebSite", + "@id": `${base}/#website`, + name: SITE_NAME, + description: SITE_DESCRIPTION, + url: `${base}/`, + inLanguage: "en", + publisher: { "@id": ORGANIZATION_ID }, + }, + { + "@type": "SoftwareApplication", + "@id": `${base}/#software`, + name: SITE_NAME, + description: SITE_DESCRIPTION, + url: `${base}/`, + applicationCategory: "DeveloperApplication", + applicationSubCategory: "AI API", + operatingSystem: "Any", + browserRequirements: "Any HTTP client", + softwareHelp: DOCS_URL, + installUrl: `${base}/keys`, + featureList: [ + "OpenAI-compatible chat completions", + "Responses API", + "Text embeddings", + "Image generation", + "Content moderation", + "OCR for images and PDFs", + "Web search via Exa", + ], + provider: { "@id": ORGANIZATION_ID }, + author: { "@id": ORGANIZATION_ID }, + isAccessibleForFree: true, + offers: { + "@type": "Offer", + price: "0", + priceCurrency: "USD", + availability: "https://schema.org/InStock", + url: `${base}/`, + }, + potentialAction: { + "@type": "ViewAction", + target: `${base}/openapi.json`, + name: "Read the OpenAPI specification", + }, + }, + ], + }; +}; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 54379a2..86b497d 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -17,11 +17,10 @@ const BLOCKED_USER_AGENTS = blockedUserAgentsConfig.map((a) => a.toLowerCase()); const BLOCKED_MESSAGE = "For now, AI coding agents and frontends like SillyTavern aren't allowed to be used with ai.hackclub.com. Join #hackclub-ai on the Hack Club Slack for future updates."; +// The app-wide error handler renders every HTTPException as the structured +// JSON envelope, so there is no need to attach a bespoke response here. const createBlockedException = () => - new HTTPException(403, { - message: BLOCKED_MESSAGE, - res: Response.json({ error: BLOCKED_MESSAGE }, { status: 403 }), - }); + new HTTPException(403, { message: BLOCKED_MESSAGE }); export async function blockAICodingAgents(c: Context, next: Next) { const referer = c.req.header("Referer") || c.req.header("HTTP-Referer"); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 7b24e36..851795f 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -198,7 +198,7 @@ auth.get("/callback", async (c) => { if (hasBlockedAddressCountry(identity)) { try { await sendBlockedAddressSlackMessage(identity); - } catch (error) { + } catch { throw new HTTPException(400, { message: "Please contact support and send this error code: willow-savannah-tunnel-windermere", diff --git a/src/routes/discovery.test.ts b/src/routes/discovery.test.ts new file mode 100644 index 0000000..e62ed72 --- /dev/null +++ b/src/routes/discovery.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; + +import discovery from "./discovery"; + +const BASE_URL = "https://ai.hackclub.com"; + +const get = (path: string) => discovery.request(path); + +describe("GET /robots.txt", () => { + test("serves plain text that advertises the sitemap", async () => { + const res = await get("/robots.txt"); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("text/plain; charset=utf-8"); + expect(await res.text()).toContain(`Sitemap: ${BASE_URL}/sitemap.xml`); + }); +}); + +describe("GET /sitemap.xml", () => { + test("serves XML listing the homepage", async () => { + const res = await get("/sitemap.xml"); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe( + "application/xml; charset=utf-8", + ); + + const body = await res.text(); + expect(body).toStartWith(''); + expect(body).toContain(`${BASE_URL}/`); + expect(body).toContain(""); + }); +}); + +describe("GET /llms.txt", () => { + test("serves markdown that indexes the site", async () => { + const res = await get("/llms.txt"); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe( + "text/markdown; charset=utf-8", + ); + expect(await res.text()).toStartWith("# Hack Club AI"); + }); +}); + +describe("GET /openapi.json", () => { + test("serves a JSON OpenAPI 3.2 document", async () => { + const res = await get("/openapi.json"); + + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("application/json"); + + const doc = (await res.json()) as { + openapi: string; + servers: Array<{ url: string }>; + paths: Record; + }; + expect(doc.openapi).toBe("3.1.0"); + expect(doc.servers[0].url).toBe(BASE_URL); + expect(doc.paths).toHaveProperty(["/proxy/v1/chat/completions"]); + }); + + test("reflects the models this deployment actually allows", async () => { + const doc = (await (await get("/openapi.json")).json()) as { + components: { + schemas: { + ChatCompletionRequest: { + properties: { model: { examples: string[] } }; + }; + }; + }; + }; + + expect( + doc.components.schemas.ChatCompletionRequest.properties.model.examples, + ).toEqual(["qwen/qwen3-32b", "openai/gpt-5-mini"]); + }); + + test("is also served from /.well-known/openapi.json", async () => { + const res = await get("/.well-known/openapi.json"); + + expect(res.status).toBe(200); + expect(((await res.json()) as { openapi: string }).openapi).toBe("3.1.0"); + }); +}); + +describe("caching", () => { + test("every discovery document is cacheable and has an ETag", async () => { + for (const path of [ + "/robots.txt", + "/sitemap.xml", + "/llms.txt", + "/openapi.json", + ]) { + const res = await get(path); + expect(res.headers.get("Cache-Control"), path).toBe( + "public, max-age=3600", + ); + expect(res.headers.get("ETag"), path).toBeTruthy(); + } + }); + + test("returns 304 when the client already has the current version", async () => { + const first = await get("/llms.txt"); + const etag = first.headers.get("ETag") as string; + + const second = await discovery.request("/llms.txt", { + headers: { "If-None-Match": etag }, + }); + + expect(second.status).toBe(304); + }); +}); diff --git a/src/routes/discovery.ts b/src/routes/discovery.ts new file mode 100644 index 0000000..ba06e6a --- /dev/null +++ b/src/routes/discovery.ts @@ -0,0 +1,61 @@ +import { type Context, Hono } from "hono"; +import { etag } from "hono/etag"; + +import { + allowedEmbeddingModels, + allowedImageModels, + allowedLanguageModels, + env, +} from "../env"; +import { buildOpenApiDocument } from "../lib/openapi"; +import { buildLlmsTxt, buildRobotsTxt, buildSitemap } from "../lib/site"; + +/** + * Machine-readable descriptions of this site: what it is, what URLs exist and + * what the API can do. Everything here is public and unauthenticated. + */ +const discovery = new Hono(); + +// Long enough that crawlers and agents aren't refetching constantly, short +// enough that a model-list change shows up the same day. +const CACHE_CONTROL = "public, max-age=3600"; + +discovery.use("*", etag()); + +discovery.get("/robots.txt", (c) => + c.text(buildRobotsTxt(env.BASE_URL), 200, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": CACHE_CONTROL, + }), +); + +discovery.get("/sitemap.xml", (c) => + c.text(buildSitemap(env.BASE_URL), 200, { + "Content-Type": "application/xml; charset=utf-8", + "Cache-Control": CACHE_CONTROL, + }), +); + +discovery.get("/llms.txt", (c) => + c.text(buildLlmsTxt(env.BASE_URL), 200, { + "Content-Type": "text/markdown; charset=utf-8", + "Cache-Control": CACHE_CONTROL, + }), +); + +const openApiDocument = () => + buildOpenApiDocument({ + baseUrl: env.BASE_URL, + languageModels: allowedLanguageModels, + imageModels: allowedImageModels, + embeddingModels: allowedEmbeddingModels, + }); + +const serveOpenApi = (c: Context) => + c.json(openApiDocument(), 200, { "Cache-Control": CACHE_CONTROL }); + +discovery.get("/openapi.json", serveOpenApi); +// Alternate location some agents probe first. +discovery.get("/.well-known/openapi.json", serveOpenApi); + +export default discovery; diff --git a/src/routes/proxy/shared.ts b/src/routes/proxy/shared.ts index 5f9dec9..5cda293 100644 --- a/src/routes/proxy/shared.ts +++ b/src/routes/proxy/shared.ts @@ -159,7 +159,7 @@ export const apiHeaders = (c: Ctx) => ({ // export const resolveModel = (model: string, pool: string[]) => // pool.includes(model) ? model : pool[0]; -export const resolveModel = (model: string, pool: string[]) => model; +export const resolveModel = (model: string, _pool: string[]) => model; export const logRequest = async ( c: Ctx, diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..0bdc5d8 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,33 @@ +/** + * Preloaded by `bun test` (see bunfig.toml). `src/env.ts` validates the whole + * environment at import time and exits the process if anything is missing, so + * any test that imports a route needs these placeholders in place first. Only + * unset variables are filled in, so a real .env still wins locally. + */ +const TEST_ENV: Record = { + DATABASE_URL: "postgres://localhost:5432/test", + BASE_URL: "https://ai.hackclub.com", + PORT: "54321", + HACK_CLUB_CLIENT_ID: "test-client-id", + HACK_CLUB_CLIENT_SECRET: "test-client-secret", + SLACK_GEOBLOCK_WEBHOOK_URL: "https://hooks.slack.com/services/test", + OPENAI_API_URL: "https://openrouter.ai/api", + OPENAI_API_KEY: "test-openai-key", + OPENAI_MODERATION_API_KEY: "test-moderation-key", + OPENAI_MODERATION_API_URL: "https://api.openai.com/v1/moderations", + ALLOWED_LANGUAGE_MODELS: "qwen/qwen3-32b,openai/gpt-5-mini", + ALLOWED_IMAGE_MODELS: "google/gemini-2.5-flash-image", + ALLOWED_EMBEDDING_MODELS: "qwen/qwen3-embedding-8b", + NODE_ENV: "test", + OPENROUTER_PROVISIONING_KEY: "test-provisioning-key", + REPLICATE_SESSION_ID: "test-session-id", + REPLICATE_API_KEY: "test-replicate-key", + REPLICATE_USERNAME: "test-user", + POSTHOG_API_KEY: "test-posthog-key", + MISTRAL_API_KEY: "test-mistral-key", + EXA_API_KEY: "test-exa-key", +}; + +for (const [key, value] of Object.entries(TEST_ENV)) { + process.env[key] ??= value; +} diff --git a/src/views/layout.test.tsx b/src/views/layout.test.tsx new file mode 100644 index 0000000..19ef976 --- /dev/null +++ b/src/views/layout.test.tsx @@ -0,0 +1,93 @@ +import { describe, expect, test } from "bun:test"; + +import { Home } from "./home"; +import { jsonForScript } from "./layout"; +import { NotFound } from "./not-found"; + +const render = async (node: unknown): Promise => + String(await (node as PromiseLike)); + +const homepage = await render(Home({ models: ["qwen/qwen3-32b"] })); + +const extractJsonLd = (html: string): Record => { + const match = html.match( + /", homepage.indexOf("ld+json")), + ); + expect(raw).not.toContain("<"); + expect(raw).not.toContain(">"); + }); + + test("jsonForScript escapes everything that could close the script tag", () => { + const escaped = jsonForScript({ + evil: "", + sep: "\u2028\u2029", + }); + + expect(escaped).not.toContain("<"); + expect(escaped).not.toContain(">"); + expect(escaped).not.toContain("\u2028"); + expect(escaped).not.toContain("\u2029"); + expect(escaped).toContain("\\u003c"); + expect(JSON.parse(escaped).evil).toBe( + "", + ); + }); +}); + +describe("discovery link tags", () => { + test("points at the OpenAPI spec, sitemap and llms.txt", () => { + expect(homepage).toContain('rel="service-desc"'); + expect(homepage).toContain('href="/openapi.json"'); + expect(homepage).toContain('href="/sitemap.xml"'); + expect(homepage).toContain('href="/llms.txt"'); + }); + + test("has a meta description", () => { + expect(homepage).toContain('name="description"'); + }); +}); + +const notFoundPage = await render(NotFound({ path: "/nope" })); + +describe("NotFound page", () => { + const page = notFoundPage; + + test("names the missing path and links the recovery routes", () => { + expect(page).toContain("/nope"); + expect(page).toContain("404"); + expect(page).toContain('href="/openapi.json"'); + expect(page).toContain('href="/llms.txt"'); + expect(page).toContain('href="https://docs.ai.hackclub.com"'); + }); + + test("carries the same JSON-LD as every other page", () => { + expect(() => extractJsonLd(page)).not.toThrow(); + }); +}); diff --git a/src/views/layout.tsx b/src/views/layout.tsx index b8bbea3..dff90c1 100644 --- a/src/views/layout.tsx +++ b/src/views/layout.tsx @@ -1,11 +1,12 @@ import { html } from "hono/html"; import type { Child } from "hono/jsx"; import { env } from "../env"; +import { buildStructuredData, SITE_DESCRIPTION } from "../lib/site"; import type { User } from "../types"; // JSON-encode a value for safe inlining inside a