refactor(*): 平台架构 - 拆分后端领域模块并同步插件协议与模型能力
This commit is contained in:
1 parent
916043fb5a
commit
6e5cbc8b05
644 files changed
+62731
-2320
No files matched your search
@@ -12,8 +12,20 @@ const filter = (from, as, where) => ({ $filter: { from, as, where } });
|
||||
const eq = (left, right) => ({ $eq: [left, right] });
|
||||
const ne = (left, right) => ({ $ne: [left, right] });
|
||||
const gt = (left, right) => ({ $gt: [left, right] });
|
||||
const gte = (left, right) => ({ $gte: [left, right] });
|
||||
const lt = (left, right) => ({ $lt: [left, right] });
|
||||
const lte = (left, right) => ({ $lte: [left, right] });
|
||||
const and = (...values) => ({ $and: values });
|
||||
const len = (value) => ({ $len: value });
|
||||
const lower = (value) => ({ $lower: value });
|
||||
const trim = (value) => ({ $trim: value });
|
||||
const toFloat = (value) => ({ $toFloat: value });
|
||||
const toInt = (value) => ({ $toInt: value });
|
||||
const split = (value, separator) => ({ $split: [value, separator] });
|
||||
const at = (value, index) => ({ $at: [value, index] });
|
||||
const divide = (left, right) => ({ $divide: [left, right] });
|
||||
const conditional = (condition, thenValue, elseValue = null) => ({ $if: { condition, then: thenValue, else: elseValue } });
|
||||
const nonZeroFloat = (value) => conditional(ne(toFloat(value), 0), toFloat(value));
|
||||
const first = (value) => ({ $first: value });
|
||||
const sorted = (value) => ({ $sortByOrder: value });
|
||||
const mediaWithRoles = (path, roles) => filter(sorted(ref(path)), "media", { $in: [ref("media.role"), roles] });
|
||||
@@ -58,6 +70,12 @@ const videoParams = [
|
||||
["providerOptions", "object", false, "provider-specific fields", "插件命名空间内的厂商扩展字段。"]
|
||||
];
|
||||
|
||||
const audioParams = [
|
||||
["model", "string", true, "model", "音频模型 ID。"],
|
||||
["prompt", "string", true, "input", "待合成文本。"],
|
||||
["providerOptions", "object", false, "provider-specific fields", "插件命名空间内的厂商扩展字段。"]
|
||||
];
|
||||
|
||||
const parameters = (items) => items.map(([name, type, required, mapping, description]) => ({ name, type, required, mapping, description }));
|
||||
const config = (extra = []) => ({
|
||||
fields: [
|
||||
@@ -238,17 +256,113 @@ for (const [id, name, vendor, baseUrl] of [
|
||||
});
|
||||
}
|
||||
|
||||
add({
|
||||
id: "openai-images", providerId: "openai-image", name: "OpenAI Images", vendor: "OpenAI", capability: "image",
|
||||
baseUrl: "https://api.openai.com", auth: bearer, params: imageParams,
|
||||
notes: "无参考图走 JSON generations;有参考图或蒙版走 multipart edits。quality 的 1k/2k/4k 映射为 OpenAI low/medium/high。",
|
||||
create: {
|
||||
method: "POST",
|
||||
path: "/v1/images/generations",
|
||||
pathTemplate: conditional(gt(len(ref("request.images")), 0), "/v1/images/edits", "/v1/images/generations"),
|
||||
contentType: "application/json",
|
||||
contentTypeTemplate: conditional(gt(len(ref("request.images")), 0), "multipart/form-data", "application/json"),
|
||||
body: {
|
||||
model: ref("request.model"),
|
||||
prompt: ref("request.prompt"),
|
||||
n: omit(conditional(gt(ref("request.imageCount"), 0), ref("request.imageCount"), 1)),
|
||||
size: omit(conditional({ $in: [lower(trim(ref("request.aspectRatio"))), ["", "auto"]] }, null, ref("request.aspectRatio"))),
|
||||
quality: omit({
|
||||
$switch: {
|
||||
cases: [
|
||||
{ when: { $in: [lower(trim(ref("request.quality"))), ["1k"]] }, then: "low" },
|
||||
{ when: { $in: [lower(trim(ref("request.quality"))), ["2k"]] }, then: "medium" },
|
||||
{ when: { $in: [lower(trim(ref("request.quality"))), ["4k"]] }, then: "high" },
|
||||
{ when: { $in: [lower(trim(ref("request.quality"))), ["", "auto"]] }, then: null }
|
||||
],
|
||||
default: ref("request.quality")
|
||||
}
|
||||
}),
|
||||
background: omit(coalesce(
|
||||
ref("request.providerOptions.openai-image.background"),
|
||||
conditional(eq(ref("request.extra.transparentBackground"), "true"), "transparent", null)
|
||||
)),
|
||||
output_format: omit(coalesce(ref("request.providerOptions.openai-image.output_format"), "png")),
|
||||
output_compression: omit(ref("request.providerOptions.openai-image.output_compression")),
|
||||
moderation: omit(ref("request.providerOptions.openai-image.moderation")),
|
||||
response_format: omit(coalesce(ref("request.providerOptions.openai-image.response_format"), "b64_json")),
|
||||
style: omit(ref("request.providerOptions.openai-image.style")),
|
||||
user: omit(ref("request.providerOptions.openai-image.user"))
|
||||
},
|
||||
files: [
|
||||
{ name: "image", source: filter(ref("request.images"), "media", ne(ref("media.role"), "mask")), filename: "source.png" },
|
||||
{ name: "mask", source: filter(ref("request.images"), "media", eq(ref("media.role"), "mask")), filename: "mask.png" }
|
||||
]
|
||||
},
|
||||
response: {
|
||||
status: "succeeded",
|
||||
images: map(ref("response.data"), "item", {
|
||||
url: omit(ref("item.url")),
|
||||
dataUrl: conditional(ref("item.b64_json"), { $concat: ["data:image/png;base64,", ref("item.b64_json")] })
|
||||
}),
|
||||
usage: ref("response.usage"), errorPaths: ["error.code"], messagePaths: ["error.message"]
|
||||
}
|
||||
});
|
||||
|
||||
const grokAspectSource = lower(trim(ref("request.aspectRatio")));
|
||||
const grokSizeParts = split(grokAspectSource, "x");
|
||||
const grokSizeWidth = toFloat(at(grokSizeParts, 0));
|
||||
const grokSizeHeight = toFloat(at(grokSizeParts, 1));
|
||||
const grokSizeRatio = divide(grokSizeWidth, grokSizeHeight);
|
||||
const grokHasPixelSize = and(eq(len(grokSizeParts), 2), gt(grokSizeWidth, 0), gt(grokSizeHeight, 0));
|
||||
const grokAspectRatio = omit({
|
||||
$switch: {
|
||||
cases: [
|
||||
{ when: { $in: [grokAspectSource, ["", "auto"]] }, then: null },
|
||||
{ when: { $in: [grokAspectSource, ["1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2", "9:19.5", "19.5:9", "1:2", "2:1"]] }, then: grokAspectSource },
|
||||
{ when: and(grokHasPixelSize, eq(grokSizeWidth, grokSizeHeight)), then: "1:1" },
|
||||
{ when: and(grokHasPixelSize, gte(grokSizeRatio, 1.7), lte(grokSizeRatio, 1.8)), then: "16:9" },
|
||||
{ when: and(grokHasPixelSize, gte(grokSizeRatio, 1.0 / 1.8), lte(grokSizeRatio, 1.0 / 1.7)), then: "9:16" },
|
||||
{ when: and(grokHasPixelSize, gt(grokSizeRatio, 1.2), lt(grokSizeRatio, 1.4)), then: "4:3" },
|
||||
{ when: and(grokHasPixelSize, gt(grokSizeRatio, 0.7), lt(grokSizeRatio, 0.85)), then: "3:4" },
|
||||
{ when: and(grokHasPixelSize, gte(grokSizeRatio, 0.6), lt(grokSizeRatio, 0.72)), then: "2:3" },
|
||||
{ when: and(grokHasPixelSize, gt(grokSizeRatio, 1.35), lt(grokSizeRatio, 1.6)), then: "3:2" },
|
||||
{ when: and(grokHasPixelSize, gt(grokSizeRatio, 0.45), lt(grokSizeRatio, 0.55)), then: "1:2" },
|
||||
{ when: and(grokHasPixelSize, gt(grokSizeRatio, 1.85), lt(grokSizeRatio, 2.2)), then: "2:1" },
|
||||
{ when: and(grokHasPixelSize, gt(grokSizeWidth, grokSizeHeight)), then: "16:9" },
|
||||
{ when: grokHasPixelSize, then: "9:16" }
|
||||
],
|
||||
default: null
|
||||
}
|
||||
});
|
||||
const grokResolutionSource = lower(trim(coalesce(ref("request.resolution"), ref("request.quality"))));
|
||||
const grokResolution = omit({
|
||||
$switch: {
|
||||
cases: [
|
||||
{ when: { $in: [grokResolutionSource, ["1k", "low", "standard"]] }, then: "1k" },
|
||||
{ when: { $in: [grokResolutionSource, ["2k", "medium", "hd", "high", "4k"]] }, then: "2k" }
|
||||
],
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
add({
|
||||
id: "xai-grok-images", providerId: "grok-image", name: "xAI Grok Images", vendor: "xAI", capability: "image",
|
||||
baseUrl: "https://api.x.ai", auth: bearer, params: imageParams,
|
||||
create: jsonCreate("/v1/images/generations", {
|
||||
model: ref("request.model"), prompt: ref("request.prompt"),
|
||||
image: conditional(gt(len(ref("request.images")), 0), { url: firstMediaFieldWithRoles("request.images", ["edit_source", "reference_image", ""], "value") }),
|
||||
n: conditional(gt(ref("request.imageCount"), 0), ref("request.imageCount"), 1),
|
||||
response_format: coalesce(ref("request.providerOptions.grok-image.response_format"), "url"),
|
||||
aspect_ratio: omit(ref("request.aspectRatio")), resolution: omit(coalesce(ref("request.resolution"), ref("request.quality"))),
|
||||
user: omit(ref("request.providerOptions.grok-image.user"))
|
||||
}),
|
||||
notes: "有参考图时改走 /v1/images/edits;resolution 只接受 1k/2k;像素尺寸会换算为 aspect_ratio。",
|
||||
create: {
|
||||
method: "POST",
|
||||
path: "/v1/images/generations",
|
||||
pathTemplate: conditional(gt(len(ref("request.images")), 0), "/v1/images/edits", "/v1/images/generations"),
|
||||
contentType: "application/json",
|
||||
body: {
|
||||
model: ref("request.model"), prompt: ref("request.prompt"),
|
||||
image: conditional(gt(len(ref("request.images")), 0), { url: firstMediaFieldWithRoles("request.images", ["edit_source", "reference_image", ""], "value") }),
|
||||
n: conditional(gt(ref("request.imageCount"), 0), ref("request.imageCount"), 1),
|
||||
response_format: coalesce(ref("request.providerOptions.grok-image.response_format"), "url"),
|
||||
aspect_ratio: grokAspectRatio, resolution: grokResolution,
|
||||
user: omit(ref("request.providerOptions.grok-image.user"))
|
||||
}
|
||||
},
|
||||
response: { status: "succeeded", images: map(ref("response.data"), "item", { url: omit(ref("item.url")), dataUrl: conditional(ref("item.b64_json"), { $concat: ["data:image/png;base64,", ref("item.b64_json")] }) }), errorPaths: ["error.code"], messagePaths: ["error.message"] }
|
||||
});
|
||||
|
||||
@@ -264,18 +378,50 @@ add({
|
||||
model: ref("request.model"), prompt: ref("request.prompt"),
|
||||
// Ark 的 size 只接受 WIDTHxHEIGHT 或 2k/3k/4k;统一层的比例值在这里换算成 2K 档像素尺寸,像素或档位值原样透传。
|
||||
size: omit({ $switch: { cases: arkSeedreamRatioSizes.map(([ratio, size]) => ({ when: eq(ref("request.aspectRatio"), ratio), then: size })), default: ref("request.aspectRatio") } }),
|
||||
image: omit(map(ref("request.images"), "media", ref("media.value"))),
|
||||
image: omit(conditional(
|
||||
eq(len(ref("request.images")), 1),
|
||||
first(map(ref("request.images"), "media", ref("media.value"))),
|
||||
conditional(gt(len(ref("request.images")), 1), map(ref("request.images"), "media", ref("media.value")), null)
|
||||
)),
|
||||
sequential_image_generation: omit(ref("request.providerOptions.volcengine-ark-image.sequential_image_generation")),
|
||||
sequential_image_generation_options: omit(ref("request.providerOptions.volcengine-ark-image.sequential_image_generation_options")),
|
||||
watermark: ref("request.watermark"), seed: omit(ref("request.providerOptions.volcengine-ark-image.seed")),
|
||||
response_format: omit(ref("request.providerOptions.volcengine-ark-image.response_format"))
|
||||
watermark: coalesce(ref("request.providerOptions.volcengine-ark-image.watermark"), ref("request.watermark"), false),
|
||||
seed: omit(ref("request.providerOptions.volcengine-ark-image.seed")),
|
||||
response_format: coalesce(ref("request.providerOptions.volcengine-ark-image.response_format"), "b64_json")
|
||||
}),
|
||||
response: { status: "succeeded", images: ref("response.data"), usage: ref("response.usage"), errorPaths: ["error.code"], messagePaths: ["error.message"] }
|
||||
});
|
||||
|
||||
// Gemini imageConfig.imageSize 只接受 1K/2K/4K;画布统一层用 1k/2k/4k 或 low/medium/high。
|
||||
const geminiImageSize = omit({
|
||||
$coalesce: [
|
||||
{
|
||||
$switch: {
|
||||
cases: [
|
||||
{ when: { $in: [lower(ref("request.quality")), ["1k", "low"]] }, then: "1K" },
|
||||
{ when: { $in: [lower(ref("request.quality")), ["2k", "medium"]] }, then: "2K" },
|
||||
{ when: { $in: [lower(ref("request.quality")), ["4k", "high"]] }, then: "4K" }
|
||||
],
|
||||
default: null
|
||||
}
|
||||
},
|
||||
{
|
||||
$switch: {
|
||||
cases: [
|
||||
{ when: { $in: [lower(ref("request.resolution")), ["1k", "low"]] }, then: "1K" },
|
||||
{ when: { $in: [lower(ref("request.resolution")), ["2k", "medium"]] }, then: "2K" },
|
||||
{ when: { $in: [lower(ref("request.resolution")), ["4k", "high"]] }, then: "4K" }
|
||||
],
|
||||
default: null
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
add({
|
||||
id: "google-gemini-image", providerId: "gemini-image", name: "Google Gemini Image", vendor: "Google", capability: "image",
|
||||
baseUrl: "https://generativelanguage.googleapis.com", auth: { type: "google-api-key", field: "apiKey" }, params: imageParams,
|
||||
notes: "imageSize 只映射 1K/2K/4K;未知质量值(如视频清晰度 720)必须省略。多图输出由宿主按次创建,不映射 candidateCount。",
|
||||
create: jsonCreate("/v1beta/models/{{model}}:generateContent", {
|
||||
contents: [{ role: "user", parts: { $concatArrays: [
|
||||
[{ text: ref("request.prompt") }],
|
||||
@@ -283,10 +429,11 @@ add({
|
||||
] } }],
|
||||
generationConfig: {
|
||||
responseModalities: coalesce(ref("request.providerOptions.gemini-image.responseModalities"), ["TEXT", "IMAGE"]),
|
||||
imageConfig: { aspectRatio: omit(ref("request.aspectRatio")), imageSize: omit(coalesce(ref("request.resolution"), ref("request.quality"))) },
|
||||
candidateCount: omit(ref("request.imageCount")), temperature: omit(ref("request.providerOptions.gemini-image.temperature")), topP: omit(ref("request.providerOptions.gemini-image.topP")), topK: omit(ref("request.providerOptions.gemini-image.topK")), seed: omit(ref("request.providerOptions.gemini-image.seed"))
|
||||
imageConfig: { aspectRatio: omit(ref("request.aspectRatio")), imageSize: geminiImageSize },
|
||||
temperature: omit(ref("request.providerOptions.gemini-image.temperature")), topP: omit(ref("request.providerOptions.gemini-image.topP")), topK: omit(ref("request.providerOptions.gemini-image.topK")), seed: omit(ref("request.providerOptions.gemini-image.seed"))
|
||||
},
|
||||
safetySettings: omit(ref("request.providerOptions.gemini-image.safetySettings")), systemInstruction: omit(ref("request.providerOptions.gemini-image.systemInstruction"))
|
||||
safetySettings: omit(ref("request.providerOptions.gemini-image.safetySettings")),
|
||||
systemInstruction: omit(coalesce(ref("request.providerOptions.gemini-image.systemInstruction"), conditional(ref("request.instructions"), { parts: [{ text: ref("request.instructions") }] }, null)))
|
||||
}),
|
||||
response: {
|
||||
status: "succeeded",
|
||||
@@ -298,19 +445,64 @@ add({
|
||||
}
|
||||
});
|
||||
|
||||
const jimengSizeParts = split(lower(trim(ref("request.aspectRatio"))), "x");
|
||||
const jimengHasPixelSize = and(eq(len(jimengSizeParts), 2), gt(toFloat(at(jimengSizeParts, 0)), 0), gt(toFloat(at(jimengSizeParts, 1)), 0));
|
||||
|
||||
add({
|
||||
id: "volcengine-jimeng-image", providerId: "volcengine-jimeng-image", name: "Volcengine Jimeng Image", vendor: "Volcengine", capability: "image",
|
||||
baseUrl: "https://visual.volcengineapi.com", auth: { type: "volcengine-v4", field: "apiKey", secretField: "secretKey", service: "cv", region: "cn-north-1" }, params: imageParams,
|
||||
configuration: config([{ name: "secretKey", type: "secret", label: "Secret Key", required: true }]),
|
||||
notes: "本地参考图走 binary_data_base64;公网 URL 走 image_urls。WxH 尺寸拆成 width/height。",
|
||||
create: jsonCreate("/", {
|
||||
req_key: ref("request.model"), prompt: ref("request.prompt"), image_urls: omit(map(ref("request.images"), "media", ref("media.value"))),
|
||||
seed: omit(ref("request.providerOptions.volcengine-jimeng-image.seed")), width: omit(ref("request.output.width")), height: omit(ref("request.output.height")),
|
||||
req_key: ref("request.model"), prompt: ref("request.prompt"), force_single: true,
|
||||
binary_data_base64: omit(map(filter(ref("request.images"), "media", ref("media.dataUrl")), "media", { $dataPayload: ref("media.dataUrl") })),
|
||||
image_urls: omit(map(filter(ref("request.images"), "media", and({ $not: ref("media.dataUrl") }, ref("media.url"))), "media", ref("media.url"))),
|
||||
seed: omit(ref("request.providerOptions.volcengine-jimeng-image.seed")),
|
||||
width: omit(conditional(jimengHasPixelSize, toInt(at(jimengSizeParts, 0)), ref("request.output.width"))),
|
||||
height: omit(conditional(jimengHasPixelSize, toInt(at(jimengSizeParts, 1)), ref("request.output.height"))),
|
||||
req_json: omit(ref("request.providerOptions.volcengine-jimeng-image.req_json"))
|
||||
}, { originPath: true, query: { Action: "CVSync2AsyncSubmitTask", Version: "2022-08-31" } }),
|
||||
poll: { method: "POST", path: "/", originPath: true, contentType: "application/json", query: { Action: "CVSync2AsyncGetResult", Version: "2022-08-31" }, body: { req_key: ref("request.model"), task_id: ref("taskId"), req_json: "{\"return_url\":true}" } },
|
||||
response: asyncResponse("image", { taskId: coalesce(ref("response.data.task_id"), ref("response.task_id"), ref("taskId")), status: coalesce(ref("response.data.status"), ref("response.status"), "pending"), images: coalesce(ref("response.data.image_urls"), ref("response.data.binary_data_base64")), errorPaths: ["code"], messagePaths: ["message"] })
|
||||
});
|
||||
|
||||
add({
|
||||
id: "openai-audio", providerId: "openai-audio", name: "OpenAI Audio Speech", vendor: "OpenAI", capability: "audio",
|
||||
baseUrl: "https://api.openai.com", auth: bearer, params: audioParams,
|
||||
notes: "同步 /v1/audio/speech 返回原始音频流,由 binaryPayload 包装为统一音频结果。",
|
||||
create: jsonCreate("/v1/audio/speech", {
|
||||
model: ref("request.model"),
|
||||
input: ref("request.prompt"),
|
||||
voice: coalesce(ref("request.extra.audioVoice"), ref("request.providerOptions.openai-audio.voice"), "alloy"),
|
||||
response_format: coalesce(ref("request.extra.audioFormat"), ref("request.providerOptions.openai-audio.response_format"), "mp3"),
|
||||
speed: coalesce(nonZeroFloat(ref("request.extra.audioSpeed")), ref("request.providerOptions.openai-audio.speed"), 1),
|
||||
instructions: omit(coalesce(ref("request.extra.audioInstructions"), ref("request.providerOptions.openai-audio.instructions")))
|
||||
}),
|
||||
response: { binaryPayload: true, resultKind: "audio", status: "succeeded", errorPaths: ["error.code"], messagePaths: ["error.message"] }
|
||||
});
|
||||
|
||||
add({
|
||||
id: "async-audio", providerId: "async-audio", name: "Async Audio Tasks", vendor: "OpenAI compatible", capability: "audio",
|
||||
baseUrl: "https://api.openai.com", auth: bearer, params: audioParams,
|
||||
notes: "异步音频任务:创建 /v1/audio/tasks,轮询同一路径,结果优先 URL,否则下载 /content。",
|
||||
create: jsonCreate("/v1/audio/tasks", {
|
||||
model: ref("request.model"),
|
||||
input: ref("request.prompt"),
|
||||
voice: coalesce(ref("request.extra.audioVoice"), ref("request.providerOptions.async-audio.voice"), "alloy"),
|
||||
response_format: coalesce(ref("request.extra.audioFormat"), ref("request.providerOptions.async-audio.response_format"), "mp3"),
|
||||
speed: coalesce(nonZeroFloat(ref("request.extra.audioSpeed")), ref("request.providerOptions.async-audio.speed"), 1),
|
||||
instructions: omit(coalesce(ref("request.extra.audioInstructions"), ref("request.providerOptions.async-audio.instructions")))
|
||||
}),
|
||||
poll: { method: "GET", path: "/v1/audio/tasks/{{taskId}}" },
|
||||
result: { method: "GET", path: "/v1/audio/tasks/{{taskId}}/content", headers: { Accept: "audio/*" } },
|
||||
response: asyncResponse("audio", {
|
||||
taskId: coalesce(ref("response.id"), ref("response.task_id"), ref("response.data.id"), ref("taskId")),
|
||||
status: coalesce(ref("response.status"), ref("response.data.status"), "pending"),
|
||||
audios: coalesce(ref("response.audio_url"), ref("response.audioUrl"), ref("response.result_url"), ref("response.url"), ref("response.data.audio_url"), ref("response.output.url")),
|
||||
errorPaths: ["error.code"], messagePaths: ["error.message"]
|
||||
})
|
||||
});
|
||||
|
||||
add({
|
||||
id: "openai-videos", providerId: "newapi", name: "OpenAI Videos / Sora", vendor: "OpenAI", capability: "video",
|
||||
baseUrl: "https://api.openai.com", auth: bearer, params: videoParams, requiresPublicMediaUrls: false,
|
||||
@@ -318,7 +510,8 @@ add({
|
||||
method: "POST", path: "/v1/videos", contentType: "multipart/form-data",
|
||||
body: {
|
||||
model: ref("request.model"), prompt: ref("request.prompt"), seconds: { $toString: ref("request.duration") },
|
||||
size: omit(ref("request.aspectRatio")), variants: omit(ref("request.providerOptions.newapi.variants"))
|
||||
size: omit(ref("request.aspectRatio")), resolution_name: omit(ref("request.resolution")),
|
||||
variants: omit(ref("request.providerOptions.newapi.variants"))
|
||||
},
|
||||
files: [{ name: "input_reference", source: first(filter(sorted(ref("request.images")), "media", ne(ref("media.role"), "mask"))), filename: "input-reference.png" }]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user