ima2-gen 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js ADDED
@@ -0,0 +1,348 @@
1
+ import "dotenv/config";
2
+ import express from "express";
3
+ import { writeFile, mkdir, readFile } from "fs/promises";
4
+ import { join, dirname } from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { spawn } from "child_process";
7
+ import { existsSync } from "fs";
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const app = express();
11
+
12
+ // Load API key from env or .ima2/config.json
13
+ let apiKey = process.env.OPENAI_API_KEY;
14
+ if (!apiKey) {
15
+ const cfgPath = join(__dirname, ".ima2", "config.json");
16
+ if (existsSync(cfgPath)) {
17
+ try {
18
+ const cfg = JSON.parse(await readFile(cfgPath, "utf-8"));
19
+ if (cfg.apiKey) apiKey = cfg.apiKey;
20
+ } catch {}
21
+ }
22
+ }
23
+
24
+ const OAUTH_PORT = parseInt(process.env.OAUTH_PORT || "10531");
25
+ const OAUTH_URL = `http://127.0.0.1:${OAUTH_PORT}`;
26
+ const HAS_API_KEY = !!apiKey;
27
+
28
+ let openai = null;
29
+ if (HAS_API_KEY) {
30
+ const OpenAI = (await import("openai")).default;
31
+ openai = new OpenAI({ apiKey });
32
+ }
33
+
34
+ app.use(express.json({ limit: "50mb" }));
35
+ app.use(express.static(join(__dirname, "public")));
36
+
37
+ // ── OAuth proxy: generate via Responses API (stream mode) ──
38
+ async function generateViaOAuth(prompt, quality, size) {
39
+ const res = await fetch(`${OAUTH_URL}/v1/responses`, {
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
42
+ body: JSON.stringify({
43
+ model: "gpt-5.4",
44
+ input: [{ role: "user", content: prompt }],
45
+ tools: [{ type: "image_generation", quality, size }],
46
+ stream: true,
47
+ }),
48
+ });
49
+
50
+ console.log("[oauth] response status:", res.status, "content-type:", res.headers.get("content-type"));
51
+
52
+ if (!res.ok) {
53
+ const text = await res.text();
54
+ console.error("[oauth] error response:", text.slice(0, 500));
55
+ let msg;
56
+ try { msg = JSON.parse(text).error?.message; } catch {}
57
+ throw new Error(msg || `OAuth proxy returned ${res.status}: ${text.slice(0, 200)}`);
58
+ }
59
+
60
+ const contentType = res.headers.get("content-type") || "";
61
+ const isSSE = contentType.includes("text/event-stream");
62
+
63
+ // If not SSE, try to parse as JSON (non-stream response)
64
+ if (!isSSE) {
65
+ console.log("[oauth] non-SSE response, parsing as JSON");
66
+ const json = await res.json();
67
+ // Check output for image data
68
+ for (const item of json.output || []) {
69
+ if (item.type === "image_generation_call" && item.result) {
70
+ return { b64: item.result, usage: json.usage };
71
+ }
72
+ }
73
+ console.log("[oauth] no image in JSON output, output count:", (json.output || []).length);
74
+ console.log("[oauth] tool_usage:", JSON.stringify(json.tool_usage?.image_gen || {}));
75
+ throw new Error("No image data in response (non-stream mode)");
76
+ }
77
+
78
+ // Read SSE stream — collect complete events separated by double newlines
79
+ const reader = res.body.getReader();
80
+ const decoder = new TextDecoder();
81
+ let buffer = "";
82
+ let imageB64 = null;
83
+ let usage = null;
84
+ let eventCount = 0;
85
+
86
+ while (true) {
87
+ const { done, value } = await reader.read();
88
+ if (done) break;
89
+ buffer += decoder.decode(value, { stream: true });
90
+
91
+ // SSE events are separated by blank lines (\n\n)
92
+ let boundary;
93
+ while ((boundary = buffer.indexOf("\n\n")) !== -1) {
94
+ const block = buffer.slice(0, boundary);
95
+ buffer = buffer.slice(boundary + 2);
96
+
97
+ // Extract data from event block
98
+ let eventData = "";
99
+ for (const line of block.split("\n")) {
100
+ if (line.startsWith("data: ")) {
101
+ eventData += line.slice(6);
102
+ }
103
+ }
104
+
105
+ if (!eventData || eventData === "[DONE]") continue;
106
+
107
+ try {
108
+ const data = JSON.parse(eventData);
109
+ eventCount++;
110
+
111
+ if (data.type === "response.output_item.done" && data.item?.type === "image_generation_call") {
112
+ if (data.item.result) {
113
+ imageB64 = data.item.result;
114
+ console.log("[oauth] got image, b64 length:", imageB64.length);
115
+ }
116
+ }
117
+ if (data.type === "response.completed") {
118
+ usage = data.response?.usage || null;
119
+ }
120
+ if (data.type === "error") {
121
+ throw new Error(data.error?.message || JSON.stringify(data));
122
+ }
123
+ } catch (e) {
124
+ if (e.message && !e.message.startsWith("Unexpected")) throw e;
125
+ }
126
+ }
127
+ }
128
+
129
+ console.log("[oauth] stream ended, events:", eventCount, "hasImage:", !!imageB64);
130
+
131
+ // If stream ended without image, the proxy may have split the response.
132
+ // Wait briefly and retry with non-stream to check if image was generated.
133
+ if (!imageB64) {
134
+ console.log("[oauth] no image in stream, retrying non-stream...");
135
+ const retryRes = await fetch(`${OAUTH_URL}/v1/responses`, {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json" },
138
+ body: JSON.stringify({
139
+ model: "gpt-5.4",
140
+ input: [{ role: "user", content: prompt }],
141
+ tools: [{ type: "image_generation", quality, size }],
142
+ stream: false,
143
+ }),
144
+ });
145
+
146
+ if (retryRes.ok) {
147
+ const json = await retryRes.json();
148
+ for (const item of json.output || []) {
149
+ if (item.type === "image_generation_call" && item.result) {
150
+ console.log("[oauth] got image from retry, b64 length:", item.result.length);
151
+ return { b64: item.result, usage: json.usage };
152
+ }
153
+ }
154
+ }
155
+
156
+ throw new Error("No image data received from OAuth proxy (parsed " + eventCount + " events)");
157
+ }
158
+
159
+ return { b64: imageB64, usage };
160
+ }
161
+
162
+ // ── Provider info ──
163
+ app.get("/api/providers", (_req, res) => {
164
+ res.json({
165
+ apiKey: HAS_API_KEY,
166
+ oauth: true,
167
+ oauthPort: OAUTH_PORT,
168
+ });
169
+ });
170
+
171
+ // ── OAuth status ──
172
+ app.get("/api/oauth/status", async (_req, res) => {
173
+ try {
174
+ const r = await fetch(`${OAUTH_URL}/v1/models`, { signal: AbortSignal.timeout(3000) });
175
+ if (r.ok) {
176
+ const data = await r.json();
177
+ res.json({ status: "ready", models: data.data?.map((m) => m.id) || [] });
178
+ } else {
179
+ res.json({ status: "auth_required" });
180
+ }
181
+ } catch {
182
+ res.json({ status: "offline" });
183
+ }
184
+ });
185
+
186
+ // ── Generate image ──
187
+ app.post("/api/generate", async (req, res) => {
188
+ try {
189
+ const { prompt, quality = "low", size = "1024x1024", format = "png", moderation = "low", provider = "auto" } =
190
+ req.body;
191
+
192
+ if (!prompt) return res.status(400).json({ error: "Prompt is required" });
193
+
194
+ const useOAuth = provider === "oauth" || (provider === "auto" && !HAS_API_KEY);
195
+ console.log(`[generate] provider=${useOAuth ? "oauth" : "api"} quality=${quality} size=${size}`);
196
+ const startTime = Date.now();
197
+
198
+ let imageB64, usage;
199
+
200
+ if (useOAuth) {
201
+ const result = await generateViaOAuth(prompt, quality, size);
202
+ imageB64 = result.b64;
203
+ usage = result.usage;
204
+ } else if (openai) {
205
+ const response = await openai.images.generate({
206
+ model: "gpt-image-2",
207
+ prompt,
208
+ quality,
209
+ size,
210
+ moderation,
211
+ n: 1,
212
+ output_format: format,
213
+ output_compression: format === "png" ? undefined : 90,
214
+ });
215
+ imageB64 = response.data[0].b64_json;
216
+ usage = response.usage;
217
+ } else {
218
+ return res.status(400).json({ error: "No API key configured and OAuth not selected" });
219
+ }
220
+
221
+ if (!imageB64) return res.status(500).json({ error: "No image data received" });
222
+
223
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
224
+
225
+ await mkdir(join(__dirname, "generated"), { recursive: true });
226
+ const filename = `${Date.now()}.${format}`;
227
+ await writeFile(join(__dirname, "generated", filename), Buffer.from(imageB64, "base64"));
228
+
229
+ const mimeMap = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
230
+
231
+ res.json({
232
+ image: `data:${mimeMap[format] || "image/png"};base64,${imageB64}`,
233
+ elapsed,
234
+ filename,
235
+ usage,
236
+ provider: useOAuth ? "oauth" : "api",
237
+ });
238
+ } catch (err) {
239
+ console.error("Generate error:", err.message);
240
+ res.status(err.status || 500).json({ error: err.message, code: err.code });
241
+ }
242
+ });
243
+
244
+ // ── Edit image (inpainting) ──
245
+ app.post("/api/edit", async (req, res) => {
246
+ try {
247
+ const { prompt, image: imageB64, mask: maskB64, quality = "low", size = "1024x1024", moderation = "low" } =
248
+ req.body;
249
+
250
+ if (!prompt || !imageB64)
251
+ return res.status(400).json({ error: "Prompt and image are required" });
252
+ if (!openai)
253
+ return res.status(400).json({ error: "Image editing requires an API key" });
254
+
255
+ const startTime = Date.now();
256
+
257
+ const imageFile = new File([Buffer.from(imageB64, "base64")], "image.png", { type: "image/png" });
258
+ const params = { model: "gpt-image-2", prompt, image: imageFile, quality, size, moderation };
259
+ if (maskB64) {
260
+ params.mask = new File([Buffer.from(maskB64, "base64")], "mask.png", { type: "image/png" });
261
+ }
262
+
263
+ const response = await openai.images.edit(params);
264
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
265
+
266
+ res.json({
267
+ image: `data:image/png;base64,${response.data[0].b64_json}`,
268
+ elapsed,
269
+ usage: response.usage,
270
+ });
271
+ } catch (err) {
272
+ console.error("Edit error:", err.message);
273
+ res.status(err.status || 500).json({ error: err.message });
274
+ }
275
+ });
276
+
277
+ // ── Billing info ──
278
+ app.get("/api/billing", async (_req, res) => {
279
+ if (!HAS_API_KEY) return res.json({ oauth: true });
280
+
281
+ try {
282
+ const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };
283
+ const [subRes, usageRes] = await Promise.allSettled([
284
+ fetch(
285
+ "https://api.openai.com/v1/organization/costs?start_time=" +
286
+ Math.floor(new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000) +
287
+ "&end_time=" + Math.floor(Date.now() / 1000) + "&bucket_width=1d&limit=31",
288
+ { headers },
289
+ ),
290
+ fetch("https://api.openai.com/dashboard/billing/credit_grants", { headers }),
291
+ ]);
292
+
293
+ const billing = {};
294
+ if (subRes.status === "fulfilled" && subRes.value.ok) billing.costs = await subRes.value.json();
295
+ if (usageRes.status === "fulfilled" && usageRes.value.ok) billing.credits = await usageRes.value.json();
296
+ if (!billing.costs && !billing.credits) {
297
+ billing.apiKeyValid = (await fetch("https://api.openai.com/v1/models", { headers })).ok;
298
+ }
299
+ res.json(billing);
300
+ } catch (err) {
301
+ res.status(500).json({ error: err.message });
302
+ }
303
+ });
304
+
305
+ // ── Start OAuth proxy as child process ──
306
+ function startOAuthProxy() {
307
+ console.log(`Starting openai-oauth on port ${OAUTH_PORT}...`);
308
+ const child = spawn("npx", ["openai-oauth", "--port", String(OAUTH_PORT)], {
309
+ stdio: ["ignore", "pipe", "pipe"],
310
+ env: { ...process.env },
311
+ });
312
+
313
+ child.stdout.on("data", (d) => {
314
+ const msg = d.toString().trim();
315
+ if (msg) console.log(`[oauth] ${msg}`);
316
+ });
317
+
318
+ child.stderr.on("data", (d) => {
319
+ const msg = d.toString().trim();
320
+ if (msg && !msg.includes("npm warn")) console.error(`[oauth] ${msg}`);
321
+ });
322
+
323
+ child.on("exit", (code) => {
324
+ console.log(`[oauth] exited with code ${code}, restarting in 5s...`);
325
+ setTimeout(startOAuthProxy, 5000);
326
+ });
327
+
328
+ return child;
329
+ }
330
+
331
+ // ── Boot ──
332
+ const PORT = process.env.PORT || 3333;
333
+ const oauthChild = startOAuthProxy();
334
+
335
+ process.on("SIGINT", () => {
336
+ oauthChild.kill();
337
+ process.exit();
338
+ });
339
+ process.on("SIGTERM", () => {
340
+ oauthChild.kill();
341
+ process.exit();
342
+ });
343
+
344
+ app.listen(PORT, () => {
345
+ console.log(`Image Gen running at http://localhost:${PORT}`);
346
+ console.log(`Providers: ${HAS_API_KEY ? "API Key + " : ""}OAuth (port ${OAUTH_PORT})`);
347
+ if (!HAS_API_KEY) console.log("No OPENAI_API_KEY set — OAuth mode only. Run 'codex login' first.");
348
+ });