feat: add local auth billing and usage management
This commit is contained in:
1 parent
f642b5e71f
commit
196fdde83f
119 files changed
+15695
-2650
No files matched your search
@@ -3,13 +3,14 @@ import {
|
||||
createAsset,
|
||||
createGenerationJob,
|
||||
getGenerationJob,
|
||||
recordUsageEvent,
|
||||
recordUsageForJob,
|
||||
updateGenerationJob
|
||||
} from "@/lib/server/data-store";
|
||||
import { chargeGenerationJob, quoteGenerationCharge, settleSeedanceGenerationCharge } from "@/lib/server/billing-service";
|
||||
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
|
||||
import { importRemoteAssetAsAsset } from "@/lib/server/storage";
|
||||
import { createSeedanceTask, getSeedanceConfig, querySeedanceTask, shouldMockSeedance, type SeedanceSettings } from "@/lib/seedance/client";
|
||||
import type { GenerationJob } from "@/lib/types";
|
||||
import type { BillingJobCharge, GenerationJob, UsageContext } from "@/lib/types";
|
||||
import { normalizeVideoDuration, normalizeVideoRatio, normalizeVideoResolution } from "@/lib/video-settings";
|
||||
import { bailianResultUrls, bailianStatus, bailianTaskId, buildBailianVideoPayload, getBailianConfig, queryBailianTask, shouldMockBailian, submitBailianTask } from "@/lib/bailian/client";
|
||||
|
||||
@@ -28,11 +29,91 @@ export type SubmitVideoJobInput = PromptAssemblyInput & {
|
||||
priority?: number;
|
||||
maxAttempts?: number;
|
||||
webhookUrl?: string;
|
||||
usageContext?: UsageContext;
|
||||
};
|
||||
|
||||
type PreparedVideoGeneration = {
|
||||
ownerId: string;
|
||||
engine: VideoCreationEngine;
|
||||
config: ReturnType<typeof getSeedanceConfig>;
|
||||
assembled: ReturnType<typeof assemblePrompt>;
|
||||
finalPrompt: string;
|
||||
settings: SeedanceSettings;
|
||||
missingBailianKey: boolean;
|
||||
provider: "mock" | "bailian" | "seedance";
|
||||
reqKey: string;
|
||||
requestPayload: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export async function quoteVideoGeneration(input: SubmitVideoJobInput, origin: string): Promise<BillingJobCharge | undefined> {
|
||||
const prepared = prepareVideoGeneration(input, origin);
|
||||
if (prepared.missingBailianKey) return undefined;
|
||||
return quoteGenerationCharge({
|
||||
provider: prepared.provider,
|
||||
capability: "video.generate",
|
||||
reqKey: prepared.reqKey,
|
||||
requestPayload: prepared.requestPayload,
|
||||
usageContext: input.usageContext,
|
||||
externalClientId: input.externalClientId,
|
||||
allowUnboundOrganization: true
|
||||
});
|
||||
}
|
||||
|
||||
export async function submitVideoJob(input: SubmitVideoJobInput, origin: string): Promise<GenerationJob> {
|
||||
const prepared = prepareVideoGeneration(input, origin);
|
||||
const { ownerId, engine, config, assembled, finalPrompt, settings, missingBailianKey, provider, reqKey, requestPayload } = prepared;
|
||||
const billing = missingBailianKey ? undefined : await quoteGenerationCharge({
|
||||
provider,
|
||||
capability: "video.generate",
|
||||
reqKey,
|
||||
requestPayload,
|
||||
usageContext: input.usageContext,
|
||||
externalClientId: input.externalClientId
|
||||
});
|
||||
let job = await createGenerationJob({
|
||||
ownerId,
|
||||
externalClientId: input.externalClientId,
|
||||
capability: "video.generate",
|
||||
provider,
|
||||
reqKey,
|
||||
status: missingBailianKey ? "failed" : "queued",
|
||||
prompt: finalPrompt,
|
||||
inputAssetIds: input.materials?.map((material) => material.id).filter(Boolean) as string[] || [],
|
||||
inputUrls: assembled.materials.map((material) => material.url),
|
||||
outputAssetIds: [],
|
||||
requestPayload,
|
||||
error: missingBailianKey ? { message: "缺少 BAILIAN_API_KEY,请先在设置页配置阿里云百炼 API Key。", retryable: true } : undefined,
|
||||
retryOf: input.retryOf,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
idempotencyFingerprint: input.idempotencyFingerprint,
|
||||
priority: input.priority,
|
||||
maxAttempts: input.maxAttempts,
|
||||
webhookUrl: input.webhookUrl,
|
||||
usageContext: input.usageContext,
|
||||
billing
|
||||
});
|
||||
if (!billing) return job;
|
||||
try {
|
||||
return await chargeGenerationJob(job);
|
||||
} catch (error) {
|
||||
await updateGenerationJob(job.id, {
|
||||
status: "failed",
|
||||
billing: { ...billing, status: "not_charged" },
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
retryable: false
|
||||
}
|
||||
}).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareVideoGeneration(input: SubmitVideoJobInput, origin: string): PreparedVideoGeneration {
|
||||
const ownerId = input.ownerId || DEFAULT_OWNER_ID;
|
||||
const engine: VideoCreationEngine = input.engine === "bailian" ? "bailian" : "seedance";
|
||||
const configuredEngine = process.env.VIDEO_GENERATE_ENGINE === "seedance" ? "seedance" : "bailian";
|
||||
const engine: VideoCreationEngine = input.engine === "seedance" || input.engine === "bailian"
|
||||
? input.engine
|
||||
: configuredEngine;
|
||||
const config = getSeedanceConfig();
|
||||
const assembled = assemblePrompt({
|
||||
...input,
|
||||
@@ -52,39 +133,32 @@ export async function submitVideoJob(input: SubmitVideoJobInput, origin: string)
|
||||
}
|
||||
const mock = engine === "bailian" ? shouldMockBailian() : shouldMockSeedance();
|
||||
const missingBailianKey = engine === "bailian" && !mock && !getBailianConfig().apiKey;
|
||||
let job = await createGenerationJob({
|
||||
const provider = mock ? "mock" : engine;
|
||||
const requestPayload = {
|
||||
input,
|
||||
assembled,
|
||||
settings,
|
||||
engine
|
||||
};
|
||||
return {
|
||||
ownerId,
|
||||
externalClientId: input.externalClientId,
|
||||
capability: "video.generate",
|
||||
provider: mock ? "mock" : engine,
|
||||
engine,
|
||||
config,
|
||||
assembled,
|
||||
finalPrompt,
|
||||
settings,
|
||||
missingBailianKey,
|
||||
provider,
|
||||
reqKey: engine === "bailian" ? getBailianConfig().videoModel : config.model,
|
||||
status: missingBailianKey ? "failed" : "queued",
|
||||
prompt: finalPrompt,
|
||||
inputAssetIds: input.materials?.map((material) => material.id).filter(Boolean) as string[] || [],
|
||||
inputUrls: assembled.materials.map((material) => material.url),
|
||||
outputAssetIds: [],
|
||||
requestPayload: {
|
||||
input,
|
||||
assembled,
|
||||
settings,
|
||||
engine
|
||||
},
|
||||
error: missingBailianKey ? { message: "缺少 BAILIAN_API_KEY,请先在设置页配置阿里云百炼 API Key。", retryable: true } : undefined,
|
||||
retryOf: input.retryOf,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
idempotencyFingerprint: input.idempotencyFingerprint,
|
||||
priority: input.priority,
|
||||
maxAttempts: input.maxAttempts,
|
||||
webhookUrl: input.webhookUrl
|
||||
});
|
||||
|
||||
return job;
|
||||
requestPayload
|
||||
};
|
||||
}
|
||||
|
||||
export async function advanceVideoJob(jobId: string, origin: string): Promise<GenerationJob> {
|
||||
const job = await getGenerationJob(jobId);
|
||||
let job = await getGenerationJob(jobId);
|
||||
if (!job) throw new Error(`Generation job not found: ${jobId}`);
|
||||
if (["succeeded", "failed", "cancelled", "expired"].includes(job.status)) return job;
|
||||
if (job.billing?.status === "pending") job = await chargeGenerationJob(job);
|
||||
if (job.provider === "mock") return completeMockVideoJob(job);
|
||||
if (!job.providerTaskId) return dispatchVideoJob(job, origin);
|
||||
return syncVideoJob(job.id, origin);
|
||||
@@ -143,7 +217,7 @@ export async function syncVideoJob(jobId: string, origin: string): Promise<Gener
|
||||
error: result.errorMessage ? { message: result.errorMessage, retryable: result.status === "failed" } : undefined
|
||||
});
|
||||
}
|
||||
return completeRemoteVideo(job, origin, result.resultUrl, result.raw);
|
||||
return completeRemoteVideo(job, origin, result.resultUrl, result.raw, result.usage?.completionTokens);
|
||||
} catch (error) {
|
||||
return updateGenerationJob(job.id, {
|
||||
status: "failed",
|
||||
@@ -155,26 +229,29 @@ export async function syncVideoJob(jobId: string, origin: string): Promise<Gener
|
||||
}
|
||||
}
|
||||
|
||||
async function completeRemoteVideo(job: GenerationJob, origin: string, resultUrl: string, responsePayload: Record<string, unknown>) {
|
||||
async function completeRemoteVideo(
|
||||
job: GenerationJob,
|
||||
origin: string,
|
||||
resultUrl: string,
|
||||
responsePayload: Record<string, unknown>,
|
||||
completionTokens?: number
|
||||
) {
|
||||
const settledJob = job.provider === "seedance"
|
||||
? await settleSeedanceGenerationCharge(job, completionTokens) || job
|
||||
: job;
|
||||
const asset = await importRemoteAssetAsAsset({
|
||||
ownerId: job.ownerId,
|
||||
ownerId: settledJob.ownerId,
|
||||
url: resultUrl,
|
||||
origin,
|
||||
source: "generated",
|
||||
capability: "video.generate",
|
||||
jobId: job.id,
|
||||
jobId: settledJob.id,
|
||||
index: 0,
|
||||
fallbackContentType: "video/mp4",
|
||||
tags: assetTagsForJob(job)
|
||||
tags: assetTagsForJob(settledJob)
|
||||
});
|
||||
await recordUsageEvent({
|
||||
ownerId: job.ownerId,
|
||||
jobId: job.id,
|
||||
capability: "video.generate",
|
||||
quantity: 1,
|
||||
estimatedUnit: "job"
|
||||
});
|
||||
return updateGenerationJob(job.id, {
|
||||
await recordUsageForJob(settledJob);
|
||||
return updateGenerationJob(settledJob.id, {
|
||||
status: "succeeded",
|
||||
outputAssetIds: [asset.id],
|
||||
responsePayload
|
||||
@@ -186,7 +263,12 @@ export async function retryVideoJob(jobId: string, origin: string, ownerId?: str
|
||||
if (!job) throw new Error(`Generation job not found: ${jobId}`);
|
||||
if (ownerId && job.ownerId !== ownerId) throw new Error(`Generation job not found: ${jobId}`);
|
||||
const input = (job.requestPayload.input || {}) as SubmitVideoJobInput;
|
||||
return submitVideoJob({ ...input, ownerId: ownerId || job.ownerId, retryOf: job.id }, origin);
|
||||
return submitVideoJob({
|
||||
...input,
|
||||
ownerId: ownerId || job.ownerId,
|
||||
usageContext: job.usageContext || input.usageContext,
|
||||
retryOf: job.id
|
||||
}, origin);
|
||||
}
|
||||
|
||||
async function completeMockVideoJob(job: GenerationJob): Promise<GenerationJob> {
|
||||
@@ -204,13 +286,6 @@ async function completeMockVideoJob(job: GenerationJob): Promise<GenerationJob>
|
||||
jobId: job.id
|
||||
}
|
||||
});
|
||||
await recordUsageEvent({
|
||||
ownerId: job.ownerId,
|
||||
jobId: job.id,
|
||||
capability: "video.generate",
|
||||
quantity: 1,
|
||||
estimatedUnit: "job"
|
||||
});
|
||||
return updateGenerationJob(job.id, {
|
||||
status: "succeeded",
|
||||
outputAssetIds: [asset.id],
|
||||
|
||||
Reference in new issue
Block a user