ima2-gen 1.0.6 → 1.0.8
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/.env.example +49 -2
- package/README.md +192 -152
- package/bin/commands/edit.js +1 -1
- package/bin/commands/gen.js +1 -1
- package/bin/ima2.js +15 -7
- package/bin/lib/star-prompt.js +97 -0
- package/config.js +167 -0
- package/lib/assetLifecycle.js +9 -6
- package/lib/db.js +11 -6
- package/lib/errorClassify.js +62 -0
- package/lib/historyList.js +67 -0
- package/lib/inflight.js +70 -6
- package/lib/logger.js +116 -0
- package/lib/nodeStore.js +9 -7
- package/lib/oauthLauncher.js +31 -0
- package/lib/oauthNormalize.js +30 -0
- package/lib/oauthProxy.js +311 -0
- package/lib/refs.js +35 -0
- package/lib/sessionStore.js +49 -0
- package/lib/storageMigration.js +41 -0
- package/lib/styleSheet.js +128 -0
- package/package.json +4 -2
- package/routes/edit.js +171 -0
- package/routes/generate.js +254 -0
- package/routes/health.js +89 -0
- package/routes/history.js +102 -0
- package/routes/index.js +16 -0
- package/routes/nodes.js +340 -0
- package/routes/sessions.js +281 -0
- package/server.js +121 -1083
- package/ui/dist/assets/index-CBrmEeD7.css +1 -0
- package/ui/dist/assets/index-DRST1V_0.js +22 -0
- package/ui/dist/assets/index-DRST1V_0.js.map +1 -0
- package/ui/dist/index.html +18 -2
- package/ui/dist/assets/index-B66MK5qN.css +0 -1
- package/ui/dist/assets/index-BIwLnT0j.js +0 -16
- package/ui/dist/assets/index-BIwLnT0j.js.map +0 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Style-sheet extractor (0.10)
|
|
2
|
+
//
|
|
3
|
+
// Uses GPT-5.4 (chat completions) to derive a structured "style guide" from a
|
|
4
|
+
// user prompt and optional reference image. The style guide is stored per
|
|
5
|
+
// session and automatically prepended to subsequent image generations so
|
|
6
|
+
// continuations feel cohesive — closer to ChatGPT 4o's image carry-over.
|
|
7
|
+
//
|
|
8
|
+
// Shape:
|
|
9
|
+
// {
|
|
10
|
+
// palette: string[], // e.g. ["deep navy", "gold leaf"]
|
|
11
|
+
// composition: string, // e.g. "centered 3/4 portrait, shallow depth"
|
|
12
|
+
// mood: string, // e.g. "melancholic, reverent"
|
|
13
|
+
// medium: string, // e.g. "oil painting, glazed layers"
|
|
14
|
+
// subject_details: string, // identity/pose/outfit cues for character continuity
|
|
15
|
+
// negative: string[] // things to avoid
|
|
16
|
+
// }
|
|
17
|
+
//
|
|
18
|
+
// The module is pure JS + openai SDK. When no API key is configured it throws
|
|
19
|
+
// STYLE_SHEET_NO_KEY so callers can surface a friendly "connect key" UI.
|
|
20
|
+
|
|
21
|
+
import { config } from "../config.js";
|
|
22
|
+
const STYLE_SHEET_MODEL = config.styleSheet.model;
|
|
23
|
+
|
|
24
|
+
const SYSTEM_PROMPT = `You extract a reusable visual style guide from a user
|
|
25
|
+
image prompt (and an optional reference image). Return ONLY a JSON object with
|
|
26
|
+
these keys: palette (array of 3-6 concrete color names), composition (one
|
|
27
|
+
sentence), mood (2-4 comma-separated adjectives), medium (one short phrase
|
|
28
|
+
naming technique/material), subject_details (one sentence capturing identity
|
|
29
|
+
cues: face, outfit, pose, distinctive features), negative (array of 0-4 short
|
|
30
|
+
phrases of things to avoid). Keep entries tight — each under 120 characters.
|
|
31
|
+
Do not wrap in markdown. Do not add commentary.`;
|
|
32
|
+
|
|
33
|
+
function coerceStyleSheet(raw) {
|
|
34
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
35
|
+
const arr = (v, max = 6) =>
|
|
36
|
+
Array.isArray(v)
|
|
37
|
+
? v
|
|
38
|
+
.filter((x) => typeof x === "string" && x.trim())
|
|
39
|
+
.slice(0, max)
|
|
40
|
+
.map((s) => s.trim())
|
|
41
|
+
: [];
|
|
42
|
+
const str = (v) => (typeof v === "string" ? v.trim().slice(0, 400) : "");
|
|
43
|
+
const sheet = {
|
|
44
|
+
palette: arr(raw.palette, 6),
|
|
45
|
+
composition: str(raw.composition),
|
|
46
|
+
mood: str(raw.mood),
|
|
47
|
+
medium: str(raw.medium),
|
|
48
|
+
subject_details: str(raw.subject_details),
|
|
49
|
+
negative: arr(raw.negative, 4),
|
|
50
|
+
};
|
|
51
|
+
const hasContent =
|
|
52
|
+
sheet.palette.length > 0 ||
|
|
53
|
+
sheet.negative.length > 0 ||
|
|
54
|
+
sheet.composition ||
|
|
55
|
+
sheet.mood ||
|
|
56
|
+
sheet.medium ||
|
|
57
|
+
sheet.subject_details;
|
|
58
|
+
return hasContent ? sheet : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function extractStyleSheet(openai, { prompt, referenceDataUrl }) {
|
|
62
|
+
if (!openai) {
|
|
63
|
+
const err = new Error("No OpenAI client configured for style-sheet extraction");
|
|
64
|
+
err.code = "STYLE_SHEET_NO_KEY";
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
if (!prompt || typeof prompt !== "string" || !prompt.trim()) {
|
|
68
|
+
const err = new Error("prompt is required");
|
|
69
|
+
err.code = "STYLE_SHEET_BAD_INPUT";
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const userContent = referenceDataUrl
|
|
74
|
+
? [
|
|
75
|
+
{ type: "text", text: prompt },
|
|
76
|
+
{ type: "image_url", image_url: { url: referenceDataUrl } },
|
|
77
|
+
]
|
|
78
|
+
: prompt;
|
|
79
|
+
|
|
80
|
+
const resp = await openai.chat.completions.create({
|
|
81
|
+
model: STYLE_SHEET_MODEL,
|
|
82
|
+
response_format: { type: "json_object" },
|
|
83
|
+
messages: [
|
|
84
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
85
|
+
{ role: "user", content: userContent },
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const raw = resp.choices?.[0]?.message?.content;
|
|
90
|
+
if (!raw) {
|
|
91
|
+
const err = new Error("Empty response from style-sheet model");
|
|
92
|
+
err.code = "STYLE_SHEET_EMPTY";
|
|
93
|
+
throw err;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let parsed;
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(raw);
|
|
99
|
+
} catch {
|
|
100
|
+
const err = new Error("Style-sheet model returned non-JSON");
|
|
101
|
+
err.code = "STYLE_SHEET_PARSE";
|
|
102
|
+
throw err;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const sheet = coerceStyleSheet(parsed);
|
|
106
|
+
if (!sheet) {
|
|
107
|
+
const err = new Error("Style-sheet shape invalid");
|
|
108
|
+
err.code = "STYLE_SHEET_SHAPE";
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
return sheet;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Render a style sheet into a prompt preamble that gpt-image-1/2 can consume.
|
|
115
|
+
// Kept short so it doesn't blow the 4K prompt window on long user prompts.
|
|
116
|
+
export function renderStyleSheetPrefix(sheet) {
|
|
117
|
+
if (!sheet) return "";
|
|
118
|
+
const parts = [];
|
|
119
|
+
if (sheet.medium) parts.push(`Medium: ${sheet.medium}.`);
|
|
120
|
+
if (sheet.palette?.length) parts.push(`Palette: ${sheet.palette.join(", ")}.`);
|
|
121
|
+
if (sheet.composition) parts.push(`Composition: ${sheet.composition}.`);
|
|
122
|
+
if (sheet.mood) parts.push(`Mood: ${sheet.mood}.`);
|
|
123
|
+
if (sheet.subject_details) parts.push(`Subject: ${sheet.subject_details}.`);
|
|
124
|
+
if (sheet.negative?.length) parts.push(`Avoid: ${sheet.negative.join(", ")}.`);
|
|
125
|
+
return parts.join(" ");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { STYLE_SHEET_MODEL, SYSTEM_PROMPT, coerceStyleSheet };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ima2-gen",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "GPT Image 2 generator with OAuth & API key support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"test": "node scripts/run-tests.mjs",
|
|
18
18
|
"setup": "node bin/ima2.js setup",
|
|
19
19
|
"prepublishOnly": "npm run build && npm run lint:pkg",
|
|
20
|
-
"lint:pkg": "node -e \"const p=require('./package.json'); const req=['name','version','bin']; for(const k of req){if(!p[k])throw new Error('missing '+k)} const mustInclude=['bin/','lib/','server.js']; for(const f of mustInclude){if(!p.files.includes(f))throw new Error('files[] must include '+f)}\"",
|
|
20
|
+
"lint:pkg": "node -e \"const p=require('./package.json'); const req=['name','version','bin']; for(const k of req){if(!p[k])throw new Error('missing '+k)} const mustInclude=['bin/','lib/','routes/','server.js']; for(const f of mustInclude){if(!p.files.includes(f))throw new Error('files[] must include '+f)}\"",
|
|
21
21
|
"release:patch": "npm version patch && npm publish && git push origin main --tags",
|
|
22
22
|
"release:minor": "npm version minor && npm publish && git push origin main --tags",
|
|
23
23
|
"release:major": "npm version major && npm publish && git push origin main --tags"
|
|
@@ -37,9 +37,11 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"bin/",
|
|
39
39
|
"lib/",
|
|
40
|
+
"routes/",
|
|
40
41
|
"ui/dist/",
|
|
41
42
|
"assets/",
|
|
42
43
|
"server.js",
|
|
44
|
+
"config.js",
|
|
43
45
|
".env.example",
|
|
44
46
|
"README.md"
|
|
45
47
|
],
|
package/routes/edit.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { randomBytes } from "crypto";
|
|
4
|
+
import { editViaOAuth } from "../lib/oauthProxy.js";
|
|
5
|
+
import { classifyUpstreamError } from "../lib/errorClassify.js";
|
|
6
|
+
import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
|
|
7
|
+
import { getStyleSheet } from "../lib/sessionStore.js";
|
|
8
|
+
import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
|
|
9
|
+
import { startJob, finishJob } from "../lib/inflight.js";
|
|
10
|
+
import { logEvent, logError } from "../lib/logger.js";
|
|
11
|
+
|
|
12
|
+
function validateModeration(ctx, moderation) {
|
|
13
|
+
if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
|
|
14
|
+
return { error: "moderation must be one of: auto, low" };
|
|
15
|
+
}
|
|
16
|
+
return { moderation };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function registerEditRoutes(app, ctx) {
|
|
20
|
+
app.post("/api/edit", async (req, res) => {
|
|
21
|
+
const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : null;
|
|
22
|
+
let finishStatus = "completed";
|
|
23
|
+
let finishHttpStatus;
|
|
24
|
+
let finishErrorCode;
|
|
25
|
+
let finishMeta = {};
|
|
26
|
+
try {
|
|
27
|
+
const {
|
|
28
|
+
prompt,
|
|
29
|
+
image: imageB64,
|
|
30
|
+
quality: rawQuality = "medium",
|
|
31
|
+
size = "1024x1024",
|
|
32
|
+
moderation = "low",
|
|
33
|
+
provider = "oauth",
|
|
34
|
+
mode: promptMode = "auto",
|
|
35
|
+
} = req.body;
|
|
36
|
+
const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
|
|
37
|
+
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : null;
|
|
38
|
+
const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
|
|
39
|
+
|
|
40
|
+
startJob({
|
|
41
|
+
requestId,
|
|
42
|
+
kind: "classic",
|
|
43
|
+
prompt,
|
|
44
|
+
meta: {
|
|
45
|
+
kind: "edit",
|
|
46
|
+
sessionId,
|
|
47
|
+
quality,
|
|
48
|
+
size,
|
|
49
|
+
styleSheetApplied: false,
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (!prompt || !imageB64) {
|
|
54
|
+
finishStatus = "error";
|
|
55
|
+
finishHttpStatus = 400;
|
|
56
|
+
finishErrorCode = "INVALID_EDIT_INPUT";
|
|
57
|
+
return res.status(400).json({ error: "Prompt and image are required" });
|
|
58
|
+
}
|
|
59
|
+
const moderationCheck = validateModeration(ctx, moderation);
|
|
60
|
+
if (moderationCheck.error) {
|
|
61
|
+
finishStatus = "error";
|
|
62
|
+
finishHttpStatus = 400;
|
|
63
|
+
finishErrorCode = "INVALID_MODERATION";
|
|
64
|
+
return res.status(400).json({ error: moderationCheck.error });
|
|
65
|
+
}
|
|
66
|
+
if (provider === "api") {
|
|
67
|
+
finishStatus = "error";
|
|
68
|
+
finishHttpStatus = 403;
|
|
69
|
+
finishErrorCode = "APIKEY_DISABLED";
|
|
70
|
+
return res.status(403).json({ error: "API key provider is disabled. Use OAuth (Codex login).", code: "APIKEY_DISABLED" });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let effectivePrompt = prompt;
|
|
74
|
+
let styleSheetApplied = null;
|
|
75
|
+
if (sessionId) {
|
|
76
|
+
try {
|
|
77
|
+
const data = getStyleSheet(sessionId);
|
|
78
|
+
if (data && data.enabled && data.styleSheet) {
|
|
79
|
+
const prefix = renderStyleSheetPrefix(data.styleSheet);
|
|
80
|
+
if (prefix) {
|
|
81
|
+
effectivePrompt = `${prefix} ${prompt}`.slice(0, 4000);
|
|
82
|
+
styleSheetApplied = data.styleSheet;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
logEvent("edit", "request", {
|
|
89
|
+
requestId,
|
|
90
|
+
client: req.get("x-ima2-client") || "ui",
|
|
91
|
+
provider: "oauth",
|
|
92
|
+
quality,
|
|
93
|
+
size,
|
|
94
|
+
moderation,
|
|
95
|
+
sessionId,
|
|
96
|
+
promptChars: typeof prompt === "string" ? prompt.length : 0,
|
|
97
|
+
promptMode: normalizedPromptMode,
|
|
98
|
+
styleSheetApplied: !!styleSheetApplied,
|
|
99
|
+
inputImageChars: typeof imageB64 === "string" ? imageB64.length : 0,
|
|
100
|
+
});
|
|
101
|
+
const startTime = Date.now();
|
|
102
|
+
const { b64: resultB64, usage, revisedPrompt } = await editViaOAuth(
|
|
103
|
+
effectivePrompt,
|
|
104
|
+
imageB64,
|
|
105
|
+
quality,
|
|
106
|
+
size,
|
|
107
|
+
moderation,
|
|
108
|
+
normalizedPromptMode,
|
|
109
|
+
ctx,
|
|
110
|
+
requestId,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
114
|
+
await mkdir(ctx.config.storage.generatedDir, { recursive: true });
|
|
115
|
+
const filename = `${Date.now()}_${randomBytes(ctx.config.ids.generatedHexBytes).toString("hex")}.png`;
|
|
116
|
+
await writeFile(join(ctx.config.storage.generatedDir, filename), Buffer.from(resultB64, "base64"));
|
|
117
|
+
const meta = {
|
|
118
|
+
prompt,
|
|
119
|
+
userPrompt: prompt,
|
|
120
|
+
revisedPrompt: revisedPrompt || null,
|
|
121
|
+
promptMode: normalizedPromptMode,
|
|
122
|
+
effectivePrompt: styleSheetApplied ? effectivePrompt : undefined,
|
|
123
|
+
styleSheetApplied: styleSheetApplied || undefined,
|
|
124
|
+
quality,
|
|
125
|
+
size,
|
|
126
|
+
moderation,
|
|
127
|
+
format: "png",
|
|
128
|
+
provider: "oauth",
|
|
129
|
+
kind: "edit",
|
|
130
|
+
createdAt: Date.now(),
|
|
131
|
+
usage: usage || null,
|
|
132
|
+
webSearchCalls: 0,
|
|
133
|
+
};
|
|
134
|
+
await writeFile(join(ctx.config.storage.generatedDir, filename + ".json"), JSON.stringify(meta)).catch(() => {});
|
|
135
|
+
finishHttpStatus = 200;
|
|
136
|
+
finishMeta = { filename, imageChars: resultB64.length };
|
|
137
|
+
logEvent("edit", "saved", {
|
|
138
|
+
requestId,
|
|
139
|
+
filename,
|
|
140
|
+
imageChars: resultB64.length,
|
|
141
|
+
elapsedMs: Date.now() - startTime,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
res.json({
|
|
145
|
+
image: `data:image/png;base64,${resultB64}`,
|
|
146
|
+
elapsed,
|
|
147
|
+
filename,
|
|
148
|
+
usage,
|
|
149
|
+
provider: "oauth",
|
|
150
|
+
moderation,
|
|
151
|
+
warnings: qualityWarnings,
|
|
152
|
+
revisedPrompt: revisedPrompt || null,
|
|
153
|
+
promptMode: normalizedPromptMode,
|
|
154
|
+
});
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const fallbackCode = err.code || classifyUpstreamError(err.message);
|
|
157
|
+
finishStatus = "error";
|
|
158
|
+
finishHttpStatus = err.status || 500;
|
|
159
|
+
finishErrorCode = fallbackCode || "EDIT_FAILED";
|
|
160
|
+
logError("edit", "error", err, { requestId, code: finishErrorCode });
|
|
161
|
+
res.status(err.status || 500).json({ error: err.message, code: fallbackCode });
|
|
162
|
+
} finally {
|
|
163
|
+
finishJob(requestId, {
|
|
164
|
+
status: finishStatus,
|
|
165
|
+
httpStatus: finishHttpStatus,
|
|
166
|
+
errorCode: finishErrorCode,
|
|
167
|
+
meta: finishMeta,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { randomBytes } from "crypto";
|
|
4
|
+
import { validateAndNormalizeRefs } from "../lib/refs.js";
|
|
5
|
+
import { classifyUpstreamError } from "../lib/errorClassify.js";
|
|
6
|
+
import { normalizeOAuthParams } from "../lib/oauthNormalize.js";
|
|
7
|
+
import { generateViaOAuth } from "../lib/oauthProxy.js";
|
|
8
|
+
import { startJob, finishJob } from "../lib/inflight.js";
|
|
9
|
+
import { getStyleSheet } from "../lib/sessionStore.js";
|
|
10
|
+
import { renderStyleSheetPrefix } from "../lib/styleSheet.js";
|
|
11
|
+
import { logEvent, logError } from "../lib/logger.js";
|
|
12
|
+
|
|
13
|
+
function validateModeration(ctx, moderation) {
|
|
14
|
+
if (typeof moderation !== "string" || !ctx.config.oauth.validModeration.has(moderation)) {
|
|
15
|
+
return { error: "moderation must be one of: auto, low" };
|
|
16
|
+
}
|
|
17
|
+
return { moderation };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerGenerateRoutes(app, ctx) {
|
|
21
|
+
app.post("/api/generate", async (req, res) => {
|
|
22
|
+
const requestId = typeof req.body?.requestId === "string" ? req.body.requestId : null;
|
|
23
|
+
let finishStatus = "completed";
|
|
24
|
+
let finishHttpStatus;
|
|
25
|
+
let finishErrorCode;
|
|
26
|
+
let finishMeta = {};
|
|
27
|
+
try {
|
|
28
|
+
const sessionId = typeof req.body?.sessionId === "string" ? req.body.sessionId : null;
|
|
29
|
+
const clientNodeId = typeof req.body?.clientNodeId === "string" ? req.body.clientNodeId : null;
|
|
30
|
+
const {
|
|
31
|
+
prompt,
|
|
32
|
+
quality: rawQuality = "medium",
|
|
33
|
+
size = "1024x1024",
|
|
34
|
+
format = "png",
|
|
35
|
+
moderation = "low",
|
|
36
|
+
provider = "auto",
|
|
37
|
+
n = 1,
|
|
38
|
+
references = [],
|
|
39
|
+
mode: promptMode = "auto",
|
|
40
|
+
} = req.body;
|
|
41
|
+
const { quality, warnings: qualityWarnings } = normalizeOAuthParams({ provider, quality: rawQuality });
|
|
42
|
+
const normalizedPromptMode = promptMode === "direct" ? "direct" : "auto";
|
|
43
|
+
|
|
44
|
+
if (!prompt) return res.status(400).json({ error: "Prompt is required" });
|
|
45
|
+
const moderationCheck = validateModeration(ctx, moderation);
|
|
46
|
+
if (moderationCheck.error) return res.status(400).json({ error: moderationCheck.error });
|
|
47
|
+
const count = Math.min(Math.max(parseInt(n) || 1, 1), 8);
|
|
48
|
+
|
|
49
|
+
let effectivePrompt = prompt;
|
|
50
|
+
let styleSheetApplied = null;
|
|
51
|
+
if (sessionId) {
|
|
52
|
+
try {
|
|
53
|
+
const data = getStyleSheet(sessionId);
|
|
54
|
+
if (data && data.enabled && data.styleSheet) {
|
|
55
|
+
const prefix = renderStyleSheetPrefix(data.styleSheet);
|
|
56
|
+
if (prefix) {
|
|
57
|
+
effectivePrompt = `${prefix} ${prompt}`.slice(0, 4000);
|
|
58
|
+
styleSheetApplied = data.styleSheet;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
startJob({
|
|
65
|
+
requestId,
|
|
66
|
+
kind: "classic",
|
|
67
|
+
prompt: effectivePrompt,
|
|
68
|
+
meta: {
|
|
69
|
+
kind: "classic",
|
|
70
|
+
sessionId,
|
|
71
|
+
parentNodeId: null,
|
|
72
|
+
clientNodeId,
|
|
73
|
+
quality,
|
|
74
|
+
size,
|
|
75
|
+
n: count,
|
|
76
|
+
styleSheetApplied: !!styleSheetApplied,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const refCheck = validateAndNormalizeRefs(references);
|
|
81
|
+
if (refCheck.error) {
|
|
82
|
+
finishStatus = "error";
|
|
83
|
+
finishHttpStatus = 400;
|
|
84
|
+
finishErrorCode = refCheck.code;
|
|
85
|
+
return res.status(400).json({ error: refCheck.error, code: refCheck.code });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (provider === "api") {
|
|
89
|
+
finishStatus = "error";
|
|
90
|
+
finishHttpStatus = 403;
|
|
91
|
+
finishErrorCode = "APIKEY_DISABLED";
|
|
92
|
+
return res.status(403).json({ error: "API key provider is disabled. Use OAuth (Codex login).", code: "APIKEY_DISABLED" });
|
|
93
|
+
}
|
|
94
|
+
const client = req.get("x-ima2-client") || "ui";
|
|
95
|
+
logEvent("generate", "request", {
|
|
96
|
+
requestId,
|
|
97
|
+
client,
|
|
98
|
+
provider: "oauth",
|
|
99
|
+
quality,
|
|
100
|
+
size,
|
|
101
|
+
moderation,
|
|
102
|
+
n: count,
|
|
103
|
+
refs: refCheck.refs.length,
|
|
104
|
+
sessionId,
|
|
105
|
+
clientNodeId,
|
|
106
|
+
promptChars: typeof prompt === "string" ? prompt.length : 0,
|
|
107
|
+
promptMode: normalizedPromptMode,
|
|
108
|
+
styleSheetApplied: !!styleSheetApplied,
|
|
109
|
+
});
|
|
110
|
+
const startTime = Date.now();
|
|
111
|
+
|
|
112
|
+
const mimeMap = { png: "image/png", jpeg: "image/jpeg", webp: "image/webp" };
|
|
113
|
+
const mime = mimeMap[format] || "image/png";
|
|
114
|
+
await mkdir(ctx.config.storage.generatedDir, { recursive: true });
|
|
115
|
+
|
|
116
|
+
const generateOne = async () => {
|
|
117
|
+
const MAX_RETRIES = 1;
|
|
118
|
+
let lastErr;
|
|
119
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
120
|
+
try {
|
|
121
|
+
const r = await generateViaOAuth(
|
|
122
|
+
effectivePrompt,
|
|
123
|
+
quality,
|
|
124
|
+
size,
|
|
125
|
+
moderation,
|
|
126
|
+
refCheck.refs,
|
|
127
|
+
requestId,
|
|
128
|
+
normalizedPromptMode,
|
|
129
|
+
ctx,
|
|
130
|
+
);
|
|
131
|
+
if (r.b64) return r;
|
|
132
|
+
lastErr = new Error("Empty response (safety refusal)");
|
|
133
|
+
} catch (e) {
|
|
134
|
+
lastErr = e;
|
|
135
|
+
}
|
|
136
|
+
if (attempt < MAX_RETRIES) {
|
|
137
|
+
logEvent("generate", "retry", { requestId, attempt: attempt + 1, errorCode: lastErr?.code });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const err = new Error("Content generation refused after retries");
|
|
141
|
+
err.code = "SAFETY_REFUSAL";
|
|
142
|
+
err.status = 422;
|
|
143
|
+
err.cause = lastErr;
|
|
144
|
+
throw err;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const results = await Promise.allSettled(Array.from({ length: count }, generateOne));
|
|
148
|
+
const images = [];
|
|
149
|
+
let totalUsage = null;
|
|
150
|
+
let totalWebSearchCalls = 0;
|
|
151
|
+
for (const r of results) {
|
|
152
|
+
if (r.status === "fulfilled" && r.value.b64) {
|
|
153
|
+
const rand = randomBytes(ctx.config.ids.generatedHexBytes).toString("hex");
|
|
154
|
+
const filename = `${Date.now()}_${rand}_${images.length}.${format}`;
|
|
155
|
+
await writeFile(join(ctx.config.storage.generatedDir, filename), Buffer.from(r.value.b64, "base64"));
|
|
156
|
+
const meta = {
|
|
157
|
+
prompt,
|
|
158
|
+
userPrompt: prompt,
|
|
159
|
+
revisedPrompt: r.value.revisedPrompt || null,
|
|
160
|
+
promptMode: normalizedPromptMode,
|
|
161
|
+
effectivePrompt: styleSheetApplied ? effectivePrompt : undefined,
|
|
162
|
+
styleSheetApplied: styleSheetApplied || undefined,
|
|
163
|
+
quality,
|
|
164
|
+
size,
|
|
165
|
+
format,
|
|
166
|
+
moderation,
|
|
167
|
+
provider: "oauth",
|
|
168
|
+
createdAt: Date.now(),
|
|
169
|
+
usage: r.value.usage || null,
|
|
170
|
+
webSearchCalls: r.value.webSearchCalls || 0,
|
|
171
|
+
};
|
|
172
|
+
await writeFile(join(ctx.config.storage.generatedDir, filename + ".json"), JSON.stringify(meta)).catch(() => {});
|
|
173
|
+
images.push({
|
|
174
|
+
image: `data:${mime};base64,${r.value.b64}`,
|
|
175
|
+
filename,
|
|
176
|
+
revisedPrompt: r.value.revisedPrompt || null,
|
|
177
|
+
});
|
|
178
|
+
if (r.value.usage) {
|
|
179
|
+
if (!totalUsage) totalUsage = { ...r.value.usage };
|
|
180
|
+
else Object.keys(r.value.usage).forEach((k) => {
|
|
181
|
+
if (typeof r.value.usage[k] === "number") totalUsage[k] = (totalUsage[k] || 0) + r.value.usage[k];
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (typeof r.value.webSearchCalls === "number") totalWebSearchCalls += r.value.webSearchCalls;
|
|
185
|
+
} else if (r.status === "rejected") {
|
|
186
|
+
logError("generate", "parallel_failed", r.reason, { requestId });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (images.length === 0) {
|
|
191
|
+
const firstErr = results.find((r) => r.status === "rejected")?.reason;
|
|
192
|
+
if (firstErr?.code === "SAFETY_REFUSAL") {
|
|
193
|
+
finishStatus = "error";
|
|
194
|
+
finishHttpStatus = 422;
|
|
195
|
+
finishErrorCode = "SAFETY_REFUSAL";
|
|
196
|
+
return res.status(422).json({ error: firstErr.message, code: "SAFETY_REFUSAL" });
|
|
197
|
+
}
|
|
198
|
+
finishStatus = "error";
|
|
199
|
+
finishHttpStatus = 500;
|
|
200
|
+
finishErrorCode = "GENERATE_ALL_FAILED";
|
|
201
|
+
return res.status(500).json({ error: "All generation attempts failed" });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
205
|
+
const firstRevised = images[0]?.revisedPrompt || null;
|
|
206
|
+
const extra = {
|
|
207
|
+
usage: totalUsage,
|
|
208
|
+
provider: "oauth",
|
|
209
|
+
webSearchCalls: totalWebSearchCalls,
|
|
210
|
+
quality,
|
|
211
|
+
size,
|
|
212
|
+
moderation,
|
|
213
|
+
warnings: qualityWarnings,
|
|
214
|
+
revisedPrompt: firstRevised,
|
|
215
|
+
promptMode: normalizedPromptMode,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
if (count === 1) {
|
|
219
|
+
finishHttpStatus = 200;
|
|
220
|
+
finishMeta = { filenames: [images[0].filename], imageCount: 1 };
|
|
221
|
+
logEvent("generate", "saved", {
|
|
222
|
+
requestId,
|
|
223
|
+
imageCount: 1,
|
|
224
|
+
elapsedMs: Date.now() - startTime,
|
|
225
|
+
filename: images[0].filename,
|
|
226
|
+
});
|
|
227
|
+
res.json({ image: images[0].image, elapsed, filename: images[0].filename, requestId, ...extra });
|
|
228
|
+
} else {
|
|
229
|
+
finishHttpStatus = 200;
|
|
230
|
+
finishMeta = { filenames: images.map((image) => image.filename), imageCount: images.length };
|
|
231
|
+
logEvent("generate", "saved", {
|
|
232
|
+
requestId,
|
|
233
|
+
imageCount: images.length,
|
|
234
|
+
elapsedMs: Date.now() - startTime,
|
|
235
|
+
});
|
|
236
|
+
res.json({ images, elapsed, count: images.length, requestId, ...extra });
|
|
237
|
+
}
|
|
238
|
+
} catch (err) {
|
|
239
|
+
const fallbackCode = err.code || classifyUpstreamError(err.message);
|
|
240
|
+
finishStatus = "error";
|
|
241
|
+
finishHttpStatus = err.status || 500;
|
|
242
|
+
finishErrorCode = fallbackCode || "GENERATE_FAILED";
|
|
243
|
+
logError("generate", "error", err, { requestId, code: finishErrorCode });
|
|
244
|
+
res.status(err.status || 500).json({ error: err.message, code: fallbackCode, requestId });
|
|
245
|
+
} finally {
|
|
246
|
+
finishJob(requestId, {
|
|
247
|
+
status: finishStatus,
|
|
248
|
+
httpStatus: finishHttpStatus,
|
|
249
|
+
errorCode: finishErrorCode,
|
|
250
|
+
meta: finishMeta,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
package/routes/health.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { listJobs, listTerminalJobs, finishJob } from "../lib/inflight.js";
|
|
2
|
+
|
|
3
|
+
export function registerHealthRoutes(app, ctx) {
|
|
4
|
+
app.get("/api/providers", (_req, res) => {
|
|
5
|
+
res.json({
|
|
6
|
+
apiKey: false,
|
|
7
|
+
oauth: true,
|
|
8
|
+
oauthPort: ctx.oauthPort,
|
|
9
|
+
apiKeyDisabled: true,
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
app.get("/api/health", (_req, res) => {
|
|
14
|
+
res.json({
|
|
15
|
+
ok: true,
|
|
16
|
+
version: ctx.packageVersion,
|
|
17
|
+
provider: "oauth",
|
|
18
|
+
uptimeSec: Math.round(process.uptime()),
|
|
19
|
+
activeJobs: listJobs().length,
|
|
20
|
+
pid: process.pid,
|
|
21
|
+
startedAt: ctx.startedAt,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
app.get("/api/oauth/status", async (_req, res) => {
|
|
26
|
+
try {
|
|
27
|
+
const r = await fetch(`${ctx.oauthUrl}/v1/models`, {
|
|
28
|
+
signal: AbortSignal.timeout(ctx.config.oauth.statusTimeoutMs),
|
|
29
|
+
});
|
|
30
|
+
if (r.ok) {
|
|
31
|
+
const data = await r.json();
|
|
32
|
+
res.json({ status: "ready", models: data.data?.map((m) => m.id) || [] });
|
|
33
|
+
} else {
|
|
34
|
+
res.json({ status: "auth_required" });
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
res.json({ status: "offline" });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
app.get("/api/inflight", (req, res) => {
|
|
42
|
+
const kind =
|
|
43
|
+
typeof req.query.kind === "string" && req.query.kind.length > 0
|
|
44
|
+
? req.query.kind
|
|
45
|
+
: undefined;
|
|
46
|
+
const sessionId =
|
|
47
|
+
typeof req.query.sessionId === "string" && req.query.sessionId.length > 0
|
|
48
|
+
? req.query.sessionId
|
|
49
|
+
: undefined;
|
|
50
|
+
const includeTerminal =
|
|
51
|
+
req.query.includeTerminal === "1" || req.query.includeTerminal === "true";
|
|
52
|
+
const jobs = listJobs({ kind, sessionId });
|
|
53
|
+
if (!includeTerminal) return res.json({ jobs });
|
|
54
|
+
return res.json({
|
|
55
|
+
jobs,
|
|
56
|
+
terminalJobs: listTerminalJobs({ kind, sessionId }),
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
app.delete("/api/inflight/:requestId", (req, res) => {
|
|
61
|
+
finishJob(req.params.requestId, { canceled: true });
|
|
62
|
+
res.status(204).end();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
app.get("/api/billing", async (_req, res) => {
|
|
66
|
+
if (!ctx.hasApiKey) {
|
|
67
|
+
return res.json({ oauth: true, apiKeyValid: false, apiKeySource: "none" });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const headers = { Authorization: `Bearer ${ctx.apiKey}`, "Content-Type": "application/json" };
|
|
72
|
+
const start = Math.floor(new Date(new Date().getFullYear(), new Date().getMonth(), 1).getTime() / 1000);
|
|
73
|
+
const end = Math.floor(Date.now() / 1000);
|
|
74
|
+
const [subRes, usageRes, modelsRes] = await Promise.allSettled([
|
|
75
|
+
fetch(`https://api.openai.com/v1/organization/costs?start_time=${start}&end_time=${end}&bucket_width=1d&limit=31`, { headers }),
|
|
76
|
+
fetch("https://api.openai.com/dashboard/billing/credit_grants", { headers }),
|
|
77
|
+
fetch("https://api.openai.com/v1/models", { headers }),
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const billing = { apiKeySource: ctx.apiKeySource ?? "env" };
|
|
81
|
+
if (subRes.status === "fulfilled" && subRes.value.ok) billing.costs = await subRes.value.json();
|
|
82
|
+
if (usageRes.status === "fulfilled" && usageRes.value.ok) billing.credits = await usageRes.value.json();
|
|
83
|
+
billing.apiKeyValid = modelsRes.status === "fulfilled" && modelsRes.value.ok === true;
|
|
84
|
+
res.json(billing);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
res.status(500).json({ error: err.message, apiKeyValid: false });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|