diff --git a/web/src/pages/admin/components/channel-model-editor-form.ts b/web/src/pages/admin/components/channel-model-editor-form.ts index 7e2b75b8f..bb34507ec 100644 --- a/web/src/pages/admin/components/channel-model-editor-form.ts +++ b/web/src/pages/admin/components/channel-model-editor-form.ts @@ -90,9 +90,7 @@ export function validateChannelModelPrices(values: Pick Boolean(value && value !== "*"); - if (capability === "video" && tier.videoGenerateAudio && !["*", "true", "false"].includes(tier.videoGenerateAudio)) fail("请选择任意音频、有声或无声"); - if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.size))) || (capability === "video" && (specific(tier.resolution) || tier.videoSeconds > 0 || tier.imageCount > 0 || specific(tier.videoGenerateAudio))))) - fail("规格价格至少需要一个匹配条件;统一价格请选择默认价格"); + if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.size))) || (capability === "video" && specific(tier.resolution)))) fail("规格价格至少需要一个匹配条件;统一价格请选择默认价格"); } const prices = tier.billingMode !== "token" ? [tier.unitPrice] : capability === "video" ? [tier.outputTokenPrice] : [tier.inputTokenPrice, tier.outputTokenPrice, tier.cachedTokenPrice]; if (prices.some((price) => typeof price !== "number" || !Number.isFinite(price) || price < 0 || price > 1_000_000)) fail("积分价格必须是 0 到 1000000 之间的有效数值"); diff --git a/web/src/pages/admin/components/channel-model-editor.tsx b/web/src/pages/admin/components/channel-model-editor.tsx index 3eb856652..1f9951b63 100644 --- a/web/src/pages/admin/components/channel-model-editor.tsx +++ b/web/src/pages/admin/components/channel-model-editor.tsx @@ -372,27 +372,31 @@ export function ChannelModelEditor({ ]} > {(fields, { add, remove }, { errors }) => ( -
- {fields.map((field, index) => ( - { - dirtyRef.current = true; - }} - onRemove={() => remove(field.name)} - /> - ))} - - +
+
+ {fields.map((field, index) => ( + { + dirtyRef.current = true; + }} + onRemove={() => remove(field.name)} + /> + ))} +
+
+ + +
)} diff --git a/web/src/pages/admin/components/channel-model-price-tier-fields.tsx b/web/src/pages/admin/components/channel-model-price-tier-fields.tsx index 2e1259b89..007194dc1 100644 --- a/web/src/pages/admin/components/channel-model-price-tier-fields.tsx +++ b/web/src/pages/admin/components/channel-model-price-tier-fields.tsx @@ -38,23 +38,26 @@ export function PriceTierFields({ const staleTierUpstream = matchMode === "default" && tierUpstream && modelUpstream && tierUpstream !== modelUpstream ? tierUpstream : ""; const video = capabilityConfig?.video; const resolutionOptions = video?.resolutions || []; - const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : []; const tokenEnabled = modelProtocolSupportsTokenBilling(capability, protocol); const isVideo = capability === "video"; const isImage = capability === "image"; return ( -
-
-
-
{matchMode === "default" ? "默认价格" : `规格价格 ${ordinal}`}
+
+
+ + +
+ {String(ordinal).padStart(2, "0")} +
+
{matchMode === "default" ? "默认价格" : `规格价格 ${ordinal}`}
+
{matchMode === "default" ? "所有请求使用同一价格" : isVideo ? "按生成方式与分辨率匹配" : "按生成方式、质量或尺寸匹配"}
+
-
- - +
可供用户使用
+
- -
+
-
- +
+
+
+

适用范围

+

选择全部请求,或按生成规格精确匹配。

+
+
+ -
-
-
销售价格
-
- - - - {billingMode === "token" ? ( - isVideo ? ( - - - - ) : ( -
- - - - - - - - - + +
+ {matchMode !== "default" && ( +
+
+
+

匹配条件

+

符合以下条件时使用这条价格,精确规则优先。

- ) - ) : ( - - - - )} -
- {isVideo && billingMode === "token" ? ( -

- 所有视频模型均支持 Token 计费,统一按火山引擎模式计算:宽 × 高 × 24 帧/秒 ×(输出时长 + 参考视频时长)÷ 1024。优先按成功任务返回的有效用量结算,未返回用量时按公式结算。平台额外预留 10%,不计入公式结算;最终可能补扣或退回差额。0 表示免费;有声、无声或参考视频可分别配置规格价格。 -

- ) : null} -
- - {matchMode !== "default" && ( -
- - ({ label: value.toUpperCase(), value }))]} /> - - ) : null} - {isVideo ? ( - - {durationOptions.length ? - - ) : null} - {isVideo ? ( - - - - ) : null} - {isImage ? ( - - + + {isVideo ? ( + + + + ) : null} + {isImage ? ( + + + + ) : null} + + + +
+ + )} +
+
+
+

销售价格

+

设置用户完成一次生成实际消耗的积分。

+
+
+
+ + + {billingMode === "token" ? ( + isVideo ? ( + + + + ) : ( +
+ + + + + + + + + +
+ ) + ) : ( + + + + )} +
+ {isVideo && billingMode === "token" ? ( +
+ 视频 Token 计费 + 估算:宽 × 高 × 24 帧/秒 ×(输出时长 + 参考视频时长)÷ 1024;优先按上游有效用量结算,无用量时使用公式,授权预留 10% 会在结算后补扣或退回。 +
) : null} - {isImage ? ( - - - - ) : null} - - - -
- )} + + +
{staleTierUpstream ? ( ) : null}
-
+
); } diff --git a/web/src/pages/admin/components/channel-model-price-tier-form.ts b/web/src/pages/admin/components/channel-model-price-tier-form.ts index 89b5921bc..1f48d5c24 100644 --- a/web/src/pages/admin/components/channel-model-price-tier-form.ts +++ b/web/src/pages/admin/components/channel-model-price-tier-form.ts @@ -5,7 +5,9 @@ export type PriceTierMatchMode = "default" | "advanced"; // 与后端保存口径一致:上游键比较前剥掉一次 models/ 前缀,避免极旧存量行误报差异。 export function normalizeUpstreamModelKey(value: unknown): string { - return String(value || "").trim().replace(/^models\//, ""); + return String(value || "") + .trim() + .replace(/^models\//, ""); } export type PriceTierFormValues = { @@ -60,7 +62,7 @@ export function defaultPriceTier(matchMode: PriceTierMatchMode = "default"): Pri export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValues { const selector = tier.selector || {}; - const hasSpecificMatch = Object.values(selector).some((value) => value && value !== "*") || (tier.resolution && tier.resolution !== "*") || tier.videoSeconds > 0; + const hasSpecificMatch = [selector.operation, selector.quality, selector.size].some((value) => value && value !== "*") || (tier.resolution && tier.resolution !== "*"); return { matchMode: hasSpecificMatch ? "advanced" : "default", operation: selector.operation || "*", @@ -107,9 +109,6 @@ export function skuSelectorFromForm(capability: ModelCapabilityChoice, tier: Pri if (tier.operation && tier.operation !== "*") selector.operation = tier.operation; if (capability === "video") { if (tier.resolution && tier.resolution !== "*") selector.vquality = tier.resolution; - if (Number(tier.videoSeconds) > 0) selector.videoSeconds = String(Number(tier.videoSeconds)); - if (Number(tier.imageCount) > 0) selector.imageCount = String(Number(tier.imageCount)); - if (tier.videoGenerateAudio && tier.videoGenerateAudio !== "*") selector.videoGenerateAudio = tier.videoGenerateAudio; } if (capability === "image") { if (tier.quality && tier.quality !== "*") selector.quality = tier.quality; @@ -123,7 +122,9 @@ export function priceTierResolutionFromForm(capability: ModelCapabilityChoice, t } export function priceTierVideoSecondsFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues) { - return capability === "video" && tier.matchMode === "advanced" ? Number(tier.videoSeconds || 0) : 0; + void capability; + void tier; + return 0; } export function priceTierPayloadFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues, upstreamModel: string) { diff --git a/web/src/pages/admin/components/credit-cost-fields.tsx b/web/src/pages/admin/components/credit-cost-fields.tsx index dce90329d..9ad8f7bd1 100644 --- a/web/src/pages/admin/components/credit-cost-fields.tsx +++ b/web/src/pages/admin/components/credit-cost-fields.tsx @@ -14,12 +14,21 @@ export function CreditCostFields({ index, form, billingMode, isVideo }: { index: ] : [["costUnitPrice", billingMode === "per_second" ? "积分 / 秒" : "积分 / 次"]]; return ( -
- - - +
+
+
+
+ 积分成本 + 仅管理员可见 +
+

按当前计费方式填写成本,不影响用户售价。

+
+ + + +
{configured ? ( -
+
{fields.map(([field, label]) => ( @@ -27,6 +36,6 @@ export function CreditCostFields({ index, form, billingMode, isVideo }: { index: ))}
) : null} -
+
); } diff --git a/web/src/pages/settings/channel-video-pricing.tsx b/web/src/pages/settings/channel-video-pricing.tsx index cc517cfd0..ea66bfe48 100644 --- a/web/src/pages/settings/channel-video-pricing.tsx +++ b/web/src/pages/settings/channel-video-pricing.tsx @@ -148,7 +148,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan capability: nextCapability, billingMode: "fixed_request", unitPriceMicrocredits: 0, - capabilityConfig: nextCapability === "image" || nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined, + capabilityConfig: nextCapability === "text" || nextCapability === "image" || nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined, }); }} /> @@ -165,7 +165,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan protocol: nextProtocol, billingMode: "fixed_request", unitPriceMicrocredits: 0, - capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined, + capabilityConfig: activeCapability === "text" || activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined, })} /> @@ -175,7 +175,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan key: "capabilities", label: "能力与参数", children:
- {activeCapability === "image" || activeCapability === "video" ? ( + {activeCapability === "text" || activeCapability === "image" || activeCapability === "video" ? ( .ant-btn { + padding-inline: 5px; + font-size: 12px; +} + .admin-price-tier-card-body { display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); - gap: 12px 16px; - padding: 12px; -} - -.admin-price-tier-block { min-width: 0; } -.admin-price-tier-block-title { - margin-bottom: 10px; - color: color-mix(in srgb, var(--foreground) 48%, transparent); - font-size: 10px; - font-weight: 650; - letter-spacing: 0.08em; -} - -.admin-price-tier-block-title > span { - margin-right: 5px; - color: var(--admin-text-muted); -} - -.admin-price-tier-match-grid { - grid-column: 1 / -1; +.admin-price-tier-mode-row { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; + min-width: 0; + grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); + align-items: center; + gap: 16px; + background: color-mix(in srgb, var(--foreground) 0.8%, transparent); + padding: 9px 12px; } -.admin-price-tier-upstream { - grid-column: span 2; +.admin-price-tier-mode-row .admin-price-tier-panel-heading { + margin-bottom: 0; } -.admin-price-tier-billing-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - align-items: start; - gap: 12px; +.admin-price-tier-mode-row .admin-price-tier-panel-heading > div { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 2px 8px; } -.admin-price-tier-token-grid { - grid-column: 1 / -1; +.admin-price-tier-mode-row .admin-price-tier-panel-heading p { + margin-top: 0; } -.admin-price-tier-token-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; +.admin-price-tier-mode-control { + min-width: 0; } -.admin-price-tier-billing-mode .ant-segmented { - border: 1px solid var(--admin-card-border); +.admin-price-tier-mode-control .ant-segmented { + width: 100%; + border: 0; background: var(--admin-layer-2); padding: 2px; } -.admin-price-tier-billing-mode .ant-segmented-item { - min-height: 28px; - border-radius: 5px; +.admin-price-tier-workspace { + display: grid; + min-width: 0; + align-items: start; + gap: 12px; + padding: 10px 12px 12px; } -.admin-price-tier-billing-mode .ant-segmented-item-label { - min-height: 28px; - padding-inline: 12px; - line-height: 28px; +.admin-price-tier-workspace.is-advanced { + grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.6fr); } -.admin-price-tier-billing-mode .ant-segmented-item-selected { - background: var(--admin-layer-1); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 14%, transparent); +.admin-price-tier-workspace.is-default { + grid-template-columns: minmax(0, 1fr); } -.admin-price-tier-unit-price .ant-input-number { +.admin-price-tier-panel { + min-width: 0; + border-radius: 8px; + background: color-mix(in srgb, var(--foreground) 1.8%, var(--admin-layer-2)); + padding: 11px 12px 12px; +} + +.admin-price-tier-panel-heading { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.admin-price-tier-panel-heading > div, +.admin-price-tier-cost-heading { + display: flex; + min-width: 0; + align-items: baseline; + flex-wrap: wrap; + gap: 2px 9px; +} + +.admin-price-tier-panel-heading h3, +.admin-price-tier-cost-title { + margin: 0; + color: var(--admin-text-primary); + font-size: 12px; + font-weight: 650; + line-height: 1.4; +} + +.admin-price-tier-panel-heading p, +.admin-price-tier-cost-heading p { + margin: 0; + color: var(--admin-text-muted); + font-size: 11px; + line-height: 1.4; +} + +.admin-price-tier-match-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-items: start; + gap: 8px 10px; +} + +.admin-price-tier-upstream { + grid-column: 1 / -1; +} + +.admin-price-tier-billing-grid { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(190px, 0.85fr); + align-items: start; + gap: 10px; +} + +.admin-price-tier-token-grid { + display: grid; + grid-column: 1 / -1; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.admin-price-tier-cost-panel { + min-width: 0; + margin-top: 10px; + border-radius: 6px; + background: color-mix(in srgb, var(--foreground) 2.2%, transparent); + padding: 9px 10px 10px; +} + +.admin-price-tier-cost-header { + justify-content: space-between; + gap: 10px; +} + +.admin-price-tier-cost-heading { + min-width: 0; +} + +.admin-price-tier-cost-title { + gap: 8px; +} + +.admin-price-tier-cost-badge { + border: 1px solid color-mix(in srgb, var(--workspace-accent) 24%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--workspace-accent) 8%, transparent); + padding: 2px 7px; + color: color-mix(in srgb, var(--workspace-accent) 72%, var(--admin-text-primary)); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.admin-price-tier-cost-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + align-items: start; + gap: 10px; + margin-top: 8px; +} + +.admin-price-tier-cost-token-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.admin-price-tier-cost-switch { + flex: 0 0 auto; +} + +.admin-price-tier-alert { + margin: 0 16px 16px; +} + +.admin-price-tier-helper { + display: flex; + max-width: none; + align-items: baseline; + flex-wrap: wrap; + gap: 2px 8px; + margin-top: 8px; + border-radius: 6px; + background: color-mix(in srgb, var(--workspace-accent) 3.5%, transparent); + padding: 6px 8px; + color: var(--admin-text-muted); + font-size: 10px; + line-height: 1.4; +} + +.admin-price-tier-helper strong { + color: var(--admin-text-secondary); + flex: 0 0 auto; + font-size: 10px; + font-weight: 650; +} + +.admin-price-tier-helper span { + min-width: 240px; + flex: 1 1 520px; +} + +.admin-price-tier-unit-price .ant-input-number, +.admin-price-tier-cost-grid .ant-input-number { width: 100% !important; } @@ -9366,20 +9574,50 @@ display: flex; min-width: 0; align-items: center; - justify-content: space-between; - gap: 10px; + gap: 7px; + border: 0; + border-radius: 999px; + background: color-mix(in srgb, var(--foreground) 3%, transparent); + padding: 3px 6px 3px 8px; } .admin-price-tier-toggle span { overflow-wrap: anywhere; color: var(--admin-text-muted); - font-size: 12px; + font-size: 11px; line-height: 1.4; white-space: normal; } .admin-model-editor-add-tier.ant-btn { min-height: 38px; + border-color: color-mix(in srgb, var(--workspace-accent) 28%, var(--admin-card-border)); + background: color-mix(in srgb, var(--workspace-accent) 4%, transparent); +} + +.admin-price-tier-billing-mode .ant-segmented { + border: 0; + background: var(--admin-layer-2); + padding: 2px; +} + +.admin-price-tier-mode-control .ant-segmented-item, +.admin-price-tier-billing-mode .ant-segmented-item { + min-height: 28px; + border-radius: 5px; +} + +.admin-price-tier-mode-control .ant-segmented-item-label, +.admin-price-tier-billing-mode .ant-segmented-item-label { + min-height: 28px; + padding-inline: 12px; + line-height: 28px; +} + +.admin-price-tier-mode-control .ant-segmented-item-selected, +.admin-price-tier-billing-mode .ant-segmented-item-selected { + background: var(--admin-layer-1); + box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 7%, transparent); } .admin-model-editor-footer-actions { @@ -9441,14 +9679,19 @@ :root:not(.dark) .admin-model-editor-drawer .admin-capability-reference-card, :root:not(.dark) .admin-model-editor-drawer .admin-capability-protocol-card, :root:not(.dark) .admin-price-tier-toggle, +:root:not(.dark) .admin-price-tier-mode-control .ant-segmented, :root:not(.dark) .admin-price-tier-billing-mode .ant-segmented { border-color: rgba(17, 17, 17, 0.18); background: #ffffff; } :root:not(.dark) .admin-price-tier-card { - border-color: rgba(17, 17, 17, 0.18); - background: #f7f7f7; + border-color: rgba(17, 17, 17, 0.1); + background: #ffffff; +} + +:root:not(.dark) .admin-price-tier-panel { + background: #f8f8f8; } :root:not(.dark) .admin-model-editor-drawer .admin-capability-reference-card, @@ -9492,10 +9735,12 @@ box-shadow: 0 6px 20px rgb(17 17 17 / 0.08); } +:root:not(.dark) .admin-price-tier-mode-control .ant-segmented-item-selected, :root:not(.dark) .admin-price-tier-billing-mode .ant-segmented-item-selected { background: #ececec; - box-shadow: inset 0 0 0 1px rgb(17 17 17 / 0.12); + box-shadow: 0 1px 2px rgb(17 17 17 / 0.08); } + @media (max-width: 600px) { .admin-model-editor-modal .ant-modal-container { height: calc(100dvh - 24px); @@ -9513,6 +9758,23 @@ .admin-model-editor-modal .admin-capability-protocol-grid { grid-template-columns: minmax(0, 1fr); } + .admin-price-tier-card-header { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + .admin-price-tier-card-actions { + width: 100%; + justify-content: space-between; + } + .admin-price-tier-mode-row { + grid-template-columns: minmax(0, 1fr); + gap: 7px; + padding: 9px 10px; + } + .admin-price-tier-workspace { + padding: 10px; + } .admin-price-tier-upstream { grid-column: 1 / -1; } @@ -9520,14 +9782,15 @@ .admin-model-protocol-grid, .admin-price-tier-billing-grid, .admin-price-tier-match-grid, - .admin-price-tier-token-grid { + .admin-price-tier-token-grid, + .admin-price-tier-cost-token-grid { grid-template-columns: minmax(0, 1fr); } } @media (max-width: 899px) { .admin-model-editor-section, - .admin-price-tier-card-body { + .admin-price-tier-workspace.is-advanced { grid-template-columns: minmax(0, 1fr); } } @@ -10840,7 +11103,6 @@ html[data-magicui-theme-vt="active"] .admin-shell * { line-height: 1; } - .admin-banner-icon-picker-option.is-active { border-color: color-mix(in srgb, var(--foreground) 22%, transparent); background: var(--control-selected-bg); @@ -10943,3 +11205,37 @@ html[data-magicui-theme-vt="active"] .admin-shell * { .admin-banner-type-card[data-banner-type="warning"] .admin-banner-type-card-preview { background: var(--notice-banner-surface-warning); } + +/* 模型定价编辑器:弹窗内只保留一个滚动区域。 */ +.admin-model-editor-modal .ant-modal-body { + display: flex; + min-height: 0; + overflow: hidden; +} + +.admin-model-editor-modal .admin-model-editor-form { + display: flex; + min-height: 0; + flex: 1 1 auto; + overflow: hidden; +} + +.admin-model-editor-modal .admin-model-editor-form > .ant-tabs { + display: flex; + min-height: 0; + flex: 1 1 auto; + flex-direction: column; +} + +.admin-model-editor-modal .admin-model-editor-form > .ant-tabs > .ant-tabs-body-holder { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.admin-model-editor-modal .admin-model-editor-form > .ant-tabs > .ant-tabs-body-holder > .ant-tabs-content, +.admin-model-editor-modal .admin-model-editor-form > .ant-tabs > .ant-tabs-body-holder > .ant-tabs-content > .ant-tabs-tabpane { + min-height: 100%; +} diff --git a/web/test/channel-model-editor-form.test.ts b/web/test/channel-model-editor-form.test.ts index 19eb1889a..897b27265 100644 --- a/web/test/channel-model-editor-form.test.ts +++ b/web/test/channel-model-editor-form.test.ts @@ -83,11 +83,12 @@ describe("pricing write validation", () => { video.priceTiers[0].outputTokenPrice = 0.000001; expect(() => validateChannelModelPrices(video)).not.toThrow(); }); - test("accepts audio-only video price conditions and rejects invalid audio selectors", () => { - for (const videoGenerateAudio of ["true", "false"]) { - expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), billingMode: "token", videoGenerateAudio }] })).not.toThrow(); + test("rejects removed video conditions and accepts visible operation or resolution matches", () => { + for (const hiddenCondition of [{ videoGenerateAudio: "false" }, { videoSeconds: 5 }, { imageCount: 2 }]) { + expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), billingMode: "token", ...hiddenCondition }] })).toThrow("匹配条件"); } - expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), videoGenerateAudio: "yes" }] })).toThrow("有声或无声"); + expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), operation: "image_to_video" }] })).not.toThrow(); + expect(() => validateChannelModelPrices({ ...draft, capability: "video", protocol: "volcengine-ark-video", priceTiers: [{ ...defaultPriceTier("advanced"), resolution: "1080p" }] })).not.toThrow(); }); }); diff --git a/web/test/channel-model-price-tier-form.test.ts b/web/test/channel-model-price-tier-form.test.ts index 8643d6a2b..e86974dce 100644 --- a/web/test/channel-model-price-tier-form.test.ts +++ b/web/test/channel-model-price-tier-form.test.ts @@ -70,21 +70,22 @@ describe("channel model price tier defaults", () => { expect(priceTierToForm(base).matchMode).toBe("default"); expect(priceTierToForm({ ...base, selector: { quality: "2k" } }).matchMode).toBe("advanced"); - expect(priceTierToForm({ ...base, selector: { videoGenerateAudio: "false" } })).toMatchObject({ matchMode: "advanced", videoGenerateAudio: "false" }); + expect(priceTierToForm({ ...base, selector: { videoGenerateAudio: "false" } })).toMatchObject({ matchMode: "default", videoGenerateAudio: "false" }); }); - test("preserves explicit silent and audible selectors but omits the wildcard", () => { + test("keeps video matching limited to operation and resolution", () => { for (const videoGenerateAudio of ["true", "false"]) { - const tier = { ...defaultPriceTier("advanced"), videoGenerateAudio }; - expect(skuSelectorFromForm("video", tier)).toEqual({ videoGenerateAudio }); - expect(skuSelectorFromForm("image", tier)).toEqual({}); + const tier = { ...defaultPriceTier("advanced"), operation: "image_to_video", resolution: "1080p", videoSeconds: 10, videoGenerateAudio, imageCount: 2 }; + expect(skuSelectorFromForm("video", tier)).toEqual({ operation: "image_to_video", vquality: "1080p" }); + expect(priceTierVideoSecondsFromForm("video", tier)).toBe(0); + expect(skuSelectorFromForm("image", { ...defaultPriceTier("advanced"), videoSeconds: 10, videoGenerateAudio, imageCount: 2 })).toEqual({}); } expect(skuSelectorFromForm("video", defaultPriceTier("advanced"))).toEqual({}); }); test("video Token writes discard hidden text prices without changing the configured video rate", () => { const tier = { ...defaultPriceTier("advanced"), billingMode: "token" as const, videoGenerateAudio: "false", inputTokenPrice: 10, outputTokenPrice: 0.25, cachedTokenPrice: 5 }; - expect(priceTierPayloadFromForm("video", tier, "seedance")).toMatchObject({ selector: { videoGenerateAudio: "false" }, providerModelKey: "seedance", inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 0 }); + expect(priceTierPayloadFromForm("video", tier, "seedance")).toMatchObject({ selector: {}, videoSeconds: 0, providerModelKey: "seedance", inputTokenPriceMicrocredits: 0, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 0 }); expect(priceTierPayloadFromForm("text", tier, "text-model")).toMatchObject({ inputTokenPriceMicrocredits: 10_000_000, outputTokenPriceMicrocredits: 250_000, cachedTokenPriceMicrocredits: 5_000_000 }); expect(priceTierPayloadFromForm("video", { ...tier, outputTokenPrice: 0 }, "seedance").outputTokenPriceMicrocredits).toBe(0); });