feat: move base URL, API key, and model to server .env

- BASE_URL, API_KEY, MODEL now read from process.env (Bun auto-loads .env)
- requireEnv() fails fast at startup if any is missing
- request body simplifies to { prompt, size, referenceImages? }
- client drops the three fields from form and localStorage
- add .env.example as the variable-name source of truth
- AGENTS.md notes the 0.0.0.0 bind now exposes the upstream quota to
  anyone reachable on the network
This commit is contained in:
2026-05-18 23:28:12 +08:00
parent 4ef2f1ba2b
commit d5bbc14c8d
5 changed files with 64 additions and 62 deletions
+3
View File
@@ -0,0 +1,3 @@
BASE_URL=https://api.openai.com/v1
API_KEY=sk-...
MODEL=gpt-image-2
+25 -4
View File
@@ -12,6 +12,26 @@ proxy-read timeout.
`dotenv` — Bun loads `.env` automatically. `dotenv` — Bun loads `.env` automatically.
- Bun version baseline: `1.3.13` (per `README.md`). - Bun version baseline: `1.3.13` (per `README.md`).
## Config
Required env vars (validated at startup via `requireEnv`; the process exits
if any is missing):
| Var | Example | Purpose |
|---|---|---|
| `BASE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL |
| `API_KEY` | `sk-…` | Bearer token sent to upstream |
| `MODEL` | `gpt-image-2` | Model name forwarded to upstream |
`.env.example` is the source of truth for variable names. The real `.env`
is gitignored. Restart the server after changing env vars — they are read
once at module load.
These secrets stay **server-side**. The browser only sends `prompt`,
`size`, and `referenceImages`. Combined with the `0.0.0.0` bind, this
means anyone reachable on the network can spend your upstream quota —
bind to `127.0.0.1` or put auth in front if that matters.
## Commands ## Commands
- Install: `bun install` - Install: `bun install`
@@ -41,7 +61,9 @@ Three files do everything:
connections before the first keepalive can fire. The symptom is an connections before the first keepalive can fire. The symptom is an
empty EventStream in DevTools and `request timed out after 10 seconds` empty EventStream in DevTools and `request timed out after 10 seconds`
in the log. in the log.
- `POST /api/generate` uses `streamSSE` from `hono/streaming`. Emits: - `POST /api/generate` uses `streamSSE` from `hono/streaming`. Accepts
`{ prompt, size, referenceImages? }``BASE_URL`, `API_KEY`, and
`MODEL` come from env, not the request. Emits:
- `event: partial``{ image: dataUrl, index }` for each - `event: partial``{ image: dataUrl, index }` for each
`image_generation.partial_image` / `image_edit.partial_image`. `image_generation.partial_image` / `image_edit.partial_image`.
- `event: final``{ image: dataUrl }` for `*.completed`. - `event: final``{ image: dataUrl }` for `*.completed`.
@@ -80,9 +102,8 @@ Three files do everything:
`signal`, and the `onopen` / `onmessage` / `onerror` callbacks. `signal`, and the `onopen` / `onmessage` / `onerror` callbacks.
- On `done`, the client calls `abort.abort()` to terminate the - On `done`, the client calls `abort.abort()` to terminate the
`fetchEventSource` loop cleanly — otherwise it would retry forever. `fetchEventSource` loop cleanly — otherwise it would retry forever.
- Text fields (`baseURL`, `apiKey`, `model`, `size`, `prompt`) persist - Text fields (`size`, `prompt`) persist in `localStorage` under the
in `localStorage` under the `aip:<field>` prefix. Reference images `aip:<field>` prefix. Reference images stay in-memory only.
stay in-memory only.
- `index.html` — markup + inline CSS only. No JS lives here. - `index.html` — markup + inline CSS only. No JS lives here.
No router, no DB, no auth, no AI SDK. API key is supplied per-request by the No router, no DB, no auth, no AI SDK. API key is supplied per-request by the
+5 -9
View File
@@ -2,13 +2,12 @@ import { fetchEventSource } from "@microsoft/fetch-event-source";
const byId = <T extends HTMLElement>(id: string) => const byId = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T; document.getElementById(id) as T;
const input = (id: string) => byId<HTMLInputElement>(id);
const select = (id: string) => byId<HTMLSelectElement>(id); const select = (id: string) => byId<HTMLSelectElement>(id);
const textarea = (id: string) => byId<HTMLTextAreaElement>(id); const textarea = (id: string) => byId<HTMLTextAreaElement>(id);
const persistedFields = ["baseURL", "apiKey", "model", "size", "prompt"] as const; const persistedFields = ["size", "prompt"] as const;
for (const f of persistedFields) { for (const f of persistedFields) {
const el = byId<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(f); const el = byId<HTMLSelectElement | HTMLTextAreaElement>(f);
const saved = localStorage.getItem("aip:" + f); const saved = localStorage.getItem("aip:" + f);
if (saved) el.value = saved; if (saved) el.value = saved;
const save = () => localStorage.setItem("aip:" + f, el.value); const save = () => localStorage.setItem("aip:" + f, el.value);
@@ -42,7 +41,7 @@ function renderRefPreview() {
}); });
} }
input("refImages").addEventListener("change", async (e) => { byId<HTMLInputElement>("refImages").addEventListener("change", async (e) => {
const el = e.target as HTMLInputElement; const el = e.target as HTMLInputElement;
const files = Array.from(el.files ?? []); const files = Array.from(el.files ?? []);
for (const file of files) { for (const file of files) {
@@ -67,16 +66,13 @@ byId<HTMLButtonElement>("generate").addEventListener("click", async () => {
const result = byId<HTMLDivElement>("result"); const result = byId<HTMLDivElement>("result");
const body = { const body = {
baseURL: input("baseURL").value.trim(),
apiKey: input("apiKey").value.trim(),
model: input("model").value.trim(),
size: select("size").value, size: select("size").value,
prompt: textarea("prompt").value.trim(), prompt: textarea("prompt").value.trim(),
referenceImages: refImages, referenceImages: refImages,
}; };
if (!body.baseURL || !body.apiKey || !body.model || !body.prompt) { if (!body.prompt) {
status.textContent = "Please fill in Base URL, API Key, Model and Prompt."; status.textContent = "Please enter a prompt.";
status.className = "status error"; status.className = "status error";
return; return;
} }
+10 -28
View File
@@ -196,31 +196,13 @@
</p> </p>
<div class="panel"> <div class="panel">
<div class="grid"> <div class="row">
<div class="row"> <label for="size">Size</label>
<label for="baseURL">Base URL</label> <select id="size">
<input <option value="1024x1024">1024x1024 (square)</option>
id="baseURL" <option value="1536x1024">1536x1024 (landscape)</option>
type="text" <option value="1024x1536">1024x1536 (portrait)</option>
placeholder="https://api.openai.com/v1" </select>
/>
</div>
<div class="row">
<label for="apiKey">API Key</label>
<input id="apiKey" type="password" placeholder="sk-..." />
</div>
<div class="row">
<label for="model">Model</label>
<input id="model" type="text" placeholder="gpt-image-2" />
</div>
<div class="row">
<label for="size">Size</label>
<select id="size">
<option value="1024x1024">1024x1024 (square)</option>
<option value="1536x1024">1536x1024 (landscape)</option>
<option value="1024x1536">1024x1536 (portrait)</option>
</select>
</div>
</div> </div>
<div class="row"> <div class="row">
<label for="prompt">Prompt</label> <label for="prompt">Prompt</label>
@@ -243,9 +225,9 @@
<span id="status" class="status"></span> <span id="status" class="status"></span>
</div> </div>
<details> <details>
<summary>Settings are stored in your browser's localStorage</summary> <summary>Size and prompt are saved to your browser's localStorage</summary>
Base URL, API Key, model and size are saved locally. They are sent to Base URL, API key and model live in the server's <code>.env</code>
the local Bun server only when you click Generate. they are never sent from the browser.
</details> </details>
</div> </div>
+21 -21
View File
@@ -6,14 +6,23 @@ import index from "./index.html";
type Size = `${number}x${number}`; type Size = `${number}x${number}`;
type GenerateRequest = { type GenerateRequest = {
baseURL?: string;
apiKey?: string;
model?: string;
prompt?: string; prompt?: string;
size?: Size; size?: Size;
referenceImages?: string[]; referenceImages?: string[];
}; };
function requireEnv(name: string): string {
const v = process.env[name];
if (!v) {
throw new Error(`Missing required env: ${name} (see .env.example).`);
}
return v;
}
const BASE_URL = requireEnv("BASE_URL");
const API_KEY = requireEnv("API_KEY");
const MODEL = requireEnv("MODEL");
function decodeDataUrl( function decodeDataUrl(
dataUrl: string, dataUrl: string,
): { bytes: Uint8Array<ArrayBuffer>; mime: string } | null { ): { bytes: Uint8Array<ArrayBuffer>; mime: string } | null {
@@ -27,22 +36,19 @@ function decodeDataUrl(
} }
async function callUpstream(args: { async function callUpstream(args: {
baseURL: string;
apiKey: string;
model: string;
prompt: string; prompt: string;
size: Size; size: Size;
referenceImages: string[]; referenceImages: string[];
stream: boolean; stream: boolean;
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<Response> { }): Promise<Response> {
const { baseURL, apiKey, model, prompt, size, referenceImages, stream, signal } = args; const { prompt, size, referenceImages, stream, signal } = args;
const isEdit = referenceImages.length > 0; const isEdit = referenceImages.length > 0;
const url = `${baseURL.replace(/\/+$/, "")}/images/${isEdit ? "edits" : "generations"}`; const url = `${BASE_URL.replace(/\/+$/, "")}/images/${isEdit ? "edits" : "generations"}`;
if (isEdit) { if (isEdit) {
const form = new FormData(); const form = new FormData();
form.append("model", model); form.append("model", MODEL);
form.append("prompt", prompt); form.append("prompt", prompt);
form.append("size", size); form.append("size", size);
if (stream) { if (stream) {
@@ -64,13 +70,13 @@ async function callUpstream(args: {
} }
return fetch(url, { return fetch(url, {
method: "POST", method: "POST",
headers: { Authorization: `Bearer ${apiKey}` }, headers: { Authorization: `Bearer ${API_KEY}` },
body: form, body: form,
signal, signal,
}); });
} }
const body: Record<string, unknown> = { model, prompt, size }; const body: Record<string, unknown> = { model: MODEL, prompt, size };
if (stream) { if (stream) {
body.stream = true; body.stream = true;
body.partial_images = 2; body.partial_images = 2;
@@ -78,7 +84,7 @@ async function callUpstream(args: {
return fetch(url, { return fetch(url, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
@@ -180,18 +186,12 @@ const app = new Hono();
app.post("/api/generate", async (c) => { app.post("/api/generate", async (c) => {
const body = (await c.req.json()) as GenerateRequest; const body = (await c.req.json()) as GenerateRequest;
const { baseURL, apiKey, model, prompt, size, referenceImages } = body; const { prompt, size, referenceImages } = body;
if (!baseURL || !apiKey || !model || !prompt) { if (!prompt) {
return c.json( return c.json({ error: "prompt is required" }, 400);
{ error: "baseURL, apiKey, model, prompt are required" },
400,
);
} }
const refs = Array.isArray(referenceImages) ? referenceImages : []; const refs = Array.isArray(referenceImages) ? referenceImages : [];
const args = { const args = {
baseURL,
apiKey,
model,
prompt, prompt,
size: size ?? ("1024x1024" as Size), size: size ?? ("1024x1024" as Size),
referenceImages: refs, referenceImages: refs,