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.
|
`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
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user