380 lines
21 KiB
TypeScript
380 lines
21 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { encryptSecret } from "../src/shared/auth.ts";
|
|
import { createStore } from "../src/store.ts";
|
|
import { invokeProvider, probeProviderModels, queryProvider } from "../src/adapters/provider.ts";
|
|
|
|
const png = Buffer.from("89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489", "hex");
|
|
|
|
function channel(store: ReturnType<typeof createStore>) {
|
|
return {
|
|
...store.channels[0],
|
|
providerType: "openai-images" as const,
|
|
baseUrl: "https://1.1.1.1/v1",
|
|
providerModelId: "base-model",
|
|
resolutionModelMap: { "2K": "mapped-2k" },
|
|
secretRef: encryptSecret("1234567890123456", store.channelEncryptionKey),
|
|
};
|
|
}
|
|
|
|
test("OpenAI image generation uses the mapped model and normalized v1 endpoint", async () => {
|
|
const store = createStore();
|
|
const requests: Array<{ url: string; body: unknown; headers: Headers }> = [];
|
|
const previousFetch = globalThis.fetch;
|
|
globalThis.fetch = async (input, init) => {
|
|
requests.push({ url: String(input), body: init?.body, headers: new Headers(init?.headers) });
|
|
return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64") }] }), { status: 200, headers: { "content-type": "application/json", "x-request-id": "request-123" } });
|
|
};
|
|
try {
|
|
const result = await invokeProvider(store, channel(store), { taskId: "task-1", taskType: "image", prompt: "一只猫", count: 1, params: { resolution: "2K" }, platformIdempotencyKey: "idem-1" });
|
|
assert.equal(result.status, "succeeded");
|
|
assert.equal(result.providerRequestId, "request-123");
|
|
assert.equal(result.outputs?.[0]?.mimeType, "image/png");
|
|
assert.equal(requests[0]?.url, "https://1.1.1.1/v1/images/generations");
|
|
assert.equal(requests[0]?.headers.get("authorization"), "Bearer 1234567890123456");
|
|
assert.equal(requests[0]?.headers.get("idempotency-key"), "idem-1");
|
|
assert.equal((JSON.parse(String(requests[0]?.body)) as Record<string, unknown>).model, "mapped-2k");
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("display model IDs resolve to distinct upstream request model IDs", async () => {
|
|
const store = createStore();
|
|
const configured = { ...channel(store), modelMappings: [{ displayModelId: "平台模型", requestModelId: "供应商模型" }] };
|
|
const previousFetch = globalThis.fetch;
|
|
globalThis.fetch = async (_input, init) => {
|
|
assert.equal((JSON.parse(String(init?.body)) as Record<string, unknown>).model, "供应商模型");
|
|
return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64") }] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
};
|
|
try {
|
|
const result = await invokeProvider(store, configured, { taskId: "mapping-task", taskType: "image", publicModelId: "平台模型", prompt: "测试", params: { resolution: "2K" }, platformIdempotencyKey: "mapping-idem" });
|
|
assert.equal(result.status, "succeeded");
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("OpenAI image edits preserve reference order with image[] fields", async () => {
|
|
const store = createStore();
|
|
for (const [id, color] of [["ref-a", 0x11], ["ref-b", 0x22]] as const) {
|
|
const bytes = Buffer.from(png);
|
|
bytes[bytes.length - 1] ^= color;
|
|
store.objects.set(id, { id, ownerId: "user-1", mimeType: "image/png", data: bytes.toString("base64"), expiresAt: new Date(Date.now() + 60_000).toISOString() });
|
|
}
|
|
const previousFetch = globalThis.fetch;
|
|
globalThis.fetch = async (_input, init) => {
|
|
const form = init?.body as FormData;
|
|
const names = [...form.getAll("image[]")].map((value) => (value instanceof File ? value.name : ""));
|
|
assert.deepEqual(names, ["reference-0.png", "reference-1.png"]);
|
|
assert.equal(form.get("model"), "base-model");
|
|
assert.equal(new Headers(init?.headers).get("idempotency-key"), "idem-2");
|
|
return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64") }] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
};
|
|
try {
|
|
const result = await invokeProvider(store, channel(store), { taskId: "task-2", taskType: "image", prompt: "@图片一和@图片二", count: 1, referenceImages: [{ objectId: "ref-b", seq: 2, name: "图片二" }, { objectId: "ref-a", seq: 1, name: "图片一" }], platformIdempotencyKey: "idem-2" });
|
|
assert.equal(result.status, "succeeded");
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("invalid image base64 and empty model probes fail closed", async () => {
|
|
const store = createStore();
|
|
const previousFetch = globalThis.fetch;
|
|
globalThis.fetch = async (input) => {
|
|
if (String(input).endsWith("/models")) return new Response(JSON.stringify({ data: [] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
return new Response(JSON.stringify({ data: [{ b64_json: "not-base64" }] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
};
|
|
try {
|
|
const result = await invokeProvider(store, channel(store), { taskId: "task-3", taskType: "image", prompt: "测试", count: 1, platformIdempotencyKey: "idem-3" });
|
|
assert.equal(result.status, "failed");
|
|
assert.equal(result.errorCode, "PROVIDER_INVALID_OUTPUT");
|
|
await assert.rejects(() => probeProviderModels("https://1.1.1.1/v1", "1234567890123456"));
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("duplicate references are uploaded once and URL outputs are validated", async () => {
|
|
const store = createStore();
|
|
store.objects.set("ref-a", { id: "ref-a", ownerId: "user-1", mimeType: "image/png", data: png.toString("base64"), expiresAt: new Date(Date.now() + 60_000).toISOString() });
|
|
const previousFetch = globalThis.fetch;
|
|
const urls: string[] = [];
|
|
globalThis.fetch = async (input, init) => {
|
|
urls.push(String(input));
|
|
if (String(input).endsWith("/images/edits")) {
|
|
const form = init?.body as FormData;
|
|
assert.equal(form.getAll("image[]").length, 1);
|
|
assert.equal(form.get("prompt"), "使用第一张图");
|
|
return new Response(JSON.stringify({ data: [{ url: "https://1.1.1.1/result.png" }] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
}
|
|
return new Response(png, { status: 200, headers: { "content-type": "image/png" } });
|
|
};
|
|
try {
|
|
const result = await invokeProvider(store, channel(store), {
|
|
taskId: "task-4",
|
|
taskType: "image",
|
|
prompt: "使用@图片一",
|
|
count: 1,
|
|
referenceImages: [
|
|
{ objectId: "ref-a", seq: 1, name: "图片一" },
|
|
{ objectId: "ref-a", seq: 2, name: "图片二" },
|
|
],
|
|
platformIdempotencyKey: "idem-4",
|
|
});
|
|
assert.equal(result.status, "succeeded");
|
|
assert.equal(result.outputs?.[0]?.mimeType, "image/png");
|
|
assert.deepEqual(urls, ["https://1.1.1.1/v1/images/edits", "https://1.1.1.1/result.png"]);
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("unavailable reference objects fail without retrying the provider", async () => {
|
|
const store = createStore();
|
|
const previousFetch = globalThis.fetch;
|
|
let called = false;
|
|
globalThis.fetch = async () => {
|
|
called = true;
|
|
return new Response(JSON.stringify({ data: [] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
};
|
|
try {
|
|
const result = await invokeProvider(store, channel(store), {
|
|
taskId: "task-5",
|
|
taskType: "image",
|
|
prompt: "使用第一张图",
|
|
count: 1,
|
|
referenceImages: [{ objectId: "missing", seq: 1, name: "图片一" }],
|
|
platformIdempotencyKey: "idem-5",
|
|
});
|
|
assert.equal(result.status, "failed");
|
|
assert.equal(result.errorCode, "PROVIDER_REFERENCE_UNAVAILABLE");
|
|
assert.equal(called, false);
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("expired, revoked, and missing staging references fail deterministically", async () => {
|
|
const store = createStore();
|
|
store.objects.set("expired", { id: "expired", ownerId: "user-1", mimeType: "image/png", data: png.toString("base64"), expiresAt: new Date(Date.now() - 1).toISOString() });
|
|
store.objects.set("revoked", { id: "revoked", ownerId: "user-1", mimeType: "image/png", data: png.toString("base64"), expiresAt: new Date(Date.now() + 60_000).toISOString(), revoked: true });
|
|
store.objects.set("staging-missing", { id: "staging-missing", ownerId: "user-1", mimeType: "image/png", stagingKey: "missing/reference.png", expiresAt: new Date(Date.now() + 60_000).toISOString() });
|
|
const previousFetch = globalThis.fetch;
|
|
let called = false;
|
|
globalThis.fetch = async () => { called = true; return new Response(JSON.stringify({ data: [] }), { status: 200 }); };
|
|
try {
|
|
for (const [index, objectId] of ["expired", "revoked", "staging-missing"].entries()) {
|
|
const result = await invokeProvider(store, channel(store), { taskId: `reference-unavailable-${index}`, taskType: "image", prompt: "使用第一张图", count: 1, referenceImages: [{ objectId, seq: 1, name: "图片一" }], platformIdempotencyKey: `reference-unavailable-${index}` });
|
|
assert.equal(result.status, "failed");
|
|
assert.equal(result.errorCode, "PROVIDER_REFERENCE_UNAVAILABLE");
|
|
assert.equal(result.retryable, false);
|
|
}
|
|
assert.equal(called, false);
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("invalid image output URLs are non-retryable provider failures", async () => {
|
|
const store = createStore();
|
|
const previousFetch = globalThis.fetch;
|
|
const outputs = [
|
|
"not a url",
|
|
"https://127.0.0.1/result.png",
|
|
"https://1.1.1.1/result.png",
|
|
];
|
|
try {
|
|
for (const [index, outputUrl] of outputs.entries()) {
|
|
globalThis.fetch = async (input) => {
|
|
if (String(input).endsWith("/images/generations")) return new Response(JSON.stringify({ data: [{ url: outputUrl }] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
return new Response(index === 2 ? png : "not an image", { status: index === 2 ? 404 : 200, headers: { "content-type": "image/png" } });
|
|
};
|
|
const result = await invokeProvider(store, channel(store), { taskId: `invalid-output-${index}`, taskType: "image", prompt: "测试", count: 1, platformIdempotencyKey: `invalid-output-${index}` });
|
|
assert.equal(result.status, "failed");
|
|
assert.equal(result.errorCode, "PROVIDER_INVALID_OUTPUT");
|
|
assert.equal(result.retryable, false);
|
|
}
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("redirected image output URLs are rejected as invalid output", async () => {
|
|
const store = createStore();
|
|
const previousFetch = globalThis.fetch;
|
|
let downloadRedirected = false;
|
|
globalThis.fetch = async (input) => {
|
|
if (String(input).endsWith("/images/generations")) return new Response(JSON.stringify({ data: [{ url: "https://1.1.1.1/result.png" }] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
downloadRedirected = true;
|
|
return new Response(null, { status: 302, headers: { location: "https://example.com/private.png" } });
|
|
};
|
|
try {
|
|
const result = await invokeProvider(store, channel(store), { taskId: "redirect-output", taskType: "image", prompt: "测试", count: 1, platformIdempotencyKey: "redirect-output" });
|
|
assert.equal(result.status, "failed");
|
|
assert.equal(result.errorCode, "PROVIDER_INVALID_OUTPUT");
|
|
assert.equal(result.retryable, false);
|
|
assert.equal(downloadRedirected, true);
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("image output metadata prefers dimensions decoded from the returned bytes", async () => {
|
|
const store = createStore();
|
|
const previousFetch = globalThis.fetch;
|
|
globalThis.fetch = async (input) => {
|
|
if (String(input).endsWith("/images/generations")) return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64"), size: "999x999" }] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
return new Response(null, { status: 500 });
|
|
};
|
|
try {
|
|
const result = await invokeProvider(store, channel(store), { taskId: "metadata-bytes", taskType: "image", prompt: "测试", count: 1, platformIdempotencyKey: "metadata-bytes" });
|
|
assert.equal(result.status, "succeeded");
|
|
assert.deepEqual(result.outputs?.[0]?.metadata, { width: 1, height: 1, format: "png", size: "1x1", revisedPrompt: undefined, usage: undefined, source: "base64" });
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("image references validate mentions and add a default reference instruction", async () => {
|
|
const store = createStore();
|
|
store.objects.set("ref-a", { id: "ref-a", ownerId: "user-1", mimeType: "image/png", data: png.toString("base64"), expiresAt: new Date(Date.now() + 60_000).toISOString() });
|
|
const previousFetch = globalThis.fetch;
|
|
const prompts: string[] = [];
|
|
globalThis.fetch = async (_input, init) => {
|
|
const form = init?.body as FormData;
|
|
prompts.push(String(form.get("prompt")));
|
|
return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64") }] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
};
|
|
try {
|
|
const result = await invokeProvider(store, channel(store), { taskId: "task-6", taskType: "image", prompt: "请保持主体", count: 1, referenceImages: [{ objectId: "ref-a", seq: 1, name: "图片一" }], platformIdempotencyKey: "idem-6" });
|
|
assert.equal(result.status, "succeeded");
|
|
assert.equal(prompts[0], "请保持主体\n基于以下参考图生成。");
|
|
const missing = await invokeProvider(store, channel(store), { taskId: "task-7", taskType: "image", prompt: "使用@图片二", count: 1, referenceImages: [{ objectId: "ref-a", seq: 1, name: "图片一" }], platformIdempotencyKey: "idem-7" });
|
|
assert.equal(missing.status, "failed");
|
|
assert.equal(missing.errorCode, "PROVIDER_REFERENCE_UNAVAILABLE");
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("invalid provider reference sequences fail closed", async () => {
|
|
const store = createStore();
|
|
const result = await invokeProvider(store, channel(store), { taskId: "task-8", taskType: "image", prompt: "测试", count: 1, referenceImages: [{ objectId: "ref-a", seq: 17, name: "图片十七" }], platformIdempotencyKey: "idem-8" });
|
|
assert.equal(result.status, "failed");
|
|
assert.equal(result.errorCode, "PROVIDER_REFERENCE_INVALID");
|
|
});
|
|
|
|
test("duplicate reference sequences are rejected instead of producing ambiguous 图片 names", async () => {
|
|
const store = createStore();
|
|
for (const objectId of ["duplicate-seq-a", "duplicate-seq-b"]) store.objects.set(objectId, { id: objectId, ownerId: "user-1", mimeType: "image/png", data: png.toString("base64"), expiresAt: new Date(Date.now() + 60_000).toISOString() });
|
|
const result = await invokeProvider(store, channel(store), { taskId: "duplicate-seq", taskType: "image", prompt: "测试", count: 1, referenceImages: [{ objectId: "duplicate-seq-a", seq: 1, name: "图片一" }, { objectId: "duplicate-seq-b", seq: 1, name: "图片一" }], platformIdempotencyKey: "duplicate-seq" });
|
|
assert.equal(result.status, "failed");
|
|
assert.equal(result.errorCode, "PROVIDER_REFERENCE_INVALID");
|
|
});
|
|
|
|
test("provider image requests reject non-positive or fractional counts", async () => {
|
|
const store = createStore();
|
|
const previousFetch = globalThis.fetch;
|
|
let called = false;
|
|
globalThis.fetch = async () => { called = true; return new Response(JSON.stringify({ data: [] }), { status: 200 }); };
|
|
try {
|
|
for (const count of [0, -1, 1.5]) {
|
|
const result = await invokeProvider(store, channel(store), { taskId: `invalid-count-${count}`, taskType: "image", prompt: "测试", count, platformIdempotencyKey: `invalid-count-${String(count).replace(".", "-")}` });
|
|
assert.equal(result.status, "failed");
|
|
assert.equal(result.errorCode, "PROVIDER_INVALID_REQUEST");
|
|
}
|
|
assert.equal(called, false);
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("OpenAI image edits accept the full sixteen-reference boundary and reject the seventeenth", async () => {
|
|
const store = createStore();
|
|
const references = Array.from({ length: 16 }, (_, index) => {
|
|
const objectId = `boundary-ref-${index + 1}`;
|
|
store.objects.set(objectId, { id: objectId, ownerId: "user-1", mimeType: "image/png", data: png.toString("base64"), expiresAt: new Date(Date.now() + 60_000).toISOString() });
|
|
return { objectId, seq: index + 1, name: `图片${index + 1}` };
|
|
});
|
|
const previousFetch = globalThis.fetch;
|
|
globalThis.fetch = async (_input, init) => {
|
|
const form = init?.body as FormData;
|
|
assert.equal(form.getAll("image[]").length, 16);
|
|
return new Response(JSON.stringify({ data: [{ b64_json: png.toString("base64") }] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
};
|
|
try {
|
|
const accepted = await invokeProvider(store, channel(store), { taskId: "sixteen-references", taskType: "image", prompt: "组合参考图", count: 1, referenceImages: references, platformIdempotencyKey: "sixteen-references" });
|
|
assert.equal(accepted.status, "succeeded");
|
|
const rejected = await invokeProvider(store, channel(store), { taskId: "seventeen-references", taskType: "image", prompt: "组合参考图", count: 1, referenceImages: [...references, { objectId: "boundary-ref-1", seq: 17, name: "图片十七" }], platformIdempotencyKey: "seventeen-references" });
|
|
assert.equal(rejected.status, "failed");
|
|
assert.equal(rejected.errorCode, "PROVIDER_REFERENCE_LIMIT");
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("provider output metadata detects JPEG and WebP bytes instead of trusting a declared format", async () => {
|
|
const store = createStore();
|
|
const jpeg = Buffer.from("ffd8ffc00011080001000101011100021100031100ffd9", "hex");
|
|
const webp = Buffer.alloc(30);
|
|
webp.write("RIFF", 0, "ascii"); webp.writeUInt32LE(22, 4); webp.write("WEBP", 8, "ascii"); webp.write("VP8X", 12, "ascii"); webp.writeUInt32LE(10, 16);
|
|
const previousFetch = globalThis.fetch;
|
|
let requestedFormat = "jpeg";
|
|
globalThis.fetch = async (input) => {
|
|
const url = String(input);
|
|
if (url.endsWith("/images/generations")) return new Response(JSON.stringify({ data: [{ url: `https://1.1.1.1/result.${requestedFormat}` }] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
const bytes = requestedFormat === "jpeg" ? jpeg : webp;
|
|
return new Response(bytes, { status: 200, headers: { "content-type": requestedFormat === "jpeg" ? "image/jpg" : "image/webp" } });
|
|
};
|
|
try {
|
|
for (const [index, suffix] of ["jpeg", "webp"].entries()) {
|
|
requestedFormat = suffix;
|
|
const result = await invokeProvider(store, channel(store), { taskId: `format-${suffix}`, taskType: "image", prompt: "格式测试", count: 1, params: { output_format: suffix }, platformIdempotencyKey: `format-${index}` });
|
|
assert.equal(result.status, "succeeded");
|
|
assert.equal(result.outputs?.[0]?.mimeType, `image/${suffix}`);
|
|
assert.deepEqual(result.outputs?.[0]?.metadata && { width: result.outputs[0].metadata.width, height: result.outputs[0].metadata.height, format: result.outputs[0].metadata.format }, { width: 1, height: 1, format: suffix });
|
|
}
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("provider probes use GET and Bearer authentication, and transport failures become unknown", async () => {
|
|
const store = createStore();
|
|
const previousFetch = globalThis.fetch;
|
|
const probeRequests: Array<{ method: string; authorization: string | null }> = [];
|
|
globalThis.fetch = async (input, init) => {
|
|
if (String(input).endsWith("/models")) {
|
|
probeRequests.push({ method: init?.method || "GET", authorization: new Headers(init?.headers).get("authorization") });
|
|
return new Response(JSON.stringify({ data: [{ id: "gpt-image-2" }] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
}
|
|
throw new Error("socket closed");
|
|
};
|
|
try {
|
|
const probe = await probeProviderModels("https://1.1.1.1/v1", "1234567890123456");
|
|
assert.deepEqual(probe, { healthy: true, models: ["gpt-image-2"] });
|
|
assert.deepEqual(probeRequests, [{ method: "GET", authorization: "Bearer 1234567890123456" }]);
|
|
const unknown = await invokeProvider(store, channel(store), { taskId: "transport-unknown", taskType: "image", prompt: "网络中断", count: 1, platformIdempotencyKey: "transport-unknown" });
|
|
assert.equal(unknown.status, "unknown");
|
|
assert.equal(unknown.errorCode, "PROVIDER_TRANSPORT_UNKNOWN");
|
|
const abortedFetch = globalThis.fetch;
|
|
globalThis.fetch = async () => { throw Object.assign(new Error("timeout"), { name: "AbortError" }); };
|
|
const timeout = await invokeProvider(store, channel(store), { taskId: "timeout-unknown", taskType: "image", prompt: "超时", count: 1, platformIdempotencyKey: "timeout-unknown" });
|
|
assert.equal(timeout.status, "unknown");
|
|
assert.equal(timeout.errorCode, "PROVIDER_TIMEOUT_UNKNOWN");
|
|
globalThis.fetch = async () => { throw new Error("probe transport failed"); };
|
|
await assert.rejects(() => probeProviderModels("https://1.1.1.1/v1", "1234567890123456"));
|
|
globalThis.fetch = abortedFetch;
|
|
} finally {
|
|
globalThis.fetch = previousFetch;
|
|
}
|
|
});
|
|
|
|
test("OpenAI image channels stay in manual reconciliation because no query endpoint is assumed", async () => {
|
|
const store = createStore();
|
|
const result = await queryProvider(store, channel(store), "provider-request-1");
|
|
assert.deepEqual(result, { status: "unknown", providerRequestId: "provider-request-1", errorCode: "PROVIDER_QUERY_UNAVAILABLE" });
|
|
});
|