imgrelay 0.0.0-stage → 0.2.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/LICENSE.md +22 -0
- package/README.md +191 -2
- package/dist/index.mjs +1138 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +43 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { access, lstat, mkdir, open, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, parse, resolve } from "node:path";
|
|
5
|
+
import { fileTypeFromBuffer } from "file-type";
|
|
6
|
+
import { imageSize } from "image-size";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { setTimeout } from "node:timers/promises";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { Command, CommanderError } from "commander";
|
|
11
|
+
import * as prompts from "@clack/prompts";
|
|
12
|
+
//#region ../core/dist/index.mjs
|
|
13
|
+
var ImgRelayError = class extends Error {
|
|
14
|
+
category;
|
|
15
|
+
exitCode;
|
|
16
|
+
httpStatus;
|
|
17
|
+
constructor(category, message, exitCode = 2, httpStatus) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = "ImgRelayError";
|
|
20
|
+
this.category = category;
|
|
21
|
+
this.exitCode = exitCode;
|
|
22
|
+
this.httpStatus = httpStatus;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
/** Redact before serialization; never retain an upstream response as a diagnostic. */
|
|
26
|
+
var Redactor = class {
|
|
27
|
+
secrets = /* @__PURE__ */ new Set();
|
|
28
|
+
add(secret) {
|
|
29
|
+
if (secret) this.secrets.add(secret);
|
|
30
|
+
}
|
|
31
|
+
text(value) {
|
|
32
|
+
let text = value;
|
|
33
|
+
for (const secret of [...this.secrets].toSorted((a, b) => b.length - a.length)) text = text.replaceAll(secret, "[REDACTED]");
|
|
34
|
+
return text.replace(/https?:\/\/[^\s<>"']+/gi, (raw) => {
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(raw);
|
|
37
|
+
if (url.username || url.password) {
|
|
38
|
+
url.username = "REDACTED";
|
|
39
|
+
url.password = "";
|
|
40
|
+
}
|
|
41
|
+
if (url.search) url.search = "?REDACTED";
|
|
42
|
+
if (url.hash) url.hash = "#REDACTED";
|
|
43
|
+
return url.toString();
|
|
44
|
+
} catch {
|
|
45
|
+
return "[REDACTED_URL]";
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
value(value) {
|
|
50
|
+
if (typeof value === "string") return this.text(value);
|
|
51
|
+
if (Array.isArray(value)) return value.map((item) => this.value(item));
|
|
52
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [this.text(key), this.value(item)]));
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
error(error) {
|
|
56
|
+
if (error instanceof ImgRelayError) return {
|
|
57
|
+
category: error.category,
|
|
58
|
+
message: this.text(error.message),
|
|
59
|
+
...error.httpStatus === void 0 ? {} : { httpStatus: error.httpStatus }
|
|
60
|
+
};
|
|
61
|
+
return {
|
|
62
|
+
category: "internal_error",
|
|
63
|
+
message: this.text(error instanceof Error ? error.message : "发生未知错误")
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
function parseApiUrl(value) {
|
|
68
|
+
let url;
|
|
69
|
+
try {
|
|
70
|
+
url = new URL(value);
|
|
71
|
+
} catch {
|
|
72
|
+
throw new ImgRelayError("config_error", "API 地址必须是完整的 HTTP 或 HTTPS URL");
|
|
73
|
+
}
|
|
74
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new ImgRelayError("config_error", "API 地址只能使用 HTTP(S),不能包含凭据、查询参数或片段");
|
|
75
|
+
return url;
|
|
76
|
+
}
|
|
77
|
+
function normalizeAddress(value) {
|
|
78
|
+
const url = parseApiUrl(value);
|
|
79
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
80
|
+
const isGeneration = path.endsWith("/images/generations");
|
|
81
|
+
url.pathname = isGeneration ? path.slice(0, -19) : path;
|
|
82
|
+
return {
|
|
83
|
+
baseUrl: url.toString().replace(/\/+$/, ""),
|
|
84
|
+
...isGeneration ? { generationPath: "/images/generations" } : {}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function validateEndpoint(path) {
|
|
88
|
+
try {
|
|
89
|
+
return path.startsWith("/") && !/[?#\\]/.test(path) && !path.includes("//") && !path.split("/").some((part) => ["..", "."].includes(decodeURIComponent(part)));
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function resolveEndpoint(baseUrl, endpoint) {
|
|
95
|
+
if (!validateEndpoint(endpoint)) throw new ImgRelayError("config_error", "端点必须是路径,不能包含主机、查询参数或上级目录");
|
|
96
|
+
const url = parseApiUrl(baseUrl);
|
|
97
|
+
const basePath = url.pathname.replace(/\/+$/, "");
|
|
98
|
+
url.pathname = `${basePath}${basePath.endsWith("/v1") && endpoint.startsWith("/v1/") ? endpoint.slice(3) : endpoint}`;
|
|
99
|
+
return url.toString();
|
|
100
|
+
}
|
|
101
|
+
const shortText = z.string().trim().min(1).max(256);
|
|
102
|
+
const providerNameSchema = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/);
|
|
103
|
+
const parameterSchema = z.strictObject({
|
|
104
|
+
size: shortText.optional(),
|
|
105
|
+
n: z.number().int().positive().optional(),
|
|
106
|
+
quality: shortText.optional(),
|
|
107
|
+
outputFormat: shortText.optional(),
|
|
108
|
+
responseFormat: z.enum(["url", "b64_json"]).optional()
|
|
109
|
+
});
|
|
110
|
+
const parameterCapabilitySchema = z.strictObject({
|
|
111
|
+
supported: z.boolean().optional(),
|
|
112
|
+
values: z.array(shortText).min(1).optional(),
|
|
113
|
+
min: z.number().optional(),
|
|
114
|
+
max: z.number().optional()
|
|
115
|
+
});
|
|
116
|
+
const modelCapabilitySchema = z.strictObject({
|
|
117
|
+
source: z.enum([
|
|
118
|
+
"official_documentation",
|
|
119
|
+
"provider_override",
|
|
120
|
+
"call_verification"
|
|
121
|
+
]).default("provider_override"),
|
|
122
|
+
sourceUrl: z.url().optional(),
|
|
123
|
+
verifiedAt: z.iso.datetime().optional(),
|
|
124
|
+
parameters: z.strictObject({
|
|
125
|
+
size: parameterCapabilitySchema.optional(),
|
|
126
|
+
n: parameterCapabilitySchema.optional(),
|
|
127
|
+
quality: parameterCapabilitySchema.optional(),
|
|
128
|
+
outputFormat: parameterCapabilitySchema.optional(),
|
|
129
|
+
responseFormat: parameterCapabilitySchema.optional()
|
|
130
|
+
})
|
|
131
|
+
});
|
|
132
|
+
const endpointSchema = z.string().max(1024).refine((value) => {
|
|
133
|
+
try {
|
|
134
|
+
return validateEndpoint(value);
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}, "端点必须是有效路径");
|
|
139
|
+
const providerSchema = z.strictObject({
|
|
140
|
+
protocol: z.literal("openai-images"),
|
|
141
|
+
baseUrl: z.string(),
|
|
142
|
+
apiKey: z.string().trim().min(1).refine((key) => !/[\r\n\0]/.test(key), "密钥不能包含换行或空字符").optional(),
|
|
143
|
+
defaultModel: shortText.optional(),
|
|
144
|
+
defaults: parameterSchema.default({}),
|
|
145
|
+
endpoints: z.strictObject({
|
|
146
|
+
generate: endpointSchema.optional(),
|
|
147
|
+
models: endpointSchema.optional()
|
|
148
|
+
}).default({}),
|
|
149
|
+
modelOverrides: z.record(shortText, modelCapabilitySchema).default({}),
|
|
150
|
+
timeoutSeconds: z.number().positive().max(2147483).finite().default(600),
|
|
151
|
+
revision: z.number().int().positive().default(1)
|
|
152
|
+
}).transform((provider) => {
|
|
153
|
+
const address = normalizeAddress(provider.baseUrl);
|
|
154
|
+
return {
|
|
155
|
+
...provider,
|
|
156
|
+
baseUrl: address.baseUrl,
|
|
157
|
+
endpoints: address.generationPath && !provider.endpoints.generate ? {
|
|
158
|
+
...provider.endpoints,
|
|
159
|
+
generate: address.generationPath
|
|
160
|
+
} : provider.endpoints
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
const configSchema = z.strictObject({
|
|
164
|
+
schemaVersion: z.literal(1),
|
|
165
|
+
defaultProvider: providerNameSchema.optional(),
|
|
166
|
+
providers: z.record(providerNameSchema, providerSchema)
|
|
167
|
+
});
|
|
168
|
+
function parseProvider(input) {
|
|
169
|
+
const result = providerSchema.safeParse(input);
|
|
170
|
+
if (!result.success) throw new ImgRelayError("config_error", `供应商配置无效:${result.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join(";")}`);
|
|
171
|
+
return result.data;
|
|
172
|
+
}
|
|
173
|
+
const CUBENCE_PRESET = {
|
|
174
|
+
protocol: "openai-images",
|
|
175
|
+
baseUrl: "https://api-lb.cubence.ai/v1",
|
|
176
|
+
defaultModel: "gpt-image-2.5-flare",
|
|
177
|
+
defaults: {
|
|
178
|
+
size: "auto",
|
|
179
|
+
n: 1,
|
|
180
|
+
quality: "high",
|
|
181
|
+
outputFormat: "png"
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
const GPT_IMAGE_CAPABILITY = {
|
|
185
|
+
source: "official_documentation",
|
|
186
|
+
sourceUrl: "https://developers.openai.com/api/reference/python/resources/images/methods/generate",
|
|
187
|
+
verifiedAt: "2026-10-10T10:03:00.000Z",
|
|
188
|
+
parameters: {
|
|
189
|
+
n: {
|
|
190
|
+
min: 1,
|
|
191
|
+
max: 10
|
|
192
|
+
},
|
|
193
|
+
size: { values: [
|
|
194
|
+
"auto",
|
|
195
|
+
"1024x1024",
|
|
196
|
+
"1536x1024",
|
|
197
|
+
"1024x1536"
|
|
198
|
+
] },
|
|
199
|
+
quality: { values: [
|
|
200
|
+
"auto",
|
|
201
|
+
"low",
|
|
202
|
+
"medium",
|
|
203
|
+
"high"
|
|
204
|
+
] },
|
|
205
|
+
outputFormat: { values: [
|
|
206
|
+
"png",
|
|
207
|
+
"jpeg",
|
|
208
|
+
"webp"
|
|
209
|
+
] },
|
|
210
|
+
responseFormat: { supported: false }
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
const KNOWN_MODEL_CAPABILITIES = {
|
|
214
|
+
"gpt-image-1": GPT_IMAGE_CAPABILITY,
|
|
215
|
+
"gpt-image-1-mini": GPT_IMAGE_CAPABILITY
|
|
216
|
+
};
|
|
217
|
+
function resolveParameters(provider, model, supplied) {
|
|
218
|
+
const parsed = parameterSchema.safeParse({
|
|
219
|
+
n: 1,
|
|
220
|
+
...provider.defaults,
|
|
221
|
+
...supplied
|
|
222
|
+
});
|
|
223
|
+
if (!parsed.success) throw new ImgRelayError("input_error", "生成参数无效:数量必须是正整数,各参数必须是非空字符串");
|
|
224
|
+
const result = parsed.data;
|
|
225
|
+
const known = Object.hasOwn(KNOWN_MODEL_CAPABILITIES, model) ? KNOWN_MODEL_CAPABILITIES[model] : void 0;
|
|
226
|
+
const override = Object.hasOwn(provider.modelOverrides, model) ? provider.modelOverrides[model] : void 0;
|
|
227
|
+
const limits = {
|
|
228
|
+
...known?.parameters,
|
|
229
|
+
...override?.parameters
|
|
230
|
+
};
|
|
231
|
+
for (const name of [
|
|
232
|
+
"size",
|
|
233
|
+
"n",
|
|
234
|
+
"quality",
|
|
235
|
+
"outputFormat",
|
|
236
|
+
"responseFormat"
|
|
237
|
+
]) {
|
|
238
|
+
const value = result[name];
|
|
239
|
+
const limit = limits[name];
|
|
240
|
+
if (value === void 0 || !limit) continue;
|
|
241
|
+
if (limit.supported === false && name === "n" && supplied.n === void 0 && provider.defaults.n === void 0) {
|
|
242
|
+
delete result.n;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (limit.supported === false || limit.values && !limit.values.includes(String(value)) || typeof value === "number" && (limit.min !== void 0 && value < limit.min || limit.max !== void 0 && value > limit.max)) throw new ImgRelayError("parameter_unsupported", `模型 ${model} 不支持参数 ${name}=${value};请检查模型能力或供应商覆盖`, 2);
|
|
246
|
+
}
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
function isNotFound(error) {
|
|
250
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
251
|
+
}
|
|
252
|
+
async function assertRegular(path) {
|
|
253
|
+
const stat = await lstat(path);
|
|
254
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new ImgRelayError("credential_error", "配置或凭据文件必须是普通文件,不能使用符号链接");
|
|
255
|
+
}
|
|
256
|
+
async function writeAtomic(path, value) {
|
|
257
|
+
const temporary = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
258
|
+
const handle = await open(temporary, "wx");
|
|
259
|
+
try {
|
|
260
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
261
|
+
await handle.sync();
|
|
262
|
+
await handle.close();
|
|
263
|
+
await rename(temporary, path);
|
|
264
|
+
} finally {
|
|
265
|
+
await handle.close().catch(() => {});
|
|
266
|
+
await unlink(temporary).catch(() => {});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async function readJson(path) {
|
|
270
|
+
await assertRegular(path);
|
|
271
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
272
|
+
}
|
|
273
|
+
const MAX_JSON_BYTES = 134217728;
|
|
274
|
+
const MAX_IMAGE_BYTES = 33554432;
|
|
275
|
+
async function readBounded(response, limit) {
|
|
276
|
+
if (Number(response.headers.get("content-length")) > limit) {
|
|
277
|
+
await response.body?.cancel();
|
|
278
|
+
throw new ImgRelayError("invalid_response", "上游响应超过大小限制", 3);
|
|
279
|
+
}
|
|
280
|
+
if (!response.body) return Buffer.alloc(0);
|
|
281
|
+
const reader = response.body.getReader();
|
|
282
|
+
const chunks = [];
|
|
283
|
+
let size = 0;
|
|
284
|
+
try {
|
|
285
|
+
for (;;) {
|
|
286
|
+
const { value, done } = await reader.read();
|
|
287
|
+
if (done) break;
|
|
288
|
+
size += value.byteLength;
|
|
289
|
+
if (size > limit) throw new ImgRelayError("invalid_response", "上游响应超过大小限制", 3);
|
|
290
|
+
chunks.push(value);
|
|
291
|
+
}
|
|
292
|
+
return Buffer.concat(chunks, size);
|
|
293
|
+
} finally {
|
|
294
|
+
await reader.cancel().catch(() => {});
|
|
295
|
+
reader.releaseLock();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function httpError(status, text, redactor) {
|
|
299
|
+
let detail = "";
|
|
300
|
+
try {
|
|
301
|
+
const parsed = JSON.parse(text);
|
|
302
|
+
if (parsed && typeof parsed === "object" && "error" in parsed) {
|
|
303
|
+
const error = parsed.error;
|
|
304
|
+
if (typeof error === "string") detail = error;
|
|
305
|
+
else if (error && typeof error === "object" && "message" in error && typeof error.message === "string") detail = error.message;
|
|
306
|
+
}
|
|
307
|
+
} catch {}
|
|
308
|
+
let category = "upstream_error";
|
|
309
|
+
if (status === 401 || status === 403) category = "auth_error";
|
|
310
|
+
else if (status === 429) category = "rate_limit";
|
|
311
|
+
else if (status === 402 || /insufficient.*(quota|balance)|余额不足/i.test(detail)) category = "quota_exceeded";
|
|
312
|
+
else if (/model.*(not found|not exist|unavailable|invalid)|模型.*(不存在|不可用)/i.test(detail)) category = "model_unavailable";
|
|
313
|
+
else if (/unsupported.*(param|size|quality|format)|unknown parameter|参数.*(不支持|无效)/i.test(detail)) category = "parameter_unsupported";
|
|
314
|
+
return new ImgRelayError(category, redactor.text(`上游返回 HTTP ${status}${detail ? `:${detail.slice(0, 1e3)}` : ",请检查接口、权限和请求参数"}`), 3, status);
|
|
315
|
+
}
|
|
316
|
+
async function requestJson(options) {
|
|
317
|
+
const method = options.method || "GET";
|
|
318
|
+
const attempts = method === "GET" && options.retry ? 2 : 1;
|
|
319
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
320
|
+
const timeout = AbortSignal.timeout(Math.ceil((options.timeoutSeconds || 15) * 1e3));
|
|
321
|
+
const signal = options.signal ? AbortSignal.any([timeout, options.signal]) : timeout;
|
|
322
|
+
try {
|
|
323
|
+
const response = await fetch(options.url, {
|
|
324
|
+
method,
|
|
325
|
+
headers: {
|
|
326
|
+
Authorization: `Bearer ${options.key}`,
|
|
327
|
+
...method === "POST" ? { "Content-Type": "application/json" } : {}
|
|
328
|
+
},
|
|
329
|
+
...options.body === void 0 ? {} : { body: JSON.stringify(options.body) },
|
|
330
|
+
redirect: "manual",
|
|
331
|
+
signal
|
|
332
|
+
});
|
|
333
|
+
const text = (await readBounded(response, response.ok ? MAX_JSON_BYTES : 65536)).toString("utf8");
|
|
334
|
+
if (!response.ok) throw httpError(response.status, text, options.redactor);
|
|
335
|
+
try {
|
|
336
|
+
return JSON.parse(text);
|
|
337
|
+
} catch {
|
|
338
|
+
throw new ImgRelayError("invalid_response", "上游成功响应不是有效 JSON", 3);
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
if (options.signal?.aborted) throw new ImgRelayError("interrupted", method === "POST" ? "已停止本地等待,无法保证取消已提交的上游生成" : "用户中断请求", 130);
|
|
342
|
+
const retryable = !(error instanceof ImgRelayError) || error.httpStatus === 429 || error.httpStatus !== void 0 && error.httpStatus >= 500;
|
|
343
|
+
if (attempt + 1 < attempts && retryable) {
|
|
344
|
+
await setTimeout(150);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (error instanceof ImgRelayError) throw error;
|
|
348
|
+
throw new ImgRelayError(method === "POST" ? "result_unknown" : "upstream_error", method === "POST" ? "生成请求超时或网络断开,结果未知;上游可能仍在生成,请勿直接重复提交" : "模型查询超时或网络失败,请检查地址和网络", 3);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
throw new ImgRelayError("internal_error", "请求执行状态无效", 1);
|
|
352
|
+
}
|
|
353
|
+
function base64Bytes(value) {
|
|
354
|
+
const encoded = value.replace(/^data:image\/[^;,]+;base64,/, "").replace(/\s/g, "");
|
|
355
|
+
if (encoded.length > Math.ceil(33554432 / 3) * 4 || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 === 1) throw new ImgRelayError("image_decode_error", "Base64 图片无效或超过 32 MiB 限制", 3);
|
|
356
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
357
|
+
if (bytes.length > 33554432 || bytes.toString("base64").replace(/=+$/, "") !== encoded.replace(/=+$/, "")) throw new ImgRelayError("image_decode_error", "Base64 图片解码失败", 3);
|
|
358
|
+
return bytes;
|
|
359
|
+
}
|
|
360
|
+
async function download(urlString, signal) {
|
|
361
|
+
let current = urlString;
|
|
362
|
+
try {
|
|
363
|
+
for (let redirects = 0; redirects <= 3; redirects++) {
|
|
364
|
+
const url = new URL(current);
|
|
365
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new ImgRelayError("image_download_error", "图片地址必须为不含内嵌凭据的 HTTP(S) URL", 3);
|
|
366
|
+
const timeout = AbortSignal.timeout(6e4);
|
|
367
|
+
const response = await fetch(url, {
|
|
368
|
+
redirect: "manual",
|
|
369
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout
|
|
370
|
+
});
|
|
371
|
+
if (response.status >= 300 && response.status < 400) {
|
|
372
|
+
const location = response.headers.get("location");
|
|
373
|
+
await response.body?.cancel();
|
|
374
|
+
if (!location) throw new ImgRelayError("image_download_error", "图片重定向缺少目标地址", 3);
|
|
375
|
+
current = new URL(location, url).toString();
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
if (!response.ok) {
|
|
379
|
+
await response.body?.cancel();
|
|
380
|
+
throw new ImgRelayError("image_download_error", `图片下载返回 HTTP ${response.status}`, 3, response.status);
|
|
381
|
+
}
|
|
382
|
+
return await readBounded(response, MAX_IMAGE_BYTES);
|
|
383
|
+
}
|
|
384
|
+
throw new ImgRelayError("image_download_error", "图片下载重定向超过三次", 3);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (signal?.aborted) throw new ImgRelayError("interrupted", "已停止本地保存;无法保证取消已提交的上游生成", 130);
|
|
387
|
+
if (error instanceof ImgRelayError) throw error;
|
|
388
|
+
throw new ImgRelayError("image_download_error", "图片下载超时或网络失败;不会重新生成图片", 3);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
function usageNumbers(value, depth = 0) {
|
|
392
|
+
if (depth > 4) return void 0;
|
|
393
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
394
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
395
|
+
const entries = Object.entries(value).slice(0, 100).flatMap(([key, item]) => {
|
|
396
|
+
if (!/^[a-z_][a-z0-9_]{0,63}$/i.test(key)) return [];
|
|
397
|
+
const result = usageNumbers(item, depth + 1);
|
|
398
|
+
return result === void 0 ? [] : [[key, result]];
|
|
399
|
+
});
|
|
400
|
+
return entries.length ? Object.fromEntries(entries) : void 0;
|
|
401
|
+
}
|
|
402
|
+
async function generate(store, options) {
|
|
403
|
+
if (!options.prompt.trim()) throw new ImgRelayError("input_error", "提示词不能为空");
|
|
404
|
+
if (options.timeoutSeconds !== void 0 && (!Number.isFinite(options.timeoutSeconds) || options.timeoutSeconds <= 0 || options.timeoutSeconds > 2147483)) throw new ImgRelayError("input_error", "超时必须是有效的正数秒数");
|
|
405
|
+
if (options.filename && (basename(options.filename) !== options.filename || /[<>:"/\\|?*]/.test(options.filename) || options.filename.split("").some((character) => character.charCodeAt(0) < 32) || !parse(options.filename).name || [".", ".."].includes(options.filename))) throw new ImgRelayError("input_error", "--filename 必须是普通文件名,保存目录使用 --output-dir");
|
|
406
|
+
const selected = await store.selected(options.provider);
|
|
407
|
+
const model = (options.model || selected.provider.defaultModel || "").trim();
|
|
408
|
+
if (!model) throw new ImgRelayError("config_error", "未配置默认模型,请通过 --model 指定或运行 provider edit");
|
|
409
|
+
const key = await store.key(selected.provider);
|
|
410
|
+
const outputDir = resolve(options.outputDir || "output/imagegen");
|
|
411
|
+
const requestId = randomUUID();
|
|
412
|
+
const recordPath = join(outputDir, `${requestId}-report.json`);
|
|
413
|
+
const supplied = {};
|
|
414
|
+
for (const name of [
|
|
415
|
+
"size",
|
|
416
|
+
"n",
|
|
417
|
+
"quality",
|
|
418
|
+
"outputFormat",
|
|
419
|
+
"responseFormat"
|
|
420
|
+
]) {
|
|
421
|
+
const value = options[name];
|
|
422
|
+
if (value !== void 0) Object.assign(supplied, { [name]: value });
|
|
423
|
+
}
|
|
424
|
+
const data = {
|
|
425
|
+
requestId,
|
|
426
|
+
provider: selected.name,
|
|
427
|
+
requestedModel: model,
|
|
428
|
+
requestedCount: supplied.n || selected.provider.defaults.n || 1,
|
|
429
|
+
returnedCount: 0,
|
|
430
|
+
parameters: {
|
|
431
|
+
...selected.provider.defaults,
|
|
432
|
+
...supplied
|
|
433
|
+
},
|
|
434
|
+
endpoint: resolveEndpoint(selected.provider.baseUrl, selected.provider.endpoints.generate || "/v1/images/generations"),
|
|
435
|
+
capabilityEvidence: {
|
|
436
|
+
known: Object.hasOwn(KNOWN_MODEL_CAPABILITIES, model) ? KNOWN_MODEL_CAPABILITIES[model] || null : null,
|
|
437
|
+
provider: Object.hasOwn(selected.provider.modelOverrides, model) ? selected.provider.modelOverrides[model] || null : null
|
|
438
|
+
},
|
|
439
|
+
images: [],
|
|
440
|
+
failures: [],
|
|
441
|
+
recordPath,
|
|
442
|
+
submissionStatus: "not_submitted"
|
|
443
|
+
};
|
|
444
|
+
const record = {
|
|
445
|
+
schemaVersion: 1,
|
|
446
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
447
|
+
prompt: options.prompt,
|
|
448
|
+
...data
|
|
449
|
+
};
|
|
450
|
+
try {
|
|
451
|
+
await mkdir(outputDir, { recursive: true });
|
|
452
|
+
if (options.filename && !options.overwrite) try {
|
|
453
|
+
await access(join(outputDir, options.filename));
|
|
454
|
+
throw new ImgRelayError("save_error", "目标文件已存在,请使用其他文件名或显式 --overwrite", 4);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
if (!isNotFound(error)) throw error;
|
|
457
|
+
}
|
|
458
|
+
await writeFile(recordPath, `${JSON.stringify(store.redactor.value({
|
|
459
|
+
...record,
|
|
460
|
+
status: "prepared"
|
|
461
|
+
}), null, 2)}\n`, {
|
|
462
|
+
flag: "wx",
|
|
463
|
+
mode: 384
|
|
464
|
+
});
|
|
465
|
+
} catch (error) {
|
|
466
|
+
if (error instanceof ImgRelayError) throw error;
|
|
467
|
+
throw new ImgRelayError("save_error", "无法创建输出目录或请求记录;未提交生成请求", 4);
|
|
468
|
+
}
|
|
469
|
+
let outcome;
|
|
470
|
+
try {
|
|
471
|
+
data.parameters = resolveParameters(selected.provider, model, supplied);
|
|
472
|
+
const body = {
|
|
473
|
+
model,
|
|
474
|
+
prompt: options.prompt
|
|
475
|
+
};
|
|
476
|
+
for (const [name, value] of Object.entries(data.parameters)) {
|
|
477
|
+
const wire = {
|
|
478
|
+
outputFormat: "output_format",
|
|
479
|
+
responseFormat: "response_format"
|
|
480
|
+
}[name] || name;
|
|
481
|
+
body[wire] = value;
|
|
482
|
+
}
|
|
483
|
+
if (options.signal?.aborted) throw new ImgRelayError("interrupted", "用户中断,未提交生成请求", 130);
|
|
484
|
+
options.progress?.("正在等待生图接口,生成请求只提交一次…");
|
|
485
|
+
data.submissionStatus = "submitted";
|
|
486
|
+
const response = await requestJson({
|
|
487
|
+
url: data.endpoint,
|
|
488
|
+
key,
|
|
489
|
+
redactor: store.redactor,
|
|
490
|
+
method: "POST",
|
|
491
|
+
body,
|
|
492
|
+
timeoutSeconds: options.timeoutSeconds || selected.provider.timeoutSeconds,
|
|
493
|
+
...options.signal ? { signal: options.signal } : {}
|
|
494
|
+
});
|
|
495
|
+
data.submissionStatus = "result_received";
|
|
496
|
+
if (!response || typeof response !== "object" || !("data" in response) || !Array.isArray(response.data) || response.data.length === 0) throw new ImgRelayError("invalid_response", "上游响应没有有效的 data[] 图片结果", 3);
|
|
497
|
+
if ("model" in response && typeof response.model === "string") data.reportedModel = response.model;
|
|
498
|
+
if ("usage" in response) {
|
|
499
|
+
const usage = usageNumbers(response.usage);
|
|
500
|
+
if (usage !== void 0) data.usage = usage;
|
|
501
|
+
}
|
|
502
|
+
data.returnedCount = response.data.length;
|
|
503
|
+
for (const [offset, item] of response.data.entries()) {
|
|
504
|
+
const index = offset + 1;
|
|
505
|
+
try {
|
|
506
|
+
if (options.signal?.aborted) throw new ImgRelayError("interrupted", "用户中断保存,已保存的文件保持不变", 130);
|
|
507
|
+
if (!item || typeof item !== "object") throw new ImgRelayError("invalid_response", "图片条目不是对象", 3);
|
|
508
|
+
let bytes;
|
|
509
|
+
if ("b64_json" in item && typeof item.b64_json === "string" && item.b64_json) bytes = base64Bytes(item.b64_json);
|
|
510
|
+
else if ("url" in item && typeof item.url === "string" && item.url) bytes = await download(item.url, options.signal);
|
|
511
|
+
else throw new ImgRelayError("invalid_response", "图片条目缺少 b64_json 或 url", 3);
|
|
512
|
+
let dimensions;
|
|
513
|
+
let format;
|
|
514
|
+
try {
|
|
515
|
+
format = await fileTypeFromBuffer(bytes);
|
|
516
|
+
dimensions = imageSize(bytes);
|
|
517
|
+
if (!format?.mime.startsWith("image/") || !dimensions.width || !dimensions.height) throw new Error("Invalid image");
|
|
518
|
+
} catch {
|
|
519
|
+
throw new ImgRelayError("image_decode_error", "返回数据不是可识别的有效图片", 3);
|
|
520
|
+
}
|
|
521
|
+
const stem = options.filename ? parse(options.filename).name : requestId;
|
|
522
|
+
const sequence = !options.filename || response.data.length > 1 ? `-${String(index).padStart(2, "0")}` : "";
|
|
523
|
+
const path = join(outputDir, `${stem}${sequence}.${format.ext}`);
|
|
524
|
+
const handle = await open(path, options.overwrite ? "w" : "wx", 384).catch(() => {
|
|
525
|
+
throw new ImgRelayError("save_error", "无法保存图片;请检查文件重名和目录权限", 4);
|
|
526
|
+
});
|
|
527
|
+
try {
|
|
528
|
+
await handle.writeFile(bytes);
|
|
529
|
+
await handle.sync();
|
|
530
|
+
} catch {
|
|
531
|
+
await handle.close().catch(() => {});
|
|
532
|
+
await unlink(path).catch(() => {});
|
|
533
|
+
throw new ImgRelayError("save_error", "图片写入失败;不会重新提交生成", 4);
|
|
534
|
+
} finally {
|
|
535
|
+
await handle.close().catch(() => {});
|
|
536
|
+
}
|
|
537
|
+
data.images.push({
|
|
538
|
+
index,
|
|
539
|
+
path,
|
|
540
|
+
format: format.ext,
|
|
541
|
+
width: dimensions.width,
|
|
542
|
+
height: dimensions.height,
|
|
543
|
+
bytes: bytes.length
|
|
544
|
+
});
|
|
545
|
+
options.progress?.(`已保存 ${index}/${data.returnedCount} 张图片`);
|
|
546
|
+
} catch (error) {
|
|
547
|
+
data.failures.push({
|
|
548
|
+
index,
|
|
549
|
+
error: store.redactor.error(error)
|
|
550
|
+
});
|
|
551
|
+
if (error instanceof ImgRelayError && error.category === "interrupted") throw error;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const failed = data.failures.length > 0;
|
|
555
|
+
const error = data.failures[0]?.error;
|
|
556
|
+
outcome = data.images.length ? {
|
|
557
|
+
status: failed ? "partial_success" : "success",
|
|
558
|
+
exitCode: failed ? 5 : 0,
|
|
559
|
+
data
|
|
560
|
+
} : {
|
|
561
|
+
status: "error",
|
|
562
|
+
exitCode: data.failures.some((failure) => failure.error.category === "save_error") ? 4 : 3,
|
|
563
|
+
data,
|
|
564
|
+
...error ? { error } : {}
|
|
565
|
+
};
|
|
566
|
+
} catch (error) {
|
|
567
|
+
const info = store.redactor.error(error);
|
|
568
|
+
if (info.category === "result_unknown") data.submissionStatus = "unknown";
|
|
569
|
+
outcome = {
|
|
570
|
+
status: info.category === "result_unknown" ? "unknown" : info.category === "interrupted" ? "interrupted" : "error",
|
|
571
|
+
exitCode: error instanceof ImgRelayError ? error.exitCode : 1,
|
|
572
|
+
data,
|
|
573
|
+
error: info
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
try {
|
|
577
|
+
await writeAtomic(recordPath, store.redactor.value({
|
|
578
|
+
...record,
|
|
579
|
+
...data,
|
|
580
|
+
status: outcome.status,
|
|
581
|
+
error: outcome.error || null,
|
|
582
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
583
|
+
}));
|
|
584
|
+
} catch {
|
|
585
|
+
data.recordPath = null;
|
|
586
|
+
return {
|
|
587
|
+
status: "error",
|
|
588
|
+
exitCode: 4,
|
|
589
|
+
data,
|
|
590
|
+
error: {
|
|
591
|
+
category: "save_error",
|
|
592
|
+
message: "生成记录保存失败;已保存的图片路径仍可使用,不会重新生成"
|
|
593
|
+
}
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
return outcome;
|
|
597
|
+
}
|
|
598
|
+
const modelsResponse = z.object({ data: z.array(z.object({
|
|
599
|
+
id: z.string().min(1),
|
|
600
|
+
type: z.string().optional()
|
|
601
|
+
})) });
|
|
602
|
+
const cachedModels = z.object({
|
|
603
|
+
fetchedAt: z.iso.datetime(),
|
|
604
|
+
models: modelsResponse.shape.data,
|
|
605
|
+
url: z.string(),
|
|
606
|
+
revision: z.number()
|
|
607
|
+
});
|
|
608
|
+
async function discoverModels(provider, key, redactor, signal) {
|
|
609
|
+
const url = resolveEndpoint(provider.baseUrl, provider.endpoints.models || "/v1/models");
|
|
610
|
+
const response = modelsResponse.safeParse(await requestJson({
|
|
611
|
+
url,
|
|
612
|
+
key,
|
|
613
|
+
redactor,
|
|
614
|
+
retry: true,
|
|
615
|
+
...signal ? { signal } : {}
|
|
616
|
+
}));
|
|
617
|
+
if (!response.success) throw new ImgRelayError("invalid_response", "模型接口未返回 data[].id;仍可手动配置模型后生成", 3);
|
|
618
|
+
return response.data.data;
|
|
619
|
+
}
|
|
620
|
+
async function models(store, options = {}) {
|
|
621
|
+
const selected = await store.selected(options.provider);
|
|
622
|
+
const key = await store.key(selected.provider);
|
|
623
|
+
const url = resolveEndpoint(selected.provider.baseUrl, selected.provider.endpoints.models || "/v1/models");
|
|
624
|
+
const cacheKey = createHash("sha256").update(JSON.stringify([
|
|
625
|
+
selected.name,
|
|
626
|
+
url,
|
|
627
|
+
selected.provider.revision,
|
|
628
|
+
key
|
|
629
|
+
])).digest("hex");
|
|
630
|
+
if (!options.refresh) {
|
|
631
|
+
const cache = z.record(z.string(), z.unknown()).safeParse(await store.cache());
|
|
632
|
+
const cached = cachedModels.safeParse(cache.success ? cache.data[cacheKey] : void 0);
|
|
633
|
+
if (cached.success && Date.now() - Date.parse(cached.data.fetchedAt) >= 0 && Date.now() - Date.parse(cached.data.fetchedAt) < 9e5) return {
|
|
634
|
+
...cached.data,
|
|
635
|
+
provider: selected.name,
|
|
636
|
+
cached: true
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
const discovered = await discoverModels(selected.provider, key, store.redactor, options.signal);
|
|
640
|
+
const result = {
|
|
641
|
+
fetchedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
642
|
+
models: discovered,
|
|
643
|
+
url,
|
|
644
|
+
revision: selected.provider.revision
|
|
645
|
+
};
|
|
646
|
+
await store.saveCache(cacheKey, result);
|
|
647
|
+
return {
|
|
648
|
+
...result,
|
|
649
|
+
provider: selected.name,
|
|
650
|
+
cached: false
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
async function doctor(store, options = {}) {
|
|
654
|
+
let selected;
|
|
655
|
+
try {
|
|
656
|
+
selected = await store.selected(options.provider);
|
|
657
|
+
} catch (error) {
|
|
658
|
+
const info = store.redactor.error(error);
|
|
659
|
+
return {
|
|
660
|
+
exitCode: error instanceof ImgRelayError ? error.exitCode : 1,
|
|
661
|
+
error: info,
|
|
662
|
+
data: {
|
|
663
|
+
provider: options.provider || null,
|
|
664
|
+
endpoints: null,
|
|
665
|
+
configuration: "invalid",
|
|
666
|
+
authentication: "not_checked",
|
|
667
|
+
modelDiscovery: { status: "not_checked" },
|
|
668
|
+
generation: "not_verified"
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
const endpoints = {
|
|
673
|
+
generate: resolveEndpoint(selected.provider.baseUrl, selected.provider.endpoints.generate || "/v1/images/generations"),
|
|
674
|
+
models: resolveEndpoint(selected.provider.baseUrl, selected.provider.endpoints.models || "/v1/models")
|
|
675
|
+
};
|
|
676
|
+
try {
|
|
677
|
+
const result = await models(store, {
|
|
678
|
+
...options,
|
|
679
|
+
refresh: true
|
|
680
|
+
});
|
|
681
|
+
return {
|
|
682
|
+
exitCode: 0,
|
|
683
|
+
data: {
|
|
684
|
+
provider: selected.name,
|
|
685
|
+
endpoints,
|
|
686
|
+
configuration: "valid",
|
|
687
|
+
authentication: "verified_for_models",
|
|
688
|
+
modelDiscovery: result,
|
|
689
|
+
generation: "not_verified",
|
|
690
|
+
defaultModelListed: selected.provider.defaultModel ? result.models.some((model) => model.id === selected.provider.defaultModel) : null
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (error instanceof ImgRelayError && error.category === "interrupted") throw error;
|
|
695
|
+
const info = store.redactor.error(error);
|
|
696
|
+
return {
|
|
697
|
+
exitCode: error instanceof ImgRelayError ? error.exitCode : 1,
|
|
698
|
+
error: info,
|
|
699
|
+
data: {
|
|
700
|
+
provider: selected.name,
|
|
701
|
+
endpoints,
|
|
702
|
+
configuration: "valid",
|
|
703
|
+
authentication: info.category === "auth_error" ? "failed" : "unknown",
|
|
704
|
+
modelDiscovery: {
|
|
705
|
+
status: "unavailable",
|
|
706
|
+
error: info
|
|
707
|
+
},
|
|
708
|
+
generation: "not_verified",
|
|
709
|
+
message: "模型列表不可用时,仍可手动设置模型;生成能力尚未验证"
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
function defaultConfigDirectory() {
|
|
715
|
+
if (process.env.IMGRELAY_CONFIG_DIR) return resolve(process.env.IMGRELAY_CONFIG_DIR);
|
|
716
|
+
return join(homedir(), ".imgrelay");
|
|
717
|
+
}
|
|
718
|
+
var Store = class {
|
|
719
|
+
directory;
|
|
720
|
+
redactor = new Redactor();
|
|
721
|
+
initialized = false;
|
|
722
|
+
constructor(directory = defaultConfigDirectory()) {
|
|
723
|
+
this.directory = resolve(directory);
|
|
724
|
+
if (this.directory === parse(this.directory).root) throw new ImgRelayError("config_error", "配置目录不能是文件系统根目录");
|
|
725
|
+
}
|
|
726
|
+
async config() {
|
|
727
|
+
try {
|
|
728
|
+
const raw = await readJson(join(this.directory, "config.json"));
|
|
729
|
+
if (raw && typeof raw === "object" && "schemaVersion" in raw && raw.schemaVersion !== 1) throw new ImgRelayError("config_error", "不支持此配置版本;当前仅支持 schemaVersion=1");
|
|
730
|
+
const result = configSchema.safeParse(raw);
|
|
731
|
+
if (!result.success) throw new ImgRelayError("config_error", "配置文件结构或版本无效;请检查 config.json");
|
|
732
|
+
for (const provider of Object.values(result.data.providers)) if (provider.apiKey) this.redactor.add(provider.apiKey);
|
|
733
|
+
return result.data;
|
|
734
|
+
} catch (error) {
|
|
735
|
+
if (isNotFound(error)) return {
|
|
736
|
+
schemaVersion: 1,
|
|
737
|
+
providers: {}
|
|
738
|
+
};
|
|
739
|
+
if (error instanceof ImgRelayError) throw error;
|
|
740
|
+
throw new ImgRelayError("config_error", "无法读取配置文件;请检查文件格式及访问权限");
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
async selected(name) {
|
|
744
|
+
const config = await this.config();
|
|
745
|
+
const selected = name || config.defaultProvider;
|
|
746
|
+
if (!selected) throw new ImgRelayError("config_error", "未设置默认供应商,请运行 imgrelay provider use <名称>");
|
|
747
|
+
const provider = Object.hasOwn(config.providers, selected) ? config.providers[selected] : void 0;
|
|
748
|
+
if (!provider) throw new ImgRelayError("config_error", `供应商 ${selected} 不存在,请运行 imgrelay provider add ${selected}`);
|
|
749
|
+
return {
|
|
750
|
+
name: selected,
|
|
751
|
+
provider
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
async initialize() {
|
|
755
|
+
if (!this.initialized) {
|
|
756
|
+
await mkdir(this.directory, { recursive: true });
|
|
757
|
+
this.initialized = true;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
async locked(operation) {
|
|
761
|
+
await this.initialize();
|
|
762
|
+
const lock = join(this.directory, ".write-lock");
|
|
763
|
+
const owner = join(lock, "owner.json");
|
|
764
|
+
const deadline = Date.now() + 1e4;
|
|
765
|
+
for (;;) try {
|
|
766
|
+
await mkdir(lock);
|
|
767
|
+
break;
|
|
768
|
+
} catch (error) {
|
|
769
|
+
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
|
|
770
|
+
try {
|
|
771
|
+
const data = z.object({ pid: z.number().int().positive() }).parse(await readJson(owner));
|
|
772
|
+
try {
|
|
773
|
+
process.kill(data.pid, 0);
|
|
774
|
+
} catch (checkError) {
|
|
775
|
+
if (checkError instanceof Error && "code" in checkError && checkError.code === "ESRCH") {
|
|
776
|
+
await unlink(owner);
|
|
777
|
+
await rmdir(lock);
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
} catch {
|
|
782
|
+
try {
|
|
783
|
+
if (Date.now() - (await lstat(lock)).mtimeMs > 6e4) {
|
|
784
|
+
await unlink(owner).catch(() => {});
|
|
785
|
+
await rmdir(lock);
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
} catch {}
|
|
789
|
+
}
|
|
790
|
+
if (Date.now() >= deadline) throw new ImgRelayError("config_busy", "配置正在被其他进程修改,请稍后重试");
|
|
791
|
+
await setTimeout(50);
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
await writeFile(owner, JSON.stringify({ pid: process.pid }));
|
|
795
|
+
return await operation();
|
|
796
|
+
} finally {
|
|
797
|
+
await unlink(owner).catch(() => {});
|
|
798
|
+
await rmdir(lock).catch(() => {});
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
async key(provider) {
|
|
802
|
+
const key = provider.apiKey;
|
|
803
|
+
if (!key) throw new ImgRelayError("credential_error", "未配置 API 密钥,请运行 imgrelay provider edit <名称>");
|
|
804
|
+
if (/[\r\n\0]/.test(key)) throw new ImgRelayError("credential_error", "密钥不能包含换行或空字符");
|
|
805
|
+
this.redactor.add(key);
|
|
806
|
+
return key;
|
|
807
|
+
}
|
|
808
|
+
async put(name, input, options = {}) {
|
|
809
|
+
if (!providerNameSchema.safeParse(name).success) throw new ImgRelayError("input_error", "供应商名称只能包含字母、数字、下划线和连字符");
|
|
810
|
+
if (input.apiKey) this.redactor.add(input.apiKey);
|
|
811
|
+
if (options.secret) this.redactor.add(options.secret);
|
|
812
|
+
const proposed = parseProvider({
|
|
813
|
+
...input,
|
|
814
|
+
...options.secret !== void 0 ? { apiKey: options.secret } : {}
|
|
815
|
+
});
|
|
816
|
+
if (proposed.apiKey) this.redactor.add(proposed.apiKey);
|
|
817
|
+
return this.locked(async () => {
|
|
818
|
+
const config = await this.config();
|
|
819
|
+
const previous = Object.hasOwn(config.providers, name) ? config.providers[name] : void 0;
|
|
820
|
+
if (options.edit ? !previous : previous) throw new ImgRelayError("config_error", options.edit ? "供应商不存在" : "供应商已存在,请使用 provider edit");
|
|
821
|
+
if (options.expectedRevision !== void 0 && previous?.revision !== options.expectedRevision) throw new ImgRelayError("config_busy", "配置已被其他进程修改,请重新执行编辑");
|
|
822
|
+
proposed.revision = (previous?.revision || 0) + 1;
|
|
823
|
+
config.providers[name] = proposed;
|
|
824
|
+
if (options.makeDefault) config.defaultProvider = name;
|
|
825
|
+
await writeAtomic(join(this.directory, "config.json"), config);
|
|
826
|
+
return proposed;
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
async use(name) {
|
|
830
|
+
await this.locked(async () => {
|
|
831
|
+
const config = await this.config();
|
|
832
|
+
if (!Object.hasOwn(config.providers, name)) throw new ImgRelayError("config_error", "供应商不存在");
|
|
833
|
+
config.defaultProvider = name;
|
|
834
|
+
await writeAtomic(join(this.directory, "config.json"), config);
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
async remove(name) {
|
|
838
|
+
await this.locked(async () => {
|
|
839
|
+
const config = await this.config();
|
|
840
|
+
if (!Object.hasOwn(config.providers, name)) throw new ImgRelayError("config_error", "供应商不存在");
|
|
841
|
+
delete config.providers[name];
|
|
842
|
+
if (config.defaultProvider === name) delete config.defaultProvider;
|
|
843
|
+
await writeAtomic(join(this.directory, "config.json"), config);
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
async cache() {
|
|
847
|
+
try {
|
|
848
|
+
return await readJson(join(this.directory, "models-cache.json"));
|
|
849
|
+
} catch {
|
|
850
|
+
return {};
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
async saveCache(key, value) {
|
|
854
|
+
await this.locked(async () => {
|
|
855
|
+
const parsed = z.record(z.string(), z.unknown()).safeParse(await this.cache());
|
|
856
|
+
const cache = parsed.success ? parsed.data : {};
|
|
857
|
+
cache[key] = value;
|
|
858
|
+
const entries = Object.entries(cache).slice(-100);
|
|
859
|
+
await writeAtomic(join(this.directory, "models-cache.json"), Object.fromEntries(entries));
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
//#endregion
|
|
864
|
+
//#region package.json
|
|
865
|
+
var version = "0.2.0";
|
|
866
|
+
//#endregion
|
|
867
|
+
//#region src/provider.ts
|
|
868
|
+
function answer(value) {
|
|
869
|
+
if (typeof value === "symbol") throw new ImgRelayError("interrupted", "用户取消配置,未保存修改", 130);
|
|
870
|
+
return value;
|
|
871
|
+
}
|
|
872
|
+
function providerOptions(command) {
|
|
873
|
+
return command.option("--base-url <URL>", "API 基础地址,推荐包含 /v1").option("--protocol <协议>", "首版支持 openai-images").option("--preset <名称>", "使用已验证配置预设:cubence").option("--model <ID>", "默认模型,允许手动填写").option("--clear-key", "清除配置中保存的密钥").option("--default", "保存后设为默认供应商").option("--size <尺寸>", "默认尺寸").option("--n <数量>", "默认生成数量").option("--quality <质量>", "默认质量").option("--output-format <格式>", "默认图片格式").option("--response-format <格式>", "供应商要求的返回格式:url 或 b64_json").option("--generation-path <路径>", "生成端点覆盖").option("--models-path <路径>", "模型端点覆盖").option("--capabilities-file <文件>", "按模型的能力覆盖 JSON 文件").option("--timeout <秒数>", "默认生成超时");
|
|
874
|
+
}
|
|
875
|
+
async function configureProvider(store, name, flags, interactive, signal, existing) {
|
|
876
|
+
if (flags.preset && flags.preset !== "cubence") throw new ImgRelayError("input_error", "未知预设;当前提供 cubence");
|
|
877
|
+
if (!existing && !interactive) throw new ImgRelayError("config_error", "新增供应商需要隐藏输入 API 密钥,请在交互终端不带 --json 运行 provider add");
|
|
878
|
+
const input = {
|
|
879
|
+
protocol: "openai-images",
|
|
880
|
+
baseUrl: "",
|
|
881
|
+
...flags.preset ? CUBENCE_PRESET : {},
|
|
882
|
+
...existing
|
|
883
|
+
};
|
|
884
|
+
if (flags.protocol) {
|
|
885
|
+
if (flags.protocol !== "openai-images") throw new ImgRelayError("config_error", "首版仅支持 openai-images 协议");
|
|
886
|
+
input.protocol = flags.protocol;
|
|
887
|
+
}
|
|
888
|
+
if (flags.baseUrl !== void 0) input.baseUrl = flags.baseUrl;
|
|
889
|
+
if (flags.model !== void 0) input.defaultModel = flags.model;
|
|
890
|
+
input.defaults = { ...input.defaults };
|
|
891
|
+
input.endpoints = { ...input.endpoints };
|
|
892
|
+
if (flags.size !== void 0) input.defaults.size = flags.size;
|
|
893
|
+
if (flags.n !== void 0) input.defaults.n = Number(flags.n);
|
|
894
|
+
if (flags.quality !== void 0) input.defaults.quality = flags.quality;
|
|
895
|
+
if (flags.outputFormat !== void 0) input.defaults.outputFormat = flags.outputFormat;
|
|
896
|
+
if (flags.responseFormat !== void 0) {
|
|
897
|
+
if (flags.responseFormat !== "url" && flags.responseFormat !== "b64_json") throw new ImgRelayError("input_error", "返回格式只能是 url 或 b64_json");
|
|
898
|
+
input.defaults.responseFormat = flags.responseFormat;
|
|
899
|
+
}
|
|
900
|
+
if (flags.generationPath !== void 0) input.endpoints.generate = flags.generationPath;
|
|
901
|
+
if (flags.modelsPath !== void 0) input.endpoints.models = flags.modelsPath;
|
|
902
|
+
if (flags.timeout !== void 0) input.timeoutSeconds = Number(flags.timeout);
|
|
903
|
+
if (flags.capabilitiesFile) try {
|
|
904
|
+
const modelOverrides = JSON.parse(await readFile(resolve(flags.capabilitiesFile), "utf8"));
|
|
905
|
+
input.modelOverrides = parseProvider({
|
|
906
|
+
...input,
|
|
907
|
+
modelOverrides
|
|
908
|
+
}).modelOverrides;
|
|
909
|
+
} catch {
|
|
910
|
+
throw new ImgRelayError("input_error", "能力覆盖文件必须是可读的 UTF-8 JSON");
|
|
911
|
+
}
|
|
912
|
+
let secret;
|
|
913
|
+
if (flags.clearKey) delete input.apiKey;
|
|
914
|
+
if (interactive) {
|
|
915
|
+
prompts.intro(existing ? `编辑供应商 ${name}` : `添加供应商 ${name}`);
|
|
916
|
+
if (flags.baseUrl === void 0) input.baseUrl = answer(await prompts.text({
|
|
917
|
+
message: "API 基础地址",
|
|
918
|
+
initialValue: input.baseUrl,
|
|
919
|
+
validate: (value) => value?.trim() ? void 0 : "请输入地址"
|
|
920
|
+
}));
|
|
921
|
+
if (!flags.clearKey) {
|
|
922
|
+
secret = answer(await prompts.password({
|
|
923
|
+
message: input.apiKey ? "API 密钥(留空保留当前密钥)" : "API 密钥",
|
|
924
|
+
validate: (value) => value?.trim() || input.apiKey ? void 0 : "密钥不能为空"
|
|
925
|
+
})).trim() || void 0;
|
|
926
|
+
if (secret) store.redactor.add(secret);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (!existing && !secret) throw new ImgRelayError("config_error", "新增供应商需要非空 API 密钥");
|
|
930
|
+
if (interactive && flags.model === void 0) {
|
|
931
|
+
let ids = [];
|
|
932
|
+
try {
|
|
933
|
+
const key = secret || await store.key(input);
|
|
934
|
+
ids = (await discoverModels(parseProvider(input), key, store.redactor, signal)).map((model) => model.id);
|
|
935
|
+
} catch (error) {
|
|
936
|
+
if (error instanceof ImgRelayError && error.category === "interrupted") throw error;
|
|
937
|
+
prompts.log.warn("未能查询模型列表,可以手动填写模型 ID;生成能力尚未验证");
|
|
938
|
+
}
|
|
939
|
+
const choice = ids.length ? answer(await prompts.select({
|
|
940
|
+
message: "默认模型",
|
|
941
|
+
options: [...ids.map((id) => ({
|
|
942
|
+
value: id,
|
|
943
|
+
label: store.redactor.text(id)
|
|
944
|
+
})), {
|
|
945
|
+
value: "\0manual",
|
|
946
|
+
label: "手动填写模型 ID"
|
|
947
|
+
}]
|
|
948
|
+
})) : "\0manual";
|
|
949
|
+
input.defaultModel = choice === "\0manual" ? answer(await prompts.text({
|
|
950
|
+
message: "默认模型(留空可稍后设置)",
|
|
951
|
+
initialValue: input.defaultModel || ""
|
|
952
|
+
})) : choice;
|
|
953
|
+
if (!input.defaultModel) delete input.defaultModel;
|
|
954
|
+
}
|
|
955
|
+
if (interactive && answer(await prompts.confirm({
|
|
956
|
+
message: "设置尺寸、数量、质量和格式的默认值?",
|
|
957
|
+
initialValue: false
|
|
958
|
+
}))) for (const [field, label] of [
|
|
959
|
+
["size", "尺寸"],
|
|
960
|
+
["n", "数量"],
|
|
961
|
+
["quality", "质量"],
|
|
962
|
+
["outputFormat", "图片格式"]
|
|
963
|
+
]) {
|
|
964
|
+
const value = answer(await prompts.text({
|
|
965
|
+
message: `${label}(留空表示不设置)`,
|
|
966
|
+
initialValue: String(input.defaults[field] || "")
|
|
967
|
+
}));
|
|
968
|
+
if (value) Object.assign(input.defaults, { [field]: field === "n" ? Number(value) : value });
|
|
969
|
+
else delete input.defaults[field];
|
|
970
|
+
}
|
|
971
|
+
const makeDefault = flags.default || (interactive ? answer(await prompts.confirm({
|
|
972
|
+
message: "设为默认供应商?",
|
|
973
|
+
initialValue: !(await store.config()).defaultProvider
|
|
974
|
+
})) : false);
|
|
975
|
+
const saved = await store.put(name, input, {
|
|
976
|
+
...existing ? {
|
|
977
|
+
edit: true,
|
|
978
|
+
expectedRevision: existing.revision
|
|
979
|
+
} : {},
|
|
980
|
+
...secret ? { secret } : {},
|
|
981
|
+
makeDefault
|
|
982
|
+
});
|
|
983
|
+
if (interactive) prompts.outro("配置已保存;此流程没有生成图片");
|
|
984
|
+
return saved;
|
|
985
|
+
}
|
|
986
|
+
//#endregion
|
|
987
|
+
//#region src/index.ts
|
|
988
|
+
const json = process.argv.includes("--json");
|
|
989
|
+
const controller = new AbortController();
|
|
990
|
+
process.once("SIGINT", () => controller.abort());
|
|
991
|
+
process.once("SIGTERM", () => controller.abort());
|
|
992
|
+
let commandName = "imgrelay";
|
|
993
|
+
let outputWritten = false;
|
|
994
|
+
let helpOutput = "";
|
|
995
|
+
let store;
|
|
996
|
+
function output(data, options = {}) {
|
|
997
|
+
if (outputWritten) return;
|
|
998
|
+
outputWritten = true;
|
|
999
|
+
const envelope = {
|
|
1000
|
+
schemaVersion: 1,
|
|
1001
|
+
command: commandName,
|
|
1002
|
+
status: options.status || "success",
|
|
1003
|
+
data,
|
|
1004
|
+
error: options.error || null
|
|
1005
|
+
};
|
|
1006
|
+
const safe = store ? store.redactor.value(envelope) : envelope;
|
|
1007
|
+
if (json) process.stdout.write(`${JSON.stringify(safe)}\n`);
|
|
1008
|
+
else {
|
|
1009
|
+
if (options.error) process.stderr.write(`${store?.redactor.text(options.error.message) || options.error.message}\n`);
|
|
1010
|
+
if (data !== null) process.stdout.write(`${JSON.stringify(store ? store.redactor.value(data) : data, null, 2)}\n`);
|
|
1011
|
+
}
|
|
1012
|
+
process.exitCode = options.exitCode || 0;
|
|
1013
|
+
}
|
|
1014
|
+
function active(name) {
|
|
1015
|
+
commandName = name;
|
|
1016
|
+
store ||= new Store();
|
|
1017
|
+
return store;
|
|
1018
|
+
}
|
|
1019
|
+
const program = new Command().name("imgrelay").description("通过统一入口调用 OpenAI Images 兼容中转站").version(version).option("--json", "输出一个最终 JSON 对象,禁用交互输入").exitOverride().configureOutput({
|
|
1020
|
+
writeOut: (value) => {
|
|
1021
|
+
if (json) helpOutput += value;
|
|
1022
|
+
else process.stdout.write(value);
|
|
1023
|
+
},
|
|
1024
|
+
writeErr: (value) => {
|
|
1025
|
+
if (!json) process.stderr.write(value);
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
const provider = program.command("provider").description("管理供应商配置与凭据");
|
|
1029
|
+
providerOptions(provider.command("add <名称>").description("添加供应商")).action(async (name, flags) => {
|
|
1030
|
+
output({
|
|
1031
|
+
provider: name,
|
|
1032
|
+
configuration: await configureProvider(active("provider add"), name, flags, !json && process.stdin.isTTY && process.stdout.isTTY, controller.signal)
|
|
1033
|
+
});
|
|
1034
|
+
});
|
|
1035
|
+
providerOptions(provider.command("edit <名称>").description("修改供应商")).action(async (name, flags) => {
|
|
1036
|
+
const current = active("provider edit");
|
|
1037
|
+
const existing = await current.selected(name);
|
|
1038
|
+
output({
|
|
1039
|
+
provider: name,
|
|
1040
|
+
configuration: await configureProvider(current, name, flags, !json && process.stdin.isTTY && process.stdout.isTTY, controller.signal, existing.provider)
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
provider.command("list").description("列出供应商摘要").action(async () => {
|
|
1044
|
+
const config = await active("provider list").config();
|
|
1045
|
+
output({
|
|
1046
|
+
defaultProvider: config.defaultProvider || null,
|
|
1047
|
+
providers: Object.entries(config.providers).map(([name, entry]) => ({
|
|
1048
|
+
name,
|
|
1049
|
+
protocol: entry.protocol,
|
|
1050
|
+
baseUrl: entry.baseUrl,
|
|
1051
|
+
defaultModel: entry.defaultModel || null,
|
|
1052
|
+
credentialSource: entry.apiKey ? "config" : null,
|
|
1053
|
+
default: name === config.defaultProvider
|
|
1054
|
+
}))
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
provider.command("show <名称>").description("查看脱敏配置").action(async (name) => {
|
|
1058
|
+
output({
|
|
1059
|
+
provider: name,
|
|
1060
|
+
configuration: (await active("provider show").selected(name)).provider
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
provider.command("use <名称>").description("设置默认供应商").action(async (name) => {
|
|
1064
|
+
await active("provider use").use(name);
|
|
1065
|
+
output({ defaultProvider: name });
|
|
1066
|
+
});
|
|
1067
|
+
provider.command("remove <名称>").description("移除供应商及其已保存的密钥").action(async (name) => {
|
|
1068
|
+
await active("provider remove").remove(name);
|
|
1069
|
+
output({ removed: name });
|
|
1070
|
+
});
|
|
1071
|
+
program.command("models").description("查询模型列表或查看 15 分钟缓存").option("--provider <名称>", "本次使用的供应商").option("--refresh", "强制刷新模型列表").action(async (flags) => {
|
|
1072
|
+
output(await models(active("models"), {
|
|
1073
|
+
...flags,
|
|
1074
|
+
signal: controller.signal
|
|
1075
|
+
}));
|
|
1076
|
+
});
|
|
1077
|
+
program.command("doctor").description("检查配置与模型接口,不生成图片").option("--provider <名称>", "检查指定供应商").action(async (flags) => {
|
|
1078
|
+
const result = await doctor(active("doctor"), {
|
|
1079
|
+
...flags,
|
|
1080
|
+
signal: controller.signal
|
|
1081
|
+
});
|
|
1082
|
+
output(result.data, {
|
|
1083
|
+
status: result.exitCode ? "error" : "success",
|
|
1084
|
+
exitCode: result.exitCode,
|
|
1085
|
+
...result.error ? { error: result.error } : {}
|
|
1086
|
+
});
|
|
1087
|
+
});
|
|
1088
|
+
program.command("generate").description("生成图片并保存原图和记录").option("--prompt <文本>", "直接提供非空提示词(必填)").option("--provider <名称>", "本次使用的供应商").option("--model <ID>", "本次使用的模型").option("--size <尺寸>", "图片尺寸,如 auto 或 1024x1024").option("--n <数量>", "请求图片数量,不自动拆单").option("--quality <质量>", "请求质量").option("--output-format <格式>", "请求图片格式").option("--output-dir <目录>", "保存目录,默认 output/imagegen").option("--filename <名称>", "文件名;多图追加序号,扩展名按实际格式").option("--overwrite", "允许覆盖图片文件").option("--timeout <秒数>", "生成等待超时,默认 600 秒").action(async (flags) => {
|
|
1089
|
+
const current = active("generate");
|
|
1090
|
+
if (flags.prompt === void 0) throw new ImgRelayError("input_error", "请通过 --prompt 提供非空提示词");
|
|
1091
|
+
const result = await generate(current, {
|
|
1092
|
+
prompt: flags.prompt,
|
|
1093
|
+
...flags.provider ? { provider: flags.provider } : {},
|
|
1094
|
+
...flags.model ? { model: flags.model } : {},
|
|
1095
|
+
...flags.size !== void 0 ? { size: flags.size } : {},
|
|
1096
|
+
...flags.n !== void 0 ? { n: Number(flags.n) } : {},
|
|
1097
|
+
...flags.quality !== void 0 ? { quality: flags.quality } : {},
|
|
1098
|
+
...flags.outputFormat !== void 0 ? { outputFormat: flags.outputFormat } : {},
|
|
1099
|
+
...flags.outputDir !== void 0 ? { outputDir: flags.outputDir } : {},
|
|
1100
|
+
...flags.filename !== void 0 ? { filename: flags.filename } : {},
|
|
1101
|
+
...flags.timeout !== void 0 ? { timeoutSeconds: Number(flags.timeout) } : {},
|
|
1102
|
+
overwrite: Boolean(flags.overwrite),
|
|
1103
|
+
signal: controller.signal,
|
|
1104
|
+
progress: (message) => process.stderr.write(`${message}\n`)
|
|
1105
|
+
});
|
|
1106
|
+
output(result.data, {
|
|
1107
|
+
status: result.status,
|
|
1108
|
+
exitCode: result.exitCode,
|
|
1109
|
+
...result.error ? { error: result.error } : {}
|
|
1110
|
+
});
|
|
1111
|
+
});
|
|
1112
|
+
program.action(() => {
|
|
1113
|
+
if (json) output({ help: program.helpInformation() });
|
|
1114
|
+
else program.outputHelp();
|
|
1115
|
+
});
|
|
1116
|
+
try {
|
|
1117
|
+
await program.parseAsync(process.argv);
|
|
1118
|
+
} catch (error) {
|
|
1119
|
+
if (error instanceof CommanderError && error.exitCode === 0) {
|
|
1120
|
+
if (json) output({ help: helpOutput.trim() });
|
|
1121
|
+
process.exitCode = 0;
|
|
1122
|
+
} else {
|
|
1123
|
+
const failure = error instanceof CommanderError ? new ImgRelayError("input_error", error.message) : error;
|
|
1124
|
+
const info = store ? store.redactor.error(failure) : {
|
|
1125
|
+
category: failure instanceof ImgRelayError ? failure.category : "internal_error",
|
|
1126
|
+
message: failure instanceof Error ? failure.message : "发生未知错误"
|
|
1127
|
+
};
|
|
1128
|
+
output(null, {
|
|
1129
|
+
status: info.category === "interrupted" ? "interrupted" : "error",
|
|
1130
|
+
exitCode: failure instanceof ImgRelayError ? failure.exitCode : 1,
|
|
1131
|
+
error: info
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
//#endregion
|
|
1136
|
+
export {};
|
|
1137
|
+
|
|
1138
|
+
//# sourceMappingURL=index.mjs.map
|