refactor: align provider and task contracts
This commit is contained in:
@@ -186,11 +186,11 @@ test("public task responses do not expose provider route fields and 3D remains d
|
||||
const session = (await login.json()) as { data: { accessToken: string } }; const auth = { Authorization: `Bearer ${session.data.accessToken}` };
|
||||
const catalog = await fetch(`${base}/api/v1/catalog/models`, { headers: auth }); const catalogPayload = await catalog.json() as { data: { items: Array<Record<string, unknown>> } };
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(catalogPayload.data.items[0], "channelGroupId"), false);
|
||||
const task = "provider-task"; const now = new Date().toISOString(); store.tasks.set(task, { id: task, ownerId: registered.data.userId, taskType: "image", modelProductId: "basic-image", status: "queued", estimatedCost: 1, reservedCost: 0, createdAt: now, updatedAt: now, eventSequence: 0, channelGroupId: "image-default", routeSnapshotVersion: 1, routeSnapshot: { groupId: "image-default", version: 1, channelIds: ["channel-a"] }, pricingSnapshot: { basePrice: 1, multiplier: 1, unitVersion: 1 }, planSnapshot: { queuePriority: 0, maxConcurrent: 1 }, reserveExpiresAt: now, idempotencyKey: "private-task-key", requestFingerprint: "private-fingerprint", attempts: [{ id: "attempt", channelId: "channel-a", sequence: 1, status: "started", providerRequestId: "provider-secret", platformIdempotencyKey: "provider-idempotency", leaseExpiresAt: now, startedAt: now }], outputs: [] });
|
||||
const task = "provider-task"; const now = new Date().toISOString(); store.tasks.set(task, { id: task, ownerId: registered.data.userId, taskType: "image", modelProductId: "basic-image", status: "queued", estimatedCost: 1, reservedCost: 0, createdAt: now, updatedAt: now, eventSequence: 0, routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 0, requestModelId: "basic-image-v1" }], strategy: "strict", totalRetryBudget: 0 }, modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"] }, pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", resolutionId: "low", unitPrice: 1, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 }, planSnapshot: { queuePriority: 0, maxConcurrent: 1 }, reserveExpiresAt: now, idempotencyKey: "private-task-key", requestFingerprint: "private-fingerprint", attempts: [{ id: "attempt", channelId: "channel-a", sequence: 1, status: "started", providerRequestId: "provider-secret", platformIdempotencyKey: "provider-idempotency", leaseExpiresAt: now, startedAt: now }], outputs: [] });
|
||||
const hidden = JSON.stringify(store.tasks.get(task)); assert.equal(hidden.includes("provider-secret"), true);
|
||||
const taskResponse = await fetch(`${base}/api/v1/tasks/${task}`, { headers: auth }); const taskPayload = await taskResponse.json() as { data: Record<string, unknown> & { attempts?: Array<Record<string, unknown>> } };
|
||||
assert.equal(taskResponse.status, 200);
|
||||
for (const field of ["ownerId", "channelGroupId", "routeSnapshotVersion", "routeSnapshot", "pricingSnapshot", "planSnapshot", "leaseExpiresAt", "leaseToken", "providerSubmitted", "preferredReserveBucketId", "idempotencyKey", "requestFingerprint", "reserveExpiresAt"]) assert.equal(Object.prototype.hasOwnProperty.call(taskPayload.data, field), false);
|
||||
for (const field of ["ownerId", "routeSnapshot", "pricingSnapshot", "planSnapshot", "leaseExpiresAt", "leaseToken", "providerSubmitted", "preferredReserveBucketId", "idempotencyKey", "requestFingerprint", "reserveExpiresAt"]) assert.equal(Object.prototype.hasOwnProperty.call(taskPayload.data, field), false);
|
||||
for (const field of ["channelId", "providerRequestId", "errorCode", "platformIdempotencyKey", "leaseExpiresAt"]) assert.equal(Object.prototype.hasOwnProperty.call(taskPayload.data.attempts?.[0] || {}, field), false);
|
||||
const disabled3d = await fetch(`${base}/api/v1/tasks/3d`, { method: "POST", headers: { ...auth, "content-type": "application/json" }, body: JSON.stringify({ modelProductId: "basic-image", prompt: "fixture" }) }); const disabledPayload = await disabled3d.json() as { error?: { code?: string } }; assert.ok([400, 422].includes(disabled3d.status)); assert.equal(disabledPayload.error?.code, "CAPABILITY_NOT_ENABLED");
|
||||
} finally {
|
||||
|
||||
@@ -107,7 +107,7 @@ test("postgres reload retries when a write starts during the database read", asy
|
||||
test("store transaction rolls back in-memory mutations and defers events until commit", async () => {
|
||||
const store = createStore();
|
||||
const taskId = "transaction-task";
|
||||
store.tasks.set(taskId, { id: taskId, ownerId: "transaction-user", taskType: "image", modelProductId: "basic-image-v1", status: "queued", estimatedCost: 1, reservedCost: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), eventSequence: 0, channelGroupId: "image-default", routeSnapshotVersion: 1, attempts: [], outputs: [] });
|
||||
store.tasks.set(taskId, { id: taskId, ownerId: "transaction-user", taskType: "image", modelProductId: "basic-image-v1", status: "queued", estimatedCost: 1, reservedCost: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), eventSequence: 0, routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [], strategy: "strict", totalRetryBudget: 0 }, modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"] }, pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", unitPrice: 1, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 }, planSnapshot: { queuePriority: 0, maxConcurrent: 1 }, attempts: [], outputs: [] });
|
||||
let observed = 0;
|
||||
store.taskSubscribers.set(taskId, new Set([() => { observed += 1; }]));
|
||||
await assert.rejects(store.transact(() => { const task = store.tasks.get(taskId)!; task.status = "running"; appendEvent(store, taskId, { taskId, type: "task.running", payload: {} }); throw new Error("rollback"); }), /rollback/);
|
||||
@@ -145,7 +145,7 @@ test("staging objects stay below the private root and expired files are collecte
|
||||
test("expired worker lease moves a running task to unknown without releasing reserve", () => {
|
||||
const store = createStore();
|
||||
const now = new Date(Date.now() - 10_000).toISOString();
|
||||
store.tasks.set("lease-task", { id: "lease-task", ownerId: "lease-user", taskType: "image", modelProductId: "basic-image", status: "running", estimatedCost: 10, reservedCost: 10, createdAt: now, updatedAt: now, eventSequence: 0, channelGroupId: "image-default", routeSnapshotVersion: 1, leaseExpiresAt: now, attempts: [{ id: "attempt-1", channelId: "channel-a", sequence: 1, status: "started", startedAt: now, leaseExpiresAt: now }], outputs: [] });
|
||||
store.tasks.set("lease-task", { id: "lease-task", ownerId: "lease-user", taskType: "image", modelProductId: "basic-image", status: "running", estimatedCost: 10, reservedCost: 10, createdAt: now, updatedAt: now, eventSequence: 0, routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [], strategy: "strict", totalRetryBudget: 0 }, modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"] }, pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", unitPrice: 1, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 }, planSnapshot: { queuePriority: 0, maxConcurrent: 1 }, leaseExpiresAt: now, attempts: [{ id: "attempt-1", channelId: "channel-a", sequence: 1, status: "started", startedAt: now, leaseExpiresAt: now }], outputs: [] });
|
||||
store.balances.set("lease-user", { available: 0, reserved: 10 });
|
||||
assert.equal(recoverExpiredTaskLeases(store), 1);
|
||||
const task = store.tasks.get("lease-task")!;
|
||||
@@ -293,7 +293,7 @@ test("expired unknown tasks without a provider request id remain held for reconc
|
||||
store.buckets.set(userId, [{ id: "bucket", userId, source: "recharge", remaining: 10, priority: 0 }]);
|
||||
assert.equal(reserveBalance(store, userId, 10, "unknown-expiry-task"), true);
|
||||
const expired = new Date(Date.now() - 1_000).toISOString();
|
||||
store.tasks.set("unknown-expiry-task", { id: "unknown-expiry-task", ownerId: userId, taskType: "image", modelProductId: "basic-image", status: "unknown", estimatedCost: 10, reservedCost: 10, reserveExpiresAt: expired, createdAt: expired, updatedAt: expired, eventSequence: 0, channelGroupId: "image-default", routeSnapshotVersion: 1, attempts: [{ id: "unknown-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: expired, finishedAt: expired, reconciliationStatus: "pending" }], outputs: [] });
|
||||
store.tasks.set("unknown-expiry-task", { id: "unknown-expiry-task", ownerId: userId, taskType: "image", modelProductId: "basic-image", status: "unknown", estimatedCost: 10, reservedCost: 10, reserveExpiresAt: expired, createdAt: expired, updatedAt: expired, eventSequence: 0, routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [], strategy: "strict", totalRetryBudget: 0 }, modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"] }, pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", unitPrice: 1, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 }, planSnapshot: { queuePriority: 0, maxConcurrent: 1 }, attempts: [{ id: "unknown-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: expired, finishedAt: expired, reconciliationStatus: "pending" }], outputs: [] });
|
||||
assert.equal(recoverExpiredReservations(store), 0);
|
||||
assert.equal(store.tasks.get("unknown-expiry-task")?.status, "unknown");
|
||||
assert.equal(store.tasks.get("unknown-expiry-task")?.reservedCost, 10);
|
||||
@@ -307,7 +307,7 @@ test("file snapshots use a version marker and reject corrupt state", async () =>
|
||||
const store = createStore(file);
|
||||
store.persist();
|
||||
const persisted = JSON.parse(await readFile(file, "utf8")) as { snapshotVersion?: number };
|
||||
assert.equal(persisted.snapshotVersion, 1);
|
||||
assert.equal(persisted.snapshotVersion, 2);
|
||||
await writeFile(file, "{not-json", "utf8");
|
||||
assert.throws(() => createStore(file), /persistence snapshot rejected/);
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
|
||||
@@ -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"); };
|
||||
|
||||
@@ -119,7 +119,7 @@ test("admin RBAC rejects finance access and redacts support channel internals",
|
||||
const { store, server, base } = await start("support");
|
||||
try {
|
||||
store.channels[0].providerName = "internal-provider";
|
||||
store.channels[0].providerModelId = "internal-model";
|
||||
store.channels[0].models[0].requestModelId = "internal-model";
|
||||
store.channels[0].baseUrl = "https://provider.example.com";
|
||||
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
||||
const loginPayload = await login.json() as { data: { accessToken: string } };
|
||||
@@ -165,12 +165,12 @@ test("OpenAI image channel creation does not probe implicitly and explicit probi
|
||||
return previousFetch(input, init);
|
||||
};
|
||||
const auth = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" };
|
||||
const created = await fetch(`${base}/api/v1/admin/channels`, { method: "POST", headers: { ...auth, "idempotency-key": "openai-channel-create" }, body: JSON.stringify({ label: "OpenAI 图片渠道", providerType: "openai-images", providerModelId: "gpt-image-2", baseUrl: "https://1.1.1.1/v1", secretRef: "k", resolutionModelMap: { "1K": "gpt-image-2", "2K": "gpt-image-2-2k", invalid: "should-drop" } }) });
|
||||
const created = await fetch(`${base}/api/v1/admin/channels`, { method: "POST", headers: { ...auth, "idempotency-key": "openai-channel-create" }, body: JSON.stringify({ label: "OpenAI 图片渠道", providerType: "openai-images", models: [{ requestModelId: "gpt-image-2", source: "manual", enabled: true }, { requestModelId: "gpt-image-2-2k", source: "manual", enabled: true }], baseUrl: "https://1.1.1.1/v1", secretRef: "k" }) });
|
||||
assert.equal(created.status, 201);
|
||||
const createdPayload = await created.json() as { data: { item: Record<string, unknown> } };
|
||||
assert.equal(createdPayload.data.item.secretRef, "configured");
|
||||
assert.equal(createdPayload.data.item.baseUrl, "https://1.1.1.1/v1");
|
||||
assert.deepEqual(createdPayload.data.item.resolutionModelMap, { "1K": "gpt-image-2", "2K": "gpt-image-2-2k" });
|
||||
assert.deepEqual(createdPayload.data.item.models, [{ requestModelId: "gpt-image-2", source: "manual", enabled: true }, { requestModelId: "gpt-image-2-2k", source: "manual", enabled: true }]);
|
||||
const channel = store.channels.find((item) => item.id === createdPayload.data.item.id);
|
||||
assert.ok(channel);
|
||||
assert.notEqual(channel.secretRef, "k");
|
||||
@@ -182,7 +182,7 @@ test("OpenAI image channel creation does not probe implicitly and explicit probi
|
||||
assert.equal(probeRequests[0]?.method, "GET");
|
||||
assert.equal(probeRequests[0]?.authorization, "Bearer k");
|
||||
|
||||
const invalid = await fetch(`${base}/api/v1/admin/channels`, { method: "POST", headers: { ...auth, "idempotency-key": "openai-channel-http" }, body: JSON.stringify({ label: "不安全渠道", providerType: "openai-images", providerModelId: "gpt-image-2", baseUrl: "http://1.1.1.1/v1", secretRef: "1234567890123456" }) });
|
||||
const invalid = await fetch(`${base}/api/v1/admin/channels`, { method: "POST", headers: { ...auth, "idempotency-key": "openai-channel-http" }, body: JSON.stringify({ label: "不安全渠道", providerType: "openai-images", models: [{ requestModelId: "gpt-image-2", source: "manual", enabled: true }], baseUrl: "http://1.1.1.1/v1", secretRef: "1234567890123456" }) });
|
||||
assert.equal(invalid.status, 201);
|
||||
assert.equal(store.channels.some((item) => item.label === "不安全渠道"), true);
|
||||
} finally {
|
||||
|
||||
@@ -143,7 +143,7 @@ test("admin image model products publish capability parameters and require a res
|
||||
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
||||
const loginPayload = await login.json() as { data: { accessToken: string } };
|
||||
const headers = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" };
|
||||
const created = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "model-capability-create" }, body: JSON.stringify({ id: "openai-image-product", publicModelId: "gpt-image-2", name: "OpenAI 图片模型", capabilities: ["image"], channelGroupId: "image-default", basePrice: 20, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, priceMultiplier: 1, ratios: ["1:1", "16:9"] }] }) });
|
||||
const created = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "model-capability-create" }, body: JSON.stringify({ id: "openai-image-product", publicModelId: "gpt-image-2", name: "OpenAI 图片模型", capabilities: ["image"], enabled: false, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, ratios: ["1:1", "16:9"] }] }) });
|
||||
assert.equal(created.status, 201);
|
||||
const createdPayload = await created.json() as { data: { item: { capabilitiesSchema?: Array<{ capability: string; parameters?: Record<string, { enum?: string[] }> }> } } };
|
||||
const imageSchema = createdPayload.data.item.capabilitiesSchema?.find((item) => item.capability === "image");
|
||||
@@ -152,69 +152,78 @@ test("admin image model products publish capability parameters and require a res
|
||||
assert.deepEqual(imageSchema.parameters?.resolution.enum, ["1K", "2K", "4K"]);
|
||||
assert.deepEqual(imageSchema.parameters?.input_fidelity.enum, ["low", "high"]);
|
||||
|
||||
const invalid = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "model-capability-invalid" }, body: JSON.stringify({ id: "openai-image-invalid", publicModelId: "gpt-image-invalid", name: "无分辨率图片模型", capabilities: ["image"], channelGroupId: "image-default", basePrice: 20, resolutions: [] }) });
|
||||
const invalid = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "model-capability-invalid" }, body: JSON.stringify({ id: "openai-image-invalid", publicModelId: "gpt-image-invalid", name: "无分辨率图片模型", capabilities: ["image"], enabled: true, resolutions: [] }) });
|
||||
assert.equal(invalid.status, 422);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("admin channel groups bind model products separately from request channels", async () => {
|
||||
test("admin channel groups keep model routes separate from public model records", async () => {
|
||||
const { server, base, store } = await start();
|
||||
try {
|
||||
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
||||
const loginPayload = await login.json() as { data: { accessToken: string } };
|
||||
const headers = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" };
|
||||
const firstProduct = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-first" }, body: JSON.stringify({ id: "group-basic-image", publicModelId: "group-basic-image-v1", name: "分组基础图片", capabilities: ["image"], enabled: false, basePrice: 10, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, priceMultiplier: 1, ratios: ["1:1"] }] }) });
|
||||
const firstProduct = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-first" }, body: JSON.stringify({ id: "group-basic-image", publicModelId: "group-basic-image-v1", name: "分组基础图片", capabilities: ["image"], enabled: false, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, ratios: ["1:1"] }] }) });
|
||||
assert.equal(firstProduct.status, 201);
|
||||
const secondProduct = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-second" }, body: JSON.stringify({ id: "group-advanced-image", publicModelId: "group-advanced-image-v1", name: "分组高级图片", capabilities: ["image"], enabled: false, basePrice: 25, resolutions: [{ id: "2K", label: "2K", width: 2048, height: 2048, priceMultiplier: 1.5, ratios: ["1:1"] }] }) });
|
||||
const secondProduct = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-second" }, body: JSON.stringify({ id: "group-advanced-image", publicModelId: "group-advanced-image-v1", name: "分组高级图片", capabilities: ["image"], enabled: false, resolutions: [{ id: "2K", label: "2K", width: 2048, height: 2048, ratios: ["1:1"] }] }) });
|
||||
assert.equal(secondProduct.status, 201);
|
||||
const channel = store.channels.find((item) => item.id === "channel-a")!;
|
||||
const channelUpdate = await fetch(`${base}/api/v1/admin/channels/channel-a`, { method: "PATCH", headers: { ...headers, "If-Match": String(channel.version), "idempotency-key": "channel-group-product-models" }, body: JSON.stringify({ models: [...channel.models, { requestModelId: "group-basic-image-v1", source: "manual", enabled: true }, { requestModelId: "group-advanced-image-v1", source: "manual", enabled: true }] }) });
|
||||
assert.equal(channelUpdate.status, 200);
|
||||
const created = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-bind" }, body: JSON.stringify({ id: "image-product-group", name: "图片产品组", capabilities: ["image"], productIds: ["group-basic-image"], channelIds: ["channel-a"], retryBudget: 2 }) });
|
||||
assert.equal(created.status, 201);
|
||||
const createdPayload = await created.json() as { data: { item: { productIds?: string[] } } };
|
||||
assert.deepEqual(createdPayload.data.item.productIds, ["group-basic-image"]);
|
||||
assert.equal(store.products.find((product) => product.id === "group-basic-image")?.channelGroupId, "image-product-group");
|
||||
assert.equal(created.status, 422);
|
||||
const createdWithRoutes = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-product-bind-routes" }, body: JSON.stringify({ id: "image-product-group", name: "图片产品组", capabilities: ["image"], routes: [{ publicModelId: "group-basic-image-v1", channels: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 1 }], strategy: "strict", retryBudget: 2, version: 1 }] }) });
|
||||
assert.equal(createdWithRoutes.status, 201);
|
||||
const createdPayload = await createdWithRoutes.json() as { data: { item: { routes: Array<{ publicModelId: string }> } } };
|
||||
assert.deepEqual(createdPayload.data.item.routes.map((route) => route.publicModelId), ["group-basic-image-v1"]);
|
||||
assert.equal(store.products.find((product) => product.id === "group-basic-image")?.enabled, false);
|
||||
|
||||
const listed = await fetch(`${base}/api/v1/admin/channel-groups`, { headers });
|
||||
assert.equal(listed.status, 200);
|
||||
const listedPayload = await listed.json() as { data: { items: Array<{ id: string; productIds?: string[]; products?: Array<{ productId: string }> }> } };
|
||||
const listedPayload = await listed.json() as { data: { items: Array<{ id: string; routes?: Array<{ publicModelId: string }>; products?: Array<{ productId: string }> }> } };
|
||||
const listedGroup = listedPayload.data.items.find((item) => item.id === "image-product-group");
|
||||
assert.deepEqual(listedGroup?.productIds, ["group-basic-image"]);
|
||||
assert.deepEqual(listedGroup?.routes?.map((route) => route.publicModelId), ["group-basic-image-v1"]);
|
||||
assert.deepEqual(listedGroup?.products?.map((product) => product.productId), ["group-basic-image"]);
|
||||
|
||||
const edited = await fetch(`${base}/api/v1/admin/channel-groups/image-product-group`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-product-rebind" }, body: JSON.stringify({ productIds: ["group-advanced-image"], capabilities: ["image"], channelIds: ["channel-a"], retryBudget: 2, enabled: true }) });
|
||||
assert.equal(edited.status, 200);
|
||||
assert.equal(store.products.find((product) => product.id === "group-basic-image")?.channelGroupId, undefined);
|
||||
assert.equal(edited.status, 422);
|
||||
const editedWithRoutes = await fetch(`${base}/api/v1/admin/channel-groups/image-product-group`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-product-rebind-routes" }, body: JSON.stringify({ routes: [{ publicModelId: "group-advanced-image-v1", channels: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 1 }], strategy: "strict", retryBudget: 2, version: 1 }], capabilities: ["image"], enabled: true }) });
|
||||
assert.equal(editedWithRoutes.status, 200);
|
||||
assert.deepEqual(store.channelGroups.find((group) => group.id === "image-product-group")?.routes.map((route) => route.publicModelId), ["group-advanced-image-v1"]);
|
||||
assert.equal(store.products.find((product) => product.id === "group-basic-image")?.enabled, false);
|
||||
assert.equal(store.products.find((product) => product.id === "group-advanced-image")?.channelGroupId, "image-product-group");
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
});
|
||||
|
||||
test("admin channel groups reject incompatible or silently migrated model products", async () => {
|
||||
test("admin channel groups reject incompatible routes and legacy model binding fields", async () => {
|
||||
const { server, base, store } = await start();
|
||||
try {
|
||||
const login = await fetch(`${base}/api/v1/admin/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: "admin@example.com", password: "change-me-now" }) });
|
||||
const loginPayload = await login.json() as { data: { accessToken: string } };
|
||||
const headers = { authorization: `Bearer ${loginPayload.data.accessToken}`, "content-type": "application/json" };
|
||||
const imageGroup = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-image" }, body: JSON.stringify({ id: "capability-image-group", name: "图片能力组", capabilities: ["image"], productIds: [], channelIds: ["channel-a"], retryBudget: 1 }) });
|
||||
assert.equal(imageGroup.status, 201);
|
||||
const product = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-product" }, body: JSON.stringify({ id: "capability-image-product", publicModelId: "capability-image-v1", name: "能力校验图片产品", capabilities: ["image"], channelGroupId: "capability-image-group", basePrice: 5, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, priceMultiplier: 1, ratios: ["1:1"] }] }) });
|
||||
const channel = store.channels.find((item) => item.id === "channel-a")!;
|
||||
const channelUpdate = await fetch(`${base}/api/v1/admin/channels/channel-a`, { method: "PATCH", headers: { ...headers, "If-Match": String(channel.version), "idempotency-key": "channel-group-capability-model" }, body: JSON.stringify({ models: [...channel.models, { requestModelId: "capability-image-v1", source: "manual", enabled: true }] }) });
|
||||
assert.equal(channelUpdate.status, 200);
|
||||
const product = await fetch(`${base}/api/v1/admin/model-products`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-product" }, body: JSON.stringify({ id: "capability-image-product", publicModelId: "capability-image-v1", name: "能力校验图片产品", capabilities: ["image"], enabled: false, resolutions: [{ id: "1K", label: "1K", width: 1024, height: 1024, ratios: ["1:1"] }] }) });
|
||||
assert.equal(product.status, 201);
|
||||
const textGroup = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-text" }, body: JSON.stringify({ id: "capability-text-group", name: "文字能力组", capabilities: ["text"], productIds: [], channelIds: ["channel-a"], retryBudget: 1 }) });
|
||||
const imageGroup = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-image" }, body: JSON.stringify({ id: "capability-image-group", name: "图片能力组", capabilities: ["image"], routes: [{ publicModelId: "capability-image-v1", channels: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 1 }], strategy: "strict", retryBudget: 1, version: 1 }] }) });
|
||||
assert.equal(imageGroup.status, 201);
|
||||
const textGroup = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-capability-text" }, body: JSON.stringify({ id: "capability-text-group", name: "文字能力组", capabilities: ["text"], routes: [] }) });
|
||||
assert.equal(textGroup.status, 201);
|
||||
|
||||
const incompatibleMode = await fetch(`${base}/api/v1/admin/channel-groups/capability-image-group`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-capability-change" }, body: JSON.stringify({ capabilities: ["text"] }) });
|
||||
assert.equal(incompatibleMode.status, 422);
|
||||
assert.deepEqual(store.channelGroups.find((group) => group.id === "capability-image-group")?.capabilities, ["image"]);
|
||||
assert.equal(store.products.find((item) => item.id === "capability-image-product")?.channelGroupId, "capability-image-group");
|
||||
assert.deepEqual(store.channelGroups.find((group) => group.id === "capability-image-group")?.routes.map((route) => route.publicModelId), ["capability-image-v1"]);
|
||||
|
||||
const incompatibleProduct = await fetch(`${base}/api/v1/admin/model-products/capability-image-product`, { method: "PATCH", headers: { ...headers, "If-Match": "1", "idempotency-key": "channel-group-capability-product-change" }, body: JSON.stringify({ channelGroupId: "capability-text-group" }) });
|
||||
assert.equal(incompatibleProduct.status, 422);
|
||||
assert.equal(store.products.find((item) => item.id === "capability-image-product")?.channelGroupId, "capability-image-group");
|
||||
assert.equal(store.products.find((item) => item.id === "capability-image-product")?.enabled, false);
|
||||
|
||||
const duplicateBinding = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-ownership-conflict" }, body: JSON.stringify({ id: "capability-other-group", name: "重复绑定组", capabilities: ["image"], productIds: ["capability-image-product"], channelIds: ["channel-a"], retryBudget: 1 }) });
|
||||
const duplicateBinding = await fetch(`${base}/api/v1/admin/channel-groups`, { method: "POST", headers: { ...headers, "idempotency-key": "channel-group-ownership-conflict" }, body: JSON.stringify({ id: "capability-other-group", name: "重复绑定组", capabilities: ["image"], routes: [{ publicModelId: "capability-image-v1", channels: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 1 }], strategy: "strict", retryBudget: 1, version: 1 }] }) });
|
||||
assert.equal(duplicateBinding.status, 409);
|
||||
assert.equal(store.channelGroups.some((group) => group.id === "capability-other-group"), false);
|
||||
} finally {
|
||||
@@ -251,7 +260,7 @@ test("message provider and template writes survive a file-store restart", async
|
||||
}
|
||||
});
|
||||
|
||||
test("admin unknown-task reconciliation stores real image metadata and clamps the charge", async () => {
|
||||
test("admin unknown-task reconciliation rejects overcharge and stores real image metadata", async () => {
|
||||
const { store, server, base } = await start();
|
||||
const taskId = "reconcile-image-task";
|
||||
const ownerId = "reconcile-user";
|
||||
@@ -267,13 +276,14 @@ test("admin unknown-task reconciliation stores real image metadata and clamps th
|
||||
status: "unknown",
|
||||
estimatedCost: 10,
|
||||
reservedCost: 10,
|
||||
count: 2,
|
||||
pricingSnapshot: { basePrice: 5, multiplier: 1, unitVersion: 1 },
|
||||
count: 1,
|
||||
pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", resolutionId: "low", unitPrice: 10, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 },
|
||||
modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"], resolutionId: "low", resolution: { id: "low", label: "低", width: 1024, height: 1024, ratios: ["1:1"] } },
|
||||
routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 0, requestModelId: "basic-image-v1" }], strategy: "strict", totalRetryBudget: 0 },
|
||||
planSnapshot: { queuePriority: 0, maxConcurrent: 1 },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
eventSequence: 0,
|
||||
channelGroupId: "image-default",
|
||||
routeSnapshotVersion: 1,
|
||||
attempts: [{ id: "reconcile-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: now, reconciliationStatus: "pending" }],
|
||||
outputs: [],
|
||||
inputSnapshot: { references: [], params: { resolution: "2K" } },
|
||||
@@ -287,11 +297,15 @@ test("admin unknown-task reconciliation stores real image metadata and clamps th
|
||||
assert.equal(store.tasks.get(taskId)?.status, "unknown");
|
||||
assert.equal(store.balances.get(ownerId)?.reserved, 10);
|
||||
const form = new FormData(); form.set("outcome", "success"); form.set("chargedAmount", "99"); form.append("file", new Blob([Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+XwAAAABJRU5ErkJggg==", "base64")], { type: "image/png" }), "result.png");
|
||||
const overcharged = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "idempotency-key": "reconcile-image-overcharge", "if-match": "0" }, body: form });
|
||||
assert.equal(overcharged.status, 422);
|
||||
assert.equal(store.tasks.get(taskId)?.status, "unknown");
|
||||
form.set("chargedAmount", "10");
|
||||
const response = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "idempotency-key": "reconcile-image-success", "if-match": "0" }, body: form });
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json() as { data: { item: GenerationTask } };
|
||||
const settledEntries = store.ledger.filter((entry) => entry.type === "settle" && entry.referenceId === taskId).length;
|
||||
const repeatedForm = new FormData(); repeatedForm.set("outcome", "success"); repeatedForm.set("chargedAmount", "99"); repeatedForm.append("file", new Blob([Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+XwAAAABJRU5ErkJggg==", "base64")], { type: "image/png" }), "result.png");
|
||||
const repeatedForm = new FormData(); repeatedForm.set("outcome", "success"); repeatedForm.set("chargedAmount", "10"); repeatedForm.append("file", new Blob([Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M/wHwAF/gL+XwAAAABJRU5ErkJggg==", "base64")], { type: "image/png" }), "result.png");
|
||||
const repeated = await fetch(`${base}/api/v1/admin/tasks/${taskId}/reconcile`, { method: "POST", headers: { authorization: `Bearer ${loginPayload.data.accessToken}`, "idempotency-key": "reconcile-image-success", "if-match": "0" }, body: repeatedForm });
|
||||
assert.equal(repeated.status, 200);
|
||||
assert.equal(store.ledger.filter((entry) => entry.type === "settle" && entry.referenceId === taskId).length, settledEntries);
|
||||
@@ -306,7 +320,7 @@ test("admin unknown-task reconciliation stores real image metadata and clamps th
|
||||
assert.equal(output.metadata?.format, "png");
|
||||
assert.equal(output.metadata?.source, "base64");
|
||||
assert.equal(output.metadata?.resolution, "2K");
|
||||
assert.equal(output.unitPriceSnapshot, 5);
|
||||
assert.equal(output.unitPriceSnapshot, 10);
|
||||
assert.equal(output.chargedAmount, 10);
|
||||
assert.equal(store.balances.get(ownerId)?.reserved, 0);
|
||||
assert.equal(store.ledger.some((entry) => entry.type === "settle" && entry.referenceId === taskId), true);
|
||||
@@ -333,8 +347,10 @@ test("admin unknown-task failure reconciliation releases the full reservation",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
eventSequence: 0,
|
||||
channelGroupId: "image-default",
|
||||
routeSnapshotVersion: 1,
|
||||
pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", resolutionId: "low", unitPrice: 10, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 },
|
||||
modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"], resolutionId: "low", resolution: { id: "low", label: "低", width: 1024, height: 1024, ratios: ["1:1"] } },
|
||||
routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [{ channelId: "channel-a", priorityTier: 1, orderWithinTier: 1, retryBudget: 0, requestModelId: "basic-image-v1" }], strategy: "strict", totalRetryBudget: 0 },
|
||||
planSnapshot: { queuePriority: 0, maxConcurrent: 1 },
|
||||
attempts: [{ id: "reconcile-failure-attempt", channelId: "channel-a", sequence: 1, status: "unknown", startedAt: now, reconciliationStatus: "pending" }],
|
||||
outputs: [],
|
||||
} satisfies GenerationTask);
|
||||
@@ -566,12 +582,13 @@ test("task route snapshots follow channel-group priority order", async () => {
|
||||
store.balances.set(user.registered.userId, { available: 100, reserved: 0 });
|
||||
const group = store.channelGroups.find((item) => item.id === "image-default");
|
||||
assert.ok(group);
|
||||
group.channelIds = ["channel-c", "channel-a", "channel-d", "channel-b"];
|
||||
group.channelPriorities = { "channel-c": 0, "channel-a": 1, "channel-d": 2, "channel-b": 3 };
|
||||
const route = group.routes.find((item) => item.publicModelId === "basic-image-v1");
|
||||
assert.ok(route);
|
||||
route.channels = ["channel-c", "channel-a", "channel-d", "channel-b"].map((channelId, index) => ({ channelId, priorityTier: 1, orderWithinTier: index + 1, retryBudget: 0 }));
|
||||
const response = await fetch(`${base}/api/v1/tasks/image`, { method: "POST", headers: { authorization: `Bearer ${user.payload.data.accessToken!}`, "content-type": "application/json", "idempotency-key": "channel-order-task" }, body: JSON.stringify({ modelProductId: "basic-image-v1", prompt: "route snapshot" }) });
|
||||
assert.equal(response.status, 202);
|
||||
const task = [...store.tasks.values()].find((item) => item.ownerId === user.registered.userId);
|
||||
assert.deepEqual(task?.routeSnapshot?.channelIds, ["channel-c", "channel-a", "channel-d", "channel-b"]);
|
||||
assert.deepEqual(task?.routeSnapshot?.orderedCandidates.map((candidate) => candidate.channelId), ["channel-c", "channel-a", "channel-d", "channel-b"]);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
@@ -586,7 +603,7 @@ test("documented admin singleton and detail method aliases remain callable", asy
|
||||
assert.equal((await fetch(`${base}/api/v1/admin/billing/units`, { headers: headers("alias-units-get") })).status, 200);
|
||||
assert.equal((await fetch(`${base}/api/v1/admin/billing/units`, { method: "POST", headers: headers("alias-units-post"), body: JSON.stringify({ name: "金币" }) })).status, 201);
|
||||
assert.equal((await fetch(`${base}/api/v1/admin/billing/conversion-rules`, { method: "POST", headers: headers("alias-conversion-post"), body: JSON.stringify({ conversionRate: 1, rounding: "round" }) })).status, 201);
|
||||
assert.equal((await fetch(`${base}/api/v1/admin/pricing-rules`, { method: "POST", headers: headers("alias-pricing-post"), body: JSON.stringify({ modelProductId: "basic-image", basePrice: 10 }) })).status, 201);
|
||||
assert.equal((await fetch(`${base}/api/v1/admin/pricing-rules`, { method: "POST", headers: headers("alias-pricing-post"), body: JSON.stringify({ publicModelId: "basic-image-v1", resolutionId: "low", billingUnit: "output", tiers: [{ minQuantity: 1, unitPrice: 10 }], multiplier: 1, enabled: false }) })).status, 201);
|
||||
assert.equal((await fetch(`${base}/api/v1/admin/storage-policies`, { method: "POST", headers: headers("alias-storage-post"), body: JSON.stringify({ retentionDays: 30 }) })).status, 201);
|
||||
assert.equal((await fetch(`${base}/api/v1/admin/recharge-orders/unknown-order`, { headers: headers("alias-order-detail") })).status, 404);
|
||||
} finally {
|
||||
|
||||
@@ -17,7 +17,7 @@ test("task websocket authenticates and replays a subscribed task cursor", async
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const taskId = "ws-task";
|
||||
store.users.set("ws-user", { id: "ws-user", version: 1, password: "", verified: true, status: "active", mfaRequired: false, mfaEnabled: false, roles: ["user"], failedLoginCount: 0, createdAt: new Date().toISOString() });
|
||||
store.tasks.set(taskId, { id: taskId, ownerId: "ws-user", taskType: "image", modelProductId: "basic-image", status: "queued", estimatedCost: 1, reservedCost: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), eventSequence: 0, channelGroupId: "image-default", routeSnapshotVersion: 1, attempts: [], outputs: [] });
|
||||
store.tasks.set(taskId, { id: taskId, ownerId: "ws-user", taskType: "image", modelProductId: "basic-image", status: "queued", estimatedCost: 1, reservedCost: 0, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), eventSequence: 0, routeSnapshot: { groupId: "image-default", groupVersion: 1, publicModelId: "basic-image-v1", routeVersion: 1, orderedCandidates: [], strategy: "strict", totalRetryBudget: 0 }, modelSnapshot: { publicModelId: "basic-image-v1", modelVersion: 1, name: "基础模型", tier: "basic", capabilities: ["image"] }, pricingSnapshot: { ruleId: "price-basic-image", ruleVersion: 1, publicModelId: "basic-image-v1", unitPrice: 1, quantity: 1, billingUnit: "output", multiplier: 1, balanceUnitVersion: 1 }, planSnapshot: { queuePriority: 0, maxConcurrent: 1 }, attempts: [], outputs: [] });
|
||||
const token = issueAccessToken("ws-user", "user", secret, 900);
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${address.port}/api/v1/ws/tasks?access_token=${encodeURIComponent(token)}`);
|
||||
const messages: Array<{ type: string; event?: { taskId: string } }> = [];
|
||||
|
||||
Reference in New Issue
Block a user