feat(models): 模型目录与计费 - 按展示名分组并隔离管理员积分成本

This commit is contained in:
ddCat
2026-09-19 09:54:27 +08:00
parent 5ad05efb58
commit 237f2e2a6e
49 changed files with 802 additions and 276 deletions

View File

@@ -2,8 +2,27 @@ import { describe, expect, test } from "bun:test";
import { defaultPriceTier, priceTierPayloadFromForm, priceTierResolutionFromForm, priceTierToForm, priceTierVideoSecondsFromForm, skuSelectorFromForm } from "../src/pages/admin/components/channel-model-price-tier-form";
import type { ChannelModelPriceTier } from "../src/services/api/wallet";
import { validateChannelModelPrices } from "../src/pages/admin/components/channel-model-editor-form";
describe("channel model price tier defaults", () => {
test("cost edit payload preserves independent sale and cost prices including explicit zero", () => {
const tier = { ...defaultPriceTier(), unitPrice: 0.09, costConfigured: true, costUnitPrice: 0.035678 };
const payload = priceTierPayloadFromForm("video", tier, "MiniMax-H3");
expect(payload.unitPriceMicrocredits).toBe(90_000);
expect(payload.costPricing).toMatchObject({ configured: true, unitPriceMicrocredits: 35_678 });
expect(priceTierToForm(payload as ChannelModelPriceTier)).toMatchObject({ unitPrice: 0.09, costConfigured: true, costUnitPrice: 0.035678 });
expect(priceTierPayloadFromForm("video", { ...tier, costUnitPrice: 0 }, "MiniMax-H3").costPricing).toMatchObject({ configured: true, unitPriceMicrocredits: 0 });
expect(defaultPriceTier().costConfigured).toBe(false);
for (const costUnitPrice of [-1, NaN, Infinity, 1_000_001]) {
expect(() => validateChannelModelPrices({ capability: "video", protocol: "minimax-video", priceTiers: [{ ...tier, costUnitPrice }] })).toThrow("积分成本价");
}
});
test("video Token cost uses only output price while text cost keeps separate token rates", () => {
const tier = { ...defaultPriceTier(), billingMode: "token" as const, costConfigured: true, costInputTokenPrice: 1.5, costOutputTokenPrice: 3, costCachedTokenPrice: 0.1 };
expect(priceTierPayloadFromForm("video", tier, "video").costPricing).toMatchObject({ configured: true, inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 3_000_000, cachedTokenPriceMicrocredits: 0 });
expect(priceTierPayloadFromForm("text", tier, "text").costPricing).toMatchObject({ inputTokenPriceMicrocredits: 1_500_000, outputTokenPriceMicrocredits: 3_000_000, cachedTokenPriceMicrocredits: 100_000 });
});
test("creates a usable all-spec fallback price by default", () => {
const tier = defaultPriceTier();

View File

@@ -27,19 +27,30 @@ function fixture() {
return normalizeConfigSnapshot({ config: { ...defaultConfig, channels: systemChannelModelChannels(channels), model: "b::seedance-2.0", videoModel: "b::seedance-2.0" } }).config;
}
test("one system channel exposes every model and preserves prices through catalog normalization", () => {
test("same model display name groups all channels and preserves their prices", () => {
const config = fixture();
const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video"));
expect(groups).toHaveLength(3);
expect(groups).toHaveLength(1);
expect(config.channels.map((channel) => channel.modelCosts![0].description)).toEqual(["", "优惠渠道-993的使用说明", "特惠渠道-730的使用说明"]);
expect(groups.map((group) => [group.label, group.kind, group.models.map((item) => [item.label, item.models])])).toEqual([
["正常渠道", "system-channel", [["Seedance 2.0", ["a::seedance-2.0"]]]],
["渠道 B", "system-channel", [["Seedance 2.0", ["b::seedance-2.0"]]]],
["渠道 C", "system-channel", [["Seedance 2.0", ["c::seedance-2.0"]]]],
["Seedance 2.0", "product", [["正常渠道", ["a::seedance-2.0"]], ["优惠渠道-993", ["b::seedance-2.0"]], ["特惠渠道-730", ["c::seedance-2.0"]]]],
]);
expect(config.channels.map((channel) => priceTierSummaryLabel(priceTiersForCurrentSelection(channel.modelCosts![0].logicalPriceTiers!, "video", config)))).toEqual(["0.3 积分/秒", "0.3 积分/秒", "0.2 积分/秒"]);
});
test("grouping uses display name even when model keys differ, without merging channel options", () => {
const channels = systemChannelModelChannels([
{ id: "mita", name: "秘塔", displayName: "秘塔", models: [model("秘塔(满血渠道)", 90_000, "MiniMax H3", "MiniMax H3")] },
{ id: "other", name: "其他供应商", displayName: "其他供应商", models: [model("秘塔(满血渠道)", 80_000, "minimax-h3-alt", "MiniMax H3")] },
]);
const config = normalizeConfigSnapshot({ config: { ...defaultConfig, channels } }).config;
const groups = groupModelsForPicker(config, selectableModelsByCapability(config, "video"));
expect(groups).toHaveLength(1);
expect(groups[0].label).toBe("MiniMax H3");
expect(groups[0].models.map((item) => item.models)).toEqual([["mita::MiniMax H3"], ["other::minimax-h3-alt"]]);
expect(groups[0].models.map((item) => item.label)).toEqual(["秘塔(满血渠道)", "秘塔(满血渠道)"]);
});
test("selection and quote keep the chosen channel even when another channel is cheaper", () => {
const config = fixture();
const value = "b::seedance-2.0";
@@ -51,7 +62,7 @@ test("selection and quote keep the chosen channel even when another channel is c
expect(resolveModelRequestConfig(config, value)).toMatchObject({ channelId: "b", model: "seedance-2.0" });
});
test("system channel aggregation keeps models distinct within one channel", () => {
test("different display names in one channel create distinct first-level groups", () => {
const config = fixture();
const extra = systemChannelModelChannels([{ id: "volc", name: "火山引擎", displayName: "火山引擎", models: [
model("Seedance 2 Mini", 300000, "seedance-2-mini", "Seedance 2 Mini"),
@@ -59,9 +70,9 @@ test("system channel aggregation keeps models distinct within one channel", () =
model("Seedance 2.0", 300000, "seedance-2", "Seedance 2.0"),
] }]);
const groups = groupModelsForPicker({ ...config, channels: extra }, selectableModelsByCapability({ ...config, channels: extra }, "video"));
expect(groups).toHaveLength(1);
expect(groups[0]).toMatchObject({ label: "火山引擎", kind: "system-channel" });
expect(groups[0].models.map((item) => item.label)).toEqual(["Seedance 2 Mini", "Seedance 2.0 Fast", "Seedance 2.0"]);
expect(groups).toHaveLength(3);
expect(groups.map((group) => group.label)).toEqual(["Seedance 2 Mini", "Seedance 2.0 Fast", "Seedance 2.0"]);
expect(groups.every((group) => group.kind === "product" && group.models.length === 1)).toBe(true);
});
test("group metadata is stable across channel reorder and current selection", () => {
@@ -69,10 +80,10 @@ test("group metadata is stable across channel reorder and current selection", ()
config.channels[1].modelCosts![0].displayName = "不同展示名";
config.channels[1].modelCosts![0].icon = "Jimeng";
const options = selectableModelsByCapability(config, "video");
const first = groupModelsForPicker(config, options).find((group) => group.label === "渠道 C")!;
const reordered = groupModelsForPicker({ ...config, channels: [...config.channels].reverse(), model: "c::seedance-2.0" }, options).find((group) => group.label === "渠道 C")!;
const first = groupModelsForPicker(config, options).find((group) => group.label === "不同展示名")!;
const reordered = groupModelsForPicker({ ...config, channels: [...config.channels].reverse(), model: "c::seedance-2.0" }, options).find((group) => group.label === "不同展示名")!;
expect([reordered.label, reordered.icon]).toEqual([first.label, first.icon]);
expect(reordered.models[0].models).toEqual(["c::seedance-2.0"]);
expect(reordered.models[0].models).toEqual(["b::seedance-2.0"]);
});
test("each channel keeps its own capability restrictions", () => {
@@ -83,7 +94,7 @@ test("each channel keeps its own capability restrictions", () => {
const requirements = { capability: "video" as const, input: { textCount: 1, imageCount: 1, videoCount: 0, audioCount: 0, characterCount: 0 } };
expect(modelCompatibilityError(config, "a::seedance-2.0", requirements)).not.toBe("");
expect(modelCompatibilityError(config, "b::seedance-2.0", requirements)).toBe("");
expect(groupModelsForPicker(config, selectableModelsByCapability(config, "video")).every((group) => group.models.length === 1)).toBe(true);
expect(groupModelsForPicker(config, selectableModelsByCapability(config, "video"))[0].models).toHaveLength(3);
});
test("managed logical models and personal channels never become system product routes", () => {
@@ -92,7 +103,7 @@ test("managed logical models and personal channels never become system product r
config.channels[1].modelCosts![0].logicalModelId = "logical-test";
config.channels[2].scope = "user";
const groups = groupModelsForPicker(config, ["a::seedance-2.0", "managed::seedance-2.0", "c::seedance-2.0"]);
expect(groups.map((item) => item.kind)).toEqual(["system-channel", "channel", "channel"]);
expect(groups.map((item) => item.kind)).toEqual(["product", "channel", "channel"]);
expect(groups[0].models).toHaveLength(1);
expect(groupModelsForPicker(config, [])).toEqual([]);
});