import { Hono, type Context } from "hono"; import { serveStatic } from "@hono/node-server/serve-static"; import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { authMiddleware, apiKeyMiddleware } from "./auth"; import { accountSetupApi, adminApi } from "./admin"; import { proxyApi } from "./proxy"; import type { Store } from "./store"; export interface AppOptions { /** Absolute path to a built web dist dir (optional). */ webDir?: string; } export function createApp(store: Store, opts: AppOptions = {}): Hono { const app = new Hono(); app.get("/health", (c) => c.json({ ok: true, version: "0.1.0" })); app.route("/admin/setup", accountSetupApi(store)); // Runtime log (console + server.log): auth failures, unhandled errors. const logger = store.getLogger(); app.onError((err, c) => { logger.error(`unhandled error on ${c.req.method} ${c.req.path}: ${err.stack ?? String(err)}`); return c.json({ error: { message: "internal error", type: "server_error" } }, 500); }); // Two independent secrets: the account password admins /admin (Basic), the // API key gates /v1 (Bearer / x-api-key). Neither works on the other's surface. const accountAuth = authMiddleware( () => store.get().account.username, () => store.get().account.password ?? null, logger, ); const apiKeyAuth = apiKeyMiddleware(() => store.get().apiKey, logger); // Both sub-apps require auth, applied inside each sub-app (before routes). // Three agent surfaces — one per protocol, each with its own /models: // /openai-chat/v1 (openai chat/completions), /openai-responses/v1 (openai // responses), /anthropic/v1 (anthropic messages). All gate on the same API key. const { chat, responses, anthropic } = proxyApi(store, apiKeyAuth); const admin = adminApi(store, accountAuth, chat, responses, anthropic); app.route("/openai-chat/v1", chat); app.route("/openai-responses/v1", responses); app.route("/anthropic/v1", anthropic); app.route("/admin", admin); // Unmatched paths under an API prefix answer JSON 404 — NOT the SPA's // index.html. A misconfigured client (e.g. a doubled /v1 segment) then gets a // parseable error instead of HTML that only blows up later in its JSON parser // as "Unexpected token '<'" (this bit pi's model refresh once). const apiMiss = (c: Context) => c.json({ error: { message: `no such endpoint: ${c.req.method} ${c.req.path}`, type: "invalid_request_error" } }, 404); app.all("/openai-chat/*", apiMiss); app.all("/openai-responses/*", apiMiss); app.all("/anthropic/*", apiMiss); app.all("/admin/*", apiMiss); // Legacy pre-0.47 openai surface: the two openai families now have their own // prefixes — point the caller there instead of a bare 404. app.all("/openai/*", (c) => c.json({ error: { message: "the /openai/v1 surface was split — use /openai-chat/v1 or /openai-responses/v1", type: "invalid_request_error" } }, 404)); // Legacy pre-0.12 surface: gone since the split — point the caller at the // current surfaces instead of a bare 404. app.all("/v1/*", (c) => c.json({ error: { message: "the /v1 surface was split in v0.12.0 — use /openai-chat/v1, /openai-responses/v1 or /anthropic/v1", type: "invalid_request_error" } }, 404)); // Web UI: serve built SPA when available. if (opts.webDir && existsSync(opts.webDir)) { // Cache discipline is what makes an update visible after a plain reload: // index.html (any HTML response — root or SPA fallback) must always be // revalidated, while /assets/* filenames are content-hashed and immutable. // Without this, browsers heuristic-cache the old index.html and keep // booting the previous build until a hard refresh. app.use("/*", async (c, next) => { await next(); if ((c.res.headers.get("content-type") ?? "").startsWith("text/html")) { c.res.headers.set("Cache-Control", "no-cache"); } else if (c.req.path.startsWith("/assets/")) { c.res.headers.set("Cache-Control", "public, max-age=31536000, immutable"); } }); app.use("/*", serveStatic({ root: opts.webDir })); app.get("*", async (c) => { try { return c.html(await readFile(`${opts.webDir}/index.html`, "utf8")); } catch { return c.notFound(); } }); } else { app.get("*", (c) => c.text( "MyAPIKey is running. Web UI not built — run `npm run build:web`. API at /openai-chat/v1 + /openai-responses/v1 + /anthropic/v1 (proxy) and /admin (config).", 404, ), ); } return app; }