refactor: align provider and task contracts

This commit is contained in:
Qiufeng
2026-09-03 08:18:37 +08:00
parent b16390dd41
commit ca2e4b360e
13 changed files with 807 additions and 363 deletions
+26 -22
View File
@@ -11,8 +11,12 @@ function channel(store: ReturnType<typeof createStore>) {
...store.channels[0],
providerType: "openai-images" as const,
baseUrl: "https://1.1.1.1/v1",
providerModelId: "base-model",
resolutionModelMap: { "2K": "mapped-2k" },
models: [
{ requestModelId: "base-model", source: "manual" as const, enabled: true },
{ requestModelId: "mapped-2k", source: "manual" as const, enabled: true },
{ requestModelId: "供应商模型", source: "manual" as const, enabled: true },
],
modelMappings: [],
secretRef: encryptSecret("1234567890123456", store.channelEncryptionKey),
};
}
@@ -26,14 +30,14 @@ test("OpenAI image generation uses the mapped model and normalized v1 endpoint",
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" });
const result = await invokeProvider(store, channel(store), { taskId: "task-1", taskType: "image", requestModelId: "base-model", 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");
assert.equal((JSON.parse(String(requests[0]?.body)) as Record<string, unknown>).model, "base-model");
} finally {
globalThis.fetch = previousFetch;
}
@@ -72,7 +76,7 @@ test("OpenAI image edits preserve reference order with image[] fields", async ()
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" });
const result = await invokeProvider(store, channel(store), { taskId: "task-2", taskType: "image", requestModelId: "base-model", 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;
@@ -87,7 +91,7 @@ test("invalid image base64 and empty model probes fail closed", async () => {
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" });
const result = await invokeProvider(store, channel(store), { taskId: "task-3", taskType: "image", requestModelId: "base-model", 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"));
@@ -113,7 +117,7 @@ test("duplicate references are uploaded once and URL outputs are validated", asy
};
try {
const result = await invokeProvider(store, channel(store), {
taskId: "task-4",
taskId: "task-4", requestModelId: "base-model",
taskType: "image",
prompt: "使用@图片一",
count: 1,
@@ -141,7 +145,7 @@ test("unavailable reference objects fail without retrying the provider", async (
};
try {
const result = await invokeProvider(store, channel(store), {
taskId: "task-5",
taskId: "task-5", requestModelId: "base-model",
taskType: "image",
prompt: "使用第一张图",
count: 1,
@@ -166,7 +170,7 @@ test("expired, revoked, and missing staging references fail deterministically",
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}` });
const result = await invokeProvider(store, channel(store), { taskId: `reference-unavailable-${index}`, taskType: "image", requestModelId: "base-model", 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);
@@ -191,7 +195,7 @@ test("invalid image output URLs are non-retryable provider failures", async () =
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}` });
const result = await invokeProvider(store, channel(store), { taskId: `invalid-output-${index}`, taskType: "image", requestModelId: "base-model", prompt: "测试", count: 1, platformIdempotencyKey: `invalid-output-${index}` });
assert.equal(result.status, "failed");
assert.equal(result.errorCode, "PROVIDER_INVALID_OUTPUT");
assert.equal(result.retryable, false);
@@ -211,7 +215,7 @@ test("redirected image output URLs are rejected as invalid output", async () =>
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" });
const result = await invokeProvider(store, channel(store), { taskId: "redirect-output", taskType: "image", requestModelId: "base-model", prompt: "测试", count: 1, platformIdempotencyKey: "redirect-output" });
assert.equal(result.status, "failed");
assert.equal(result.errorCode, "PROVIDER_INVALID_OUTPUT");
assert.equal(result.retryable, false);
@@ -229,7 +233,7 @@ test("image output metadata prefers dimensions decoded from the returned bytes",
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" });
const result = await invokeProvider(store, channel(store), { taskId: "metadata-bytes", taskType: "image", requestModelId: "base-model", 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 {
@@ -248,10 +252,10 @@ test("image references validate mentions and add a default reference instruction
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" });
const result = await invokeProvider(store, channel(store), { taskId: "task-6", taskType: "image", requestModelId: "base-model", 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" });
const missing = await invokeProvider(store, channel(store), { taskId: "task-7", taskType: "image", requestModelId: "base-model", 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 {
@@ -261,7 +265,7 @@ test("image references validate mentions and add a default reference instruction
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" });
const result = await invokeProvider(store, channel(store), { taskId: "task-8", taskType: "image", requestModelId: "base-model", 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");
});
@@ -269,7 +273,7 @@ test("invalid provider reference sequences fail closed", async () => {
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" });
const result = await invokeProvider(store, channel(store), { taskId: "duplicate-seq", taskType: "image", requestModelId: "base-model", 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");
});
@@ -281,7 +285,7 @@ test("provider image requests reject non-positive or fractional counts", async (
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(".", "-")}` });
const result = await invokeProvider(store, channel(store), { taskId: `invalid-count-${count}`, taskType: "image", requestModelId: "base-model", prompt: "测试", count, platformIdempotencyKey: `invalid-count-${String(count).replace(".", "-")}` });
assert.equal(result.status, "failed");
assert.equal(result.errorCode, "PROVIDER_INVALID_REQUEST");
}
@@ -305,9 +309,9 @@ test("OpenAI image edits accept the full sixteen-reference boundary and reject t
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" });
const accepted = await invokeProvider(store, channel(store), { taskId: "sixteen-references", taskType: "image", requestModelId: "base-model", 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" });
const rejected = await invokeProvider(store, channel(store), { taskId: "seventeen-references", taskType: "image", requestModelId: "base-model", 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 {
@@ -331,7 +335,7 @@ test("provider output metadata detects JPEG and WebP bytes instead of trusting a
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}` });
const result = await invokeProvider(store, channel(store), { taskId: `format-${suffix}`, taskType: "image", requestModelId: "base-model", 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 });
@@ -356,12 +360,12 @@ test("provider probes use GET and Bearer authentication, and transport failures
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" });
const unknown = await invokeProvider(store, channel(store), { taskId: "transport-unknown", taskType: "image", requestModelId: "base-model", 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" });
const timeout = await invokeProvider(store, channel(store), { taskId: "timeout-unknown", taskType: "image", requestModelId: "base-model", 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"); };