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:
@@ -0,0 +1,3 @@
|
||||
BASE_URL=https://api.openai.com/v1
|
||||
API_KEY=sk-...
|
||||
MODEL=gpt-image-2
|
||||
@@ -12,6 +12,26 @@ proxy-read timeout.
|
||||
`dotenv` — Bun loads `.env` automatically.
|
||||
- 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
|
||||
|
||||
- Install: `bun install`
|
||||
@@ -41,7 +61,9 @@ Three files do everything:
|
||||
connections before the first keepalive can fire. The symptom is an
|
||||
empty EventStream in DevTools and `request timed out after 10 seconds`
|
||||
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
|
||||
`image_generation.partial_image` / `image_edit.partial_image`.
|
||||
- `event: final` — `{ image: dataUrl }` for `*.completed`.
|
||||
@@ -80,9 +102,8 @@ Three files do everything:
|
||||
`signal`, and the `onopen` / `onmessage` / `onerror` callbacks.
|
||||
- On `done`, the client calls `abort.abort()` to terminate the
|
||||
`fetchEventSource` loop cleanly — otherwise it would retry forever.
|
||||
- Text fields (`baseURL`, `apiKey`, `model`, `size`, `prompt`) persist
|
||||
in `localStorage` under the `aip:<field>` prefix. Reference images
|
||||
stay in-memory only.
|
||||
- Text fields (`size`, `prompt`) persist in `localStorage` under the
|
||||
`aip:<field>` prefix. Reference images stay in-memory only.
|
||||
- `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
|
||||
|
||||
@@ -2,13 +2,12 @@ import { fetchEventSource } from "@microsoft/fetch-event-source";
|
||||
|
||||
const byId = <T extends HTMLElement>(id: string) =>
|
||||
document.getElementById(id) as T;
|
||||
const input = (id: string) => byId<HTMLInputElement>(id);
|
||||
const select = (id: string) => byId<HTMLSelectElement>(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) {
|
||||
const el = byId<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(f);
|
||||
const el = byId<HTMLSelectElement | HTMLTextAreaElement>(f);
|
||||
const saved = localStorage.getItem("aip:" + f);
|
||||
if (saved) el.value = saved;
|
||||
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 files = Array.from(el.files ?? []);
|
||||
for (const file of files) {
|
||||
@@ -67,16 +66,13 @@ byId<HTMLButtonElement>("generate").addEventListener("click", async () => {
|
||||
const result = byId<HTMLDivElement>("result");
|
||||
|
||||
const body = {
|
||||
baseURL: input("baseURL").value.trim(),
|
||||
apiKey: input("apiKey").value.trim(),
|
||||
model: input("model").value.trim(),
|
||||
size: select("size").value,
|
||||
prompt: textarea("prompt").value.trim(),
|
||||
referenceImages: refImages,
|
||||
};
|
||||
|
||||
if (!body.baseURL || !body.apiKey || !body.model || !body.prompt) {
|
||||
status.textContent = "Please fill in Base URL, API Key, Model and Prompt.";
|
||||
if (!body.prompt) {
|
||||
status.textContent = "Please enter a prompt.";
|
||||
status.className = "status error";
|
||||
return;
|
||||
}
|
||||
|
||||
+10
-28
@@ -196,31 +196,13 @@
|
||||
</p>
|
||||
|
||||
<div class="panel">
|
||||
<div class="grid">
|
||||
<div class="row">
|
||||
<label for="baseURL">Base URL</label>
|
||||
<input
|
||||
id="baseURL"
|
||||
type="text"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</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 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 class="row">
|
||||
<label for="prompt">Prompt</label>
|
||||
@@ -243,9 +225,9 @@
|
||||
<span id="status" class="status"></span>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Settings are stored in your browser's localStorage</summary>
|
||||
Base URL, API Key, model and size are saved locally. They are sent to
|
||||
the local Bun server only when you click Generate.
|
||||
<summary>Size and prompt are saved to your browser's localStorage</summary>
|
||||
Base URL, API key and model live in the server's <code>.env</code> —
|
||||
they are never sent from the browser.
|
||||
</details>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,14 +6,23 @@ import index from "./index.html";
|
||||
type Size = `${number}x${number}`;
|
||||
|
||||
type GenerateRequest = {
|
||||
baseURL?: string;
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
prompt?: string;
|
||||
size?: Size;
|
||||
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(
|
||||
dataUrl: string,
|
||||
): { bytes: Uint8Array<ArrayBuffer>; mime: string } | null {
|
||||
@@ -27,22 +36,19 @@ function decodeDataUrl(
|
||||
}
|
||||
|
||||
async function callUpstream(args: {
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
size: Size;
|
||||
referenceImages: string[];
|
||||
stream: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): 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 url = `${baseURL.replace(/\/+$/, "")}/images/${isEdit ? "edits" : "generations"}`;
|
||||
const url = `${BASE_URL.replace(/\/+$/, "")}/images/${isEdit ? "edits" : "generations"}`;
|
||||
|
||||
if (isEdit) {
|
||||
const form = new FormData();
|
||||
form.append("model", model);
|
||||
form.append("model", MODEL);
|
||||
form.append("prompt", prompt);
|
||||
form.append("size", size);
|
||||
if (stream) {
|
||||
@@ -64,13 +70,13 @@ async function callUpstream(args: {
|
||||
}
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
body: form,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { model, prompt, size };
|
||||
const body: Record<string, unknown> = { model: MODEL, prompt, size };
|
||||
if (stream) {
|
||||
body.stream = true;
|
||||
body.partial_images = 2;
|
||||
@@ -78,7 +84,7 @@ async function callUpstream(args: {
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
@@ -180,18 +186,12 @@ const app = new Hono();
|
||||
|
||||
app.post("/api/generate", async (c) => {
|
||||
const body = (await c.req.json()) as GenerateRequest;
|
||||
const { baseURL, apiKey, model, prompt, size, referenceImages } = body;
|
||||
if (!baseURL || !apiKey || !model || !prompt) {
|
||||
return c.json(
|
||||
{ error: "baseURL, apiKey, model, prompt are required" },
|
||||
400,
|
||||
);
|
||||
const { prompt, size, referenceImages } = body;
|
||||
if (!prompt) {
|
||||
return c.json({ error: "prompt is required" }, 400);
|
||||
}
|
||||
const refs = Array.isArray(referenceImages) ? referenceImages : [];
|
||||
const args = {
|
||||
baseURL,
|
||||
apiKey,
|
||||
model,
|
||||
prompt,
|
||||
size: size ?? ("1024x1024" as Size),
|
||||
referenceImages: refs,
|
||||
|
||||
Reference in New Issue
Block a user