若干更新迭代
This commit is contained in:
1 parent
deb9cc5bcf
commit
4ac786aafb
64 files changed
+3251
-291
No files matched your search
@@ -125,7 +125,7 @@ const settingDefinitions: Array<{
|
||||
{
|
||||
id: "bailian",
|
||||
title: "阿里云百炼 API",
|
||||
description: "用于万相 2.7 文生图、参考图生图和图生视频。Base URL 会自动派生原生异步接口。",
|
||||
description: "支持万相 2.7 图片与首尾帧视频、万相 3.0 多图参考视频。请使用与模型开通地域一致的 API Key 和 Base URL。",
|
||||
fields: [
|
||||
{ key: "BAILIAN_API_KEY", label: "百炼 API Key", secret: true, type: "password" },
|
||||
{ key: "BAILIAN_BASE_URL", label: "Base URL", defaultValue: "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1" },
|
||||
@@ -136,7 +136,7 @@ const settingDefinitions: Array<{
|
||||
{
|
||||
id: "minimax",
|
||||
title: "MiniMax API",
|
||||
description: "用于 MiniMax H3 文生视频与单首帧图生视频。",
|
||||
description: "支持 MiniMax H3 文生视频、单首帧视频和最多 9 张图片的多图参考视频。",
|
||||
fields: [
|
||||
{ key: "MINIMAX_API_KEY", label: "MiniMax API Key", secret: true, type: "password" },
|
||||
{ key: "MINIMAX_BASE_URL", label: "Base URL", defaultValue: "https://api.minimax.cn" }
|
||||
@@ -177,7 +177,7 @@ const engineFieldDefinitions: FieldDefinition[] = [
|
||||
options: [
|
||||
{ label: "Seedance", value: "seedance" },
|
||||
{ label: "MiniMax H3", value: "minimax" },
|
||||
{ label: "阿里云百炼 Wan 2.7", value: "bailian" }
|
||||
{ label: "阿里云百炼 Wan 2.7 / 3.0", value: "bailian" }
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
@@ -231,6 +231,25 @@ export const DEFAULT_BILLING_PRICE_RULES: BillingPriceRuleInput[] = [
|
||||
observedAt: BILLING_CATALOG_OBSERVED_AT
|
||||
}
|
||||
},
|
||||
...([{"resolution": "480p", "priceFen": 30}, {"resolution": "720p", "priceFen": 60}, {"resolution": "1080p", "priceFen": 120}] as const).map(({ resolution, priceFen }) => ({
|
||||
id: `base-bailian-wan30-video-${resolution}`,
|
||||
provider: "bailian" as const,
|
||||
capability: "video.generate" as const,
|
||||
reqKey: "wan3.0-video",
|
||||
variantKey: `resolution=${resolution}`,
|
||||
unit: "video_second" as const,
|
||||
standardUnitPriceFen: priceFen,
|
||||
markupMultiplier: DEFAULT_BILLING_MARKUP_MULTIPLIER,
|
||||
enabled: true,
|
||||
note: `北京地域官方原价 ¥${(priceFen / 100).toFixed(2)}/秒;官网限时折扣未计入标准成本,图片输入不另收费。`,
|
||||
source: {
|
||||
url: "https://help.aliyun.com/zh/model-studio/model-pricing",
|
||||
currency: "CNY" as const,
|
||||
unitPrice: priceFen / 100,
|
||||
basis: "北京地域官方原价;限时7折未计入标准成本;仅输出视频按实际秒数计费(本接入仅支持图片输入)",
|
||||
observedAt: "2026-10-01"
|
||||
}
|
||||
})),
|
||||
{
|
||||
id: "base-seedance-2-0-480p",
|
||||
provider: "seedance",
|
||||
|
||||
@@ -101,6 +101,15 @@ export async function quoteGenerationCharge(input: BillingGenerationInput): Prom
|
||||
};
|
||||
}
|
||||
if (input.provider === "minimax" && input.reqKey === MINIMAX_H3_MODEL) {
|
||||
const inputImageCount = Math.ceil(Number(parameters.referenceImageCount) || 0);
|
||||
const inputImagePriceFen = Math.max(0, inputImageCount - 5) * 20;
|
||||
const amountFen = Math.ceil((quote.standardUnitPriceFen * quantity + inputImagePriceFen) * quote.markupMultiplier);
|
||||
return {
|
||||
...quote, amountFen, reservedAmountFen: amountFen, settlementStatus: "pending",
|
||||
parameters: { ...quote.parameters, inputImageCount, inputImagePriceFen }
|
||||
};
|
||||
}
|
||||
if (input.provider === "bailian" && input.reqKey === "wan3.0-video") {
|
||||
return { ...quote, reservedAmountFen: quote.amountFen, settlementStatus: "pending" };
|
||||
}
|
||||
if (input.provider !== "seedance" || !isSeedanceBillingModel(input.reqKey)) {
|
||||
@@ -135,7 +144,7 @@ export async function chargeGenerationJob(job: GenerationJob): Promise<Generatio
|
||||
...billing,
|
||||
quotaExempt: true,
|
||||
status: "not_charged",
|
||||
settlementStatus: job.provider === "seedance" || job.provider === "minimax" ? "pending" : billing.settlementStatus
|
||||
settlementStatus: job.provider === "seedance" || job.provider === "minimax" || job.provider === "bailian" && job.reqKey === "wan3.0-video" ? "pending" : billing.settlementStatus
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -278,7 +287,7 @@ export async function settleSeedanceGenerationCharge(
|
||||
|
||||
export async function settleMinimaxGenerationCharge(
|
||||
jobOrId: GenerationJob | string,
|
||||
usage?: { outputSeconds: number; inputSeconds: number; inputImageCount: number }
|
||||
usage?: { outputSeconds: number; inputSeconds: number; inputImageCount?: number }
|
||||
): Promise<GenerationJob | null> {
|
||||
const job = typeof jobOrId === "string" ? await getGenerationJob(jobOrId) : jobOrId;
|
||||
if (!job) return null;
|
||||
@@ -293,9 +302,12 @@ export async function settleMinimaxGenerationCharge(
|
||||
}
|
||||
|
||||
const resolution = billing.parameters?.resolution;
|
||||
const inputImageCount = Number.isInteger(usage.inputImageCount) && (usage.inputImageCount ?? -1) >= 0
|
||||
? usage.inputImageCount!
|
||||
: minimaxQuotedInputImageCount(billing.parameters, job.requestPayload);
|
||||
const actualAmountFen = calculateMinimaxH3ActualAmountFen({
|
||||
resolution, outputSeconds: usage.outputSeconds, inputVideoSeconds: usage.inputSeconds,
|
||||
inputImageCount: usage.inputImageCount, markupMultiplier: billing.markupMultiplier
|
||||
inputImageCount, markupMultiplier: billing.markupMultiplier
|
||||
});
|
||||
const deltaFen = actualAmountFen - billing.amountFen;
|
||||
let settlementLedgerEntryId: string | undefined;
|
||||
@@ -309,7 +321,7 @@ export async function settleMinimaxGenerationCharge(
|
||||
metadata: {
|
||||
operation: "minimax_h3_actual_settlement", reservedAmountFen: billing.reservedAmountFen ?? billing.amountFen,
|
||||
actualAmountFen, outputSeconds: usage.outputSeconds, inputVideoSeconds: usage.inputSeconds,
|
||||
inputImageCount: usage.inputImageCount, resolution: String(resolution || "768P"), model: job.reqKey
|
||||
inputImageCount, resolution: String(resolution || "768P"), model: job.reqKey
|
||||
}
|
||||
});
|
||||
settlementLedgerEntryId = settlement.entry.id;
|
||||
@@ -320,13 +332,58 @@ export async function settleMinimaxGenerationCharge(
|
||||
...billing, amountFen: actualAmountFen, settlementStatus: "settled", settlementLedgerEntryId, settledAt,
|
||||
providerUsage: {
|
||||
model: job.reqKey, resolution: String(resolution || "768P"), outputSeconds: usage.outputSeconds,
|
||||
inputVideoSeconds: usage.inputSeconds, inputImageCount: usage.inputImageCount,
|
||||
inputVideoSeconds: usage.inputSeconds, inputImageCount,
|
||||
videoPriceFenPerSecond: minimaxH3VideoPriceFenPerSecond(resolution)
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function settleBailianWan3GenerationCharge(
|
||||
jobOrId: GenerationJob | string,
|
||||
outputSeconds?: number
|
||||
): Promise<GenerationJob | null> {
|
||||
const job = typeof jobOrId === "string" ? await getGenerationJob(jobOrId) : jobOrId;
|
||||
if (!job) return null;
|
||||
const billing = job.billing;
|
||||
const quotaExempt = isQuotaExemptBilling(job);
|
||||
const organizationId = job.usageContext?.organizationId;
|
||||
const chargeReady = billing?.status === "charged" || quotaExempt && billing?.status === "not_charged";
|
||||
if (job.provider !== "bailian" || job.reqKey !== "wan3.0-video" || !billing || !chargeReady || !quotaExempt && !organizationId) return job;
|
||||
if (billing.settlementStatus === "settled" || billing.settlementStatus === "estimated") return job;
|
||||
if (!Number.isFinite(outputSeconds) || !outputSeconds || outputSeconds <= 0) {
|
||||
return updateGenerationJob(job.id, { billing: { ...billing, settlementStatus: "estimated", settlementReason: "provider_usage_unavailable", settledAt: new Date().toISOString() } });
|
||||
}
|
||||
|
||||
const actualAmountFen = Math.ceil(outputSeconds * billing.standardUnitPriceFen * billing.markupMultiplier);
|
||||
const deltaFen = actualAmountFen - billing.amountFen;
|
||||
let settlementLedgerEntryId: string | undefined;
|
||||
let settledAt = new Date().toISOString();
|
||||
if (deltaFen !== 0 && !quotaExempt && organizationId) {
|
||||
const settlement = await postWalletEntry({
|
||||
organizationId, accountId: job.usageContext?.accountId, jobId: job.id,
|
||||
kind: deltaFen > 0 ? "charge" : "refund", deltaFen: -deltaFen,
|
||||
idempotencyKey: `job-settlement:${job.id}`,
|
||||
description: deltaFen > 0 ? `${capabilityLabel(job.capability)}实际用量补扣` : `${capabilityLabel(job.capability)}实际用量差额退回`,
|
||||
metadata: {
|
||||
operation: "bailian_wan30_actual_settlement", reservedAmountFen: billing.reservedAmountFen ?? billing.amountFen,
|
||||
actualAmountFen, outputSeconds, resolution: String(billing.parameters?.resolution || "720p"), model: job.reqKey
|
||||
}
|
||||
});
|
||||
settlementLedgerEntryId = settlement.entry.id;
|
||||
settledAt = settlement.entry.createdAt;
|
||||
}
|
||||
return updateGenerationJob(job.id, {
|
||||
billing: {
|
||||
...billing, amountFen: actualAmountFen, settlementStatus: "settled", settlementLedgerEntryId, settledAt,
|
||||
providerUsage: {
|
||||
model: job.reqKey, resolution: String(billing.parameters?.resolution || "720p"), outputSeconds,
|
||||
videoPriceFenPerSecond: billing.standardUnitPriceFen
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function isSeedanceBillingModel(model: string) {
|
||||
return model === SEEDANCE_20_MODEL || model === SEEDANCE_25_MODEL;
|
||||
}
|
||||
@@ -532,6 +589,17 @@ export function normalizeBillingParameters(requestPayload: Record<string, unknow
|
||||
return parameters;
|
||||
}
|
||||
|
||||
function minimaxQuotedInputImageCount(
|
||||
parameters: BillingParameterSnapshot | undefined,
|
||||
requestPayload: Record<string, unknown>
|
||||
): number {
|
||||
for (const value of [parameters?.inputImageCount, parameters?.referenceImageCount, normalizeBillingParameters(requestPayload).referenceImageCount]) {
|
||||
const count = Number(value);
|
||||
if (value !== undefined && Number.isInteger(count) && count >= 0) return count;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function effectiveBillingRuleConditions(rule: BillingPriceRule): BillingRuleConditions {
|
||||
const legacyConditions = parseLegacyVariantKey(rule.variantKey);
|
||||
return { ...legacyConditions, ...(rule.conditions || {}) };
|
||||
|
||||
@@ -5,13 +5,13 @@ import {
|
||||
recordUsageForJob,
|
||||
updateGenerationJob
|
||||
} from "@/lib/server/data-store";
|
||||
import { chargeGenerationJob, quoteGenerationCharge, settleMinimaxGenerationCharge, settleSeedanceGenerationCharge } from "@/lib/server/billing-service";
|
||||
import { chargeGenerationJob, quoteGenerationCharge, settleBailianWan3GenerationCharge, settleMinimaxGenerationCharge, settleSeedanceGenerationCharge } from "@/lib/server/billing-service";
|
||||
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
|
||||
import { importRemoteAssetAsAsset } from "@/lib/server/storage";
|
||||
import { createSeedanceTask, getSeedanceConfig, querySeedanceTask, type SeedanceSettings } from "@/lib/seedance/client";
|
||||
import type { BillingJobCharge, GenerationJob, UsageContext } from "@/lib/types";
|
||||
import { VIDEO_DURATION_DEFAULT, clampVideoDuration, isSupportedSeedanceModel, normalizeSeedanceModel, normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution } from "@/lib/video-settings";
|
||||
import { bailianResultUrls, bailianStatus, bailianTaskId, buildBailianVideoPayload, getBailianConfig, queryBailianTask, submitBailianTask } from "@/lib/bailian/client";
|
||||
import { bailianOutputVideoSeconds, bailianResultUrls, bailianStatus, bailianTaskId, buildBailianVideoPayload, getBailianConfig, queryBailianTask, submitBailianTask } from "@/lib/bailian/client";
|
||||
import { buildMinimaxH3Payload, createMinimaxH3Task, getMinimaxConfig, MINIMAX_H3_MODEL, queryMinimaxH3Task } from "@/lib/minimax/client";
|
||||
|
||||
export type VideoCreationEngine = "seedance" | "bailian" | "minimax";
|
||||
@@ -121,7 +121,14 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre
|
||||
if (engine === "seedance" && input.model !== undefined && !isSupportedSeedanceModel(input.model)) {
|
||||
throw new Error("不支持的 Seedance 视频模型。");
|
||||
}
|
||||
const model = normalizeSeedanceModel(input.model, config.model);
|
||||
if (engine === "minimax" && input.model !== undefined && input.model !== MINIMAX_H3_MODEL) {
|
||||
throw new Error("MiniMax 仅支持 H3 视频模型。");
|
||||
}
|
||||
const bailianModel = input.model || getBailianConfig().videoModel;
|
||||
if (engine === "bailian" && !["wan2.7-i2v-2026-04-25", "wan3.0-video", getBailianConfig().videoModel].includes(bailianModel)) {
|
||||
throw new Error("不支持的百炼视频模型。");
|
||||
}
|
||||
const model = engine === "seedance" ? normalizeSeedanceModel(input.model, config.model) : engine === "bailian" ? bailianModel : MINIMAX_H3_MODEL;
|
||||
const assembled = assemblePrompt({
|
||||
...input,
|
||||
mode: "video",
|
||||
@@ -130,9 +137,9 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre
|
||||
const finalPrompt = input.prompt?.trim() || assembled.prompt;
|
||||
const settings: SeedanceSettings = { ...(input.settings || {}) };
|
||||
if (engine === "bailian") {
|
||||
settings.duration = normalizeVideoDuration(settings.duration) ?? 10;
|
||||
settings.duration = settings.duration === undefined ? 10 : Number(settings.duration);
|
||||
settings.resolution = String(settings.resolution || "720P").toUpperCase();
|
||||
buildBailianVideoPayload({ prompt: finalPrompt, materials: assembled.materials, origin, settings });
|
||||
buildBailianVideoPayload({ prompt: finalPrompt, materials: assembled.materials, origin, settings, model });
|
||||
} else if (engine === "minimax") {
|
||||
const payload = buildMinimaxH3Payload({ prompt: finalPrompt, materials: assembled.materials, settings, origin });
|
||||
settings.duration = payload.duration;
|
||||
@@ -152,7 +159,7 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre
|
||||
assembled,
|
||||
settings,
|
||||
engine,
|
||||
...(engine === "seedance" ? { model } : {})
|
||||
model
|
||||
};
|
||||
return {
|
||||
ownerId,
|
||||
@@ -163,7 +170,7 @@ function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): Pre
|
||||
settings,
|
||||
missingProviderCredentials,
|
||||
provider: engine,
|
||||
reqKey: engine === "bailian" ? getBailianConfig().videoModel : engine === "minimax" ? MINIMAX_H3_MODEL : model,
|
||||
reqKey: model,
|
||||
requestPayload
|
||||
};
|
||||
}
|
||||
@@ -193,7 +200,7 @@ async function dispatchVideoJob(job: GenerationJob, origin: string): Promise<Gen
|
||||
? assembled.materials as PromptMaterial[]
|
||||
: input.materials || [];
|
||||
if (job.provider === "bailian") {
|
||||
const response = await submitBailianTask("video", buildBailianVideoPayload({ prompt: job.prompt || "", settings: asRecord(settings), materials, origin }));
|
||||
const response = await submitBailianTask("video", buildBailianVideoPayload({ prompt: job.prompt || "", settings: asRecord(settings), materials, origin, model: job.reqKey }));
|
||||
const providerTaskId = bailianTaskId(response);
|
||||
if (!providerTaskId) throw new Error("视频任务响应缺少 task_id。");
|
||||
return updateGenerationJob(job.id, { status: "running", providerTaskId, responsePayload: response });
|
||||
@@ -231,7 +238,7 @@ export async function syncVideoJob(jobId: string, origin: string): Promise<Gener
|
||||
if (status !== "succeeded" || !resultUrl) {
|
||||
return updateGenerationJob(job.id, { status, responsePayload: response, error: status === "failed" ? { message: response.message || "百炼视频任务失败。", retryable: false } : undefined });
|
||||
}
|
||||
return completeRemoteVideo(job, origin, resultUrl, response);
|
||||
return completeRemoteVideo(job, origin, resultUrl, response, undefined, undefined, bailianOutputVideoSeconds(response));
|
||||
}
|
||||
if (job.provider === "minimax") {
|
||||
const result = await queryMinimaxH3Task(job.providerTaskId);
|
||||
@@ -270,13 +277,16 @@ async function completeRemoteVideo(
|
||||
resultUrl: string,
|
||||
responsePayload: Record<string, unknown>,
|
||||
completionTokens?: number,
|
||||
minimaxUsage?: { outputSeconds: number; inputSeconds: number; inputImageCount: number }
|
||||
minimaxUsage?: { outputSeconds: number; inputSeconds: number; inputImageCount?: number },
|
||||
bailianOutputSeconds?: number
|
||||
) {
|
||||
const settledJob = job.provider === "seedance"
|
||||
? await settleSeedanceGenerationCharge(job, completionTokens) || job
|
||||
: job.provider === "minimax"
|
||||
? await settleMinimaxGenerationCharge(job, minimaxUsage) || job
|
||||
: job;
|
||||
: job.provider === "bailian"
|
||||
? await settleBailianWan3GenerationCharge(job, bailianOutputSeconds) || job
|
||||
: job;
|
||||
const asset = await importRemoteAssetAsAsset({
|
||||
ownerId: settledJob.ownerId,
|
||||
url: resultUrl,
|
||||
|
||||
Reference in new issue
Block a user