983 lines
33 KiB
TypeScript
983 lines
33 KiB
TypeScript
import { logInfo as _ulogInfo, logError as _ulogError } from '@/lib/logging/core'
|
||
|
||
/**
|
||
* 统一异步任务轮询模块
|
||
*
|
||
* 🔥 统一格式:PROVIDER:TYPE:REQUEST_ID
|
||
*
|
||
* 例如:
|
||
* - FAL:VIDEO:fal-ai/wan/v2.6:abc123
|
||
* - FAL:IMAGE:fal-ai/nano-banana-pro:def456
|
||
* - ARK:VIDEO:task_789
|
||
* - ARK:IMAGE:task_xyz
|
||
* - GEMINI:BATCH:batches/ghi012
|
||
*
|
||
* 注意:
|
||
* - 仅接受标准 externalId(不再兼容历史拼装格式)
|
||
*/
|
||
|
||
import { queryFalStatus } from './async-submit'
|
||
import { queryGeminiBatchStatus, querySeedanceVideoStatus, queryGoogleVideoStatus } from './async-task-utils'
|
||
import { getProviderConfig, getUserModels } from './api-config'
|
||
import { buildRenderedTemplateRequest, buildTemplateVariables, normalizeResponseJson, readJsonPath } from './openai-compat-template-runtime'
|
||
import { composeModelKey } from './model-config-contract'
|
||
|
||
const OPENAI_COMPAT_PROVIDER_PREFIX = 'openai-compatible:'
|
||
const PROVIDER_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||
|
||
export interface PollResult {
|
||
status: 'pending' | 'completed' | 'failed'
|
||
resultUrl?: string
|
||
imageUrl?: string
|
||
videoUrl?: string
|
||
actualVideoTokens?: number
|
||
downloadHeaders?: Record<string, string>
|
||
error?: string
|
||
}
|
||
|
||
function getErrorMessage(error: unknown): string {
|
||
if (error instanceof Error) return error.message
|
||
if (typeof error === 'object' && error !== null) {
|
||
const candidate = (error as { message?: unknown }).message
|
||
if (typeof candidate === 'string') return candidate
|
||
}
|
||
return '查询异常'
|
||
}
|
||
|
||
/**
|
||
* 解析 externalId 获取 provider、type 和请求信息
|
||
*/
|
||
export function parseExternalId(externalId: string): {
|
||
provider: 'FAL' | 'ARK' | 'GEMINI' | 'GOOGLE' | 'MINIMAX' | 'VIDU' | 'OPENAI' | 'OCOMPAT' | 'BAILIAN' | 'SILICONFLOW' | 'UNKNOWN'
|
||
type: 'VIDEO' | 'IMAGE' | 'BATCH' | 'UNKNOWN'
|
||
endpoint?: string
|
||
requestId: string
|
||
providerToken?: string
|
||
modelKeyToken?: string
|
||
} {
|
||
// 标准格式:PROVIDER:TYPE:...
|
||
if (externalId.startsWith('FAL:')) {
|
||
const parts = externalId.split(':')
|
||
|
||
if (parts[1] === 'VIDEO' || parts[1] === 'IMAGE') {
|
||
if (parts.length < 4) {
|
||
throw new Error(`无效 FAL externalId: "${externalId}",应为 FAL:TYPE:endpoint:requestId`)
|
||
}
|
||
const endpoint = parts.slice(2, -1).join(':')
|
||
const requestId = parts[parts.length - 1]
|
||
if (!endpoint || !requestId) {
|
||
throw new Error(`无效 FAL externalId: "${externalId}",缺少 endpoint 或 requestId`)
|
||
}
|
||
return {
|
||
provider: 'FAL',
|
||
type: parts[1] as 'VIDEO' | 'IMAGE',
|
||
endpoint,
|
||
requestId,
|
||
}
|
||
}
|
||
throw new Error(`无效 FAL externalId: "${externalId}",TYPE 仅支持 VIDEO/IMAGE`)
|
||
}
|
||
|
||
if (externalId.startsWith('ARK:')) {
|
||
const parts = externalId.split(':')
|
||
const type = parts[1]
|
||
const requestId = parts.slice(2).join(':')
|
||
if ((type !== 'VIDEO' && type !== 'IMAGE') || !requestId) {
|
||
throw new Error(`无效 ARK externalId: "${externalId}",应为 ARK:TYPE:requestId`)
|
||
}
|
||
return {
|
||
provider: 'ARK',
|
||
type: type as 'VIDEO' | 'IMAGE',
|
||
requestId,
|
||
}
|
||
}
|
||
|
||
if (externalId.startsWith('GEMINI:')) {
|
||
const parts = externalId.split(':')
|
||
const type = parts[1]
|
||
const requestId = parts.slice(2).join(':')
|
||
if (type !== 'BATCH' || !requestId) {
|
||
throw new Error(`无效 GEMINI externalId: "${externalId}",应为 GEMINI:BATCH:batchName`)
|
||
}
|
||
return {
|
||
provider: 'GEMINI',
|
||
type: 'BATCH',
|
||
requestId,
|
||
}
|
||
}
|
||
|
||
if (externalId.startsWith('GOOGLE:')) {
|
||
const parts = externalId.split(':')
|
||
const type = parts[1]
|
||
const requestId = parts.slice(2).join(':')
|
||
if (type !== 'VIDEO' || !requestId) {
|
||
throw new Error(`无效 GOOGLE externalId: "${externalId}",应为 GOOGLE:VIDEO:operationName`)
|
||
}
|
||
return {
|
||
provider: 'GOOGLE',
|
||
type: 'VIDEO',
|
||
requestId,
|
||
}
|
||
}
|
||
|
||
if (externalId.startsWith('MINIMAX:')) {
|
||
const parts = externalId.split(':')
|
||
const type = parts[1]
|
||
const requestId = parts.slice(2).join(':')
|
||
if ((type !== 'VIDEO' && type !== 'IMAGE') || !requestId) {
|
||
throw new Error(`无效 MINIMAX externalId: "${externalId}",应为 MINIMAX:TYPE:taskId`)
|
||
}
|
||
return {
|
||
provider: 'MINIMAX',
|
||
type: type as 'VIDEO' | 'IMAGE',
|
||
requestId,
|
||
}
|
||
}
|
||
|
||
if (externalId.startsWith('VIDU:')) {
|
||
const parts = externalId.split(':')
|
||
const type = parts[1]
|
||
const requestId = parts.slice(2).join(':')
|
||
if ((type !== 'VIDEO' && type !== 'IMAGE') || !requestId) {
|
||
throw new Error(`无效 VIDU externalId: "${externalId}",应为 VIDU:TYPE:taskId`)
|
||
}
|
||
return {
|
||
provider: 'VIDU',
|
||
type: type as 'VIDEO' | 'IMAGE',
|
||
requestId,
|
||
}
|
||
}
|
||
|
||
if (externalId.startsWith('OPENAI:')) {
|
||
const parts = externalId.split(':')
|
||
const type = parts[1]
|
||
const providerToken = parts[2]
|
||
const requestId = parts.slice(3).join(':')
|
||
if (type !== 'VIDEO' || !providerToken || !requestId) {
|
||
throw new Error(`无效 OPENAI externalId: "${externalId}",应为 OPENAI:VIDEO:providerToken:videoId`)
|
||
}
|
||
return {
|
||
provider: 'OPENAI',
|
||
type: 'VIDEO',
|
||
providerToken,
|
||
requestId,
|
||
}
|
||
}
|
||
|
||
if (externalId.startsWith('OCOMPAT:')) {
|
||
const parts = externalId.split(':')
|
||
const type = parts[1]
|
||
const providerToken = parts[2]
|
||
const modelKeyToken = parts[3]
|
||
const requestId = parts.slice(4).join(':')
|
||
if ((type !== 'VIDEO' && type !== 'IMAGE') || !providerToken || !modelKeyToken || !requestId) {
|
||
throw new Error(`无效 OCOMPAT externalId: "${externalId}",应为 OCOMPAT:TYPE:providerToken:modelKeyToken:taskId`)
|
||
}
|
||
return {
|
||
provider: 'OCOMPAT',
|
||
type: type as 'VIDEO' | 'IMAGE',
|
||
providerToken,
|
||
modelKeyToken,
|
||
requestId,
|
||
}
|
||
}
|
||
|
||
if (externalId.startsWith('BAILIAN:')) {
|
||
const parts = externalId.split(':')
|
||
const type = parts[1]
|
||
const requestId = parts.slice(2).join(':')
|
||
if ((type !== 'VIDEO' && type !== 'IMAGE') || !requestId) {
|
||
throw new Error(`无效 BAILIAN externalId: "${externalId}",应为 BAILIAN:TYPE:requestId`)
|
||
}
|
||
return {
|
||
provider: 'BAILIAN',
|
||
type: type as 'VIDEO' | 'IMAGE',
|
||
requestId,
|
||
}
|
||
}
|
||
|
||
if (externalId.startsWith('SILICONFLOW:')) {
|
||
const parts = externalId.split(':')
|
||
const type = parts[1]
|
||
const requestId = parts.slice(2).join(':')
|
||
if ((type !== 'VIDEO' && type !== 'IMAGE') || !requestId) {
|
||
throw new Error(`无效 SILICONFLOW externalId: "${externalId}",应为 SILICONFLOW:TYPE:requestId`)
|
||
}
|
||
return {
|
||
provider: 'SILICONFLOW',
|
||
type: type as 'VIDEO' | 'IMAGE',
|
||
requestId,
|
||
}
|
||
}
|
||
|
||
throw new Error(
|
||
`无法识别的 externalId 格式: "${externalId}". ` +
|
||
`支持的格式: FAL:TYPE:endpoint:requestId, ARK:TYPE:requestId, GEMINI:BATCH:batchName, GOOGLE:VIDEO:operationName, MINIMAX:TYPE:taskId, VIDU:TYPE:taskId, OPENAI:VIDEO:providerToken:videoId, OCOMPAT:TYPE:providerToken:modelKeyToken:taskId, BAILIAN:TYPE:requestId, SILICONFLOW:TYPE:requestId`
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 统一轮询入口
|
||
* 根据 externalId 格式自动选择正确的查询函数
|
||
*/
|
||
export async function pollAsyncTask(
|
||
externalId: string,
|
||
userId: string
|
||
): Promise<PollResult> {
|
||
if (!userId) {
|
||
throw new Error('缺少用户ID,无法获取 API Key')
|
||
}
|
||
|
||
const parsed = parseExternalId(externalId)
|
||
_ulogInfo(`[Poll] 解析 ${externalId.slice(0, 30)}... → provider=${parsed.provider}, type=${parsed.type}`)
|
||
|
||
switch (parsed.provider) {
|
||
case 'FAL':
|
||
return await pollFalTask(parsed.endpoint!, parsed.requestId, userId)
|
||
case 'ARK':
|
||
return await pollArkTask(parsed.requestId, userId)
|
||
case 'GEMINI':
|
||
return await pollGeminiTask(parsed.requestId, userId)
|
||
case 'GOOGLE':
|
||
return await pollGoogleVideoTask(parsed.requestId, userId)
|
||
case 'MINIMAX':
|
||
return await pollMinimaxTask(parsed.requestId, userId)
|
||
case 'VIDU':
|
||
return await pollViduTask(parsed.requestId, userId)
|
||
case 'OPENAI':
|
||
return await pollOpenAIVideoTask(parsed.requestId, userId, parsed.providerToken)
|
||
case 'OCOMPAT':
|
||
return await pollOCompatTask(parsed.type, parsed.requestId, userId, parsed.providerToken, parsed.modelKeyToken)
|
||
case 'BAILIAN':
|
||
return await pollBailianTask(parsed.requestId, userId)
|
||
case 'SILICONFLOW':
|
||
return await pollSiliconFlowTask(parsed.requestId)
|
||
default:
|
||
// 🔥 移除 fallback:未知 provider 直接抛出错误
|
||
throw new Error(`未知的 Provider: ${parsed.provider}`)
|
||
}
|
||
}
|
||
|
||
function decodeProviderId(token: string): string {
|
||
const value = token.trim()
|
||
if (!value) {
|
||
throw new Error('OPENAI_PROVIDER_TOKEN_INVALID')
|
||
}
|
||
if (value.startsWith('u_')) {
|
||
const uuid = value.slice(2).trim()
|
||
if (!PROVIDER_UUID_PATTERN.test(uuid)) {
|
||
throw new Error('OPENAI_PROVIDER_TOKEN_INVALID')
|
||
}
|
||
return `${OPENAI_COMPAT_PROVIDER_PREFIX}${uuid.toLowerCase()}`
|
||
}
|
||
if (PROVIDER_UUID_PATTERN.test(value)) {
|
||
return `${OPENAI_COMPAT_PROVIDER_PREFIX}${value.toLowerCase()}`
|
||
}
|
||
const encoded = value.startsWith('b64_') ? value.slice(4) : value
|
||
try {
|
||
const decoded = Buffer.from(encoded, 'base64url').toString('utf8').trim()
|
||
if (!decoded) {
|
||
throw new Error('OPENAI_PROVIDER_TOKEN_INVALID')
|
||
}
|
||
return decoded
|
||
} catch {
|
||
throw new Error('OPENAI_PROVIDER_TOKEN_INVALID')
|
||
}
|
||
}
|
||
|
||
function decodeModelKey(token: string): string {
|
||
try {
|
||
return Buffer.from(token, 'base64url').toString('utf8')
|
||
} catch {
|
||
throw new Error('OCOMPAT_MODEL_KEY_TOKEN_INVALID')
|
||
}
|
||
}
|
||
|
||
function resolveOCompatModelKey(providerId: string, token: string): string {
|
||
const decoded = decodeModelKey(token).trim()
|
||
if (!decoded) {
|
||
throw new Error('OCOMPAT_MODEL_KEY_TOKEN_INVALID')
|
||
}
|
||
if (decoded.includes('::')) {
|
||
return decoded
|
||
}
|
||
const composed = composeModelKey(providerId, decoded)
|
||
if (!composed) {
|
||
throw new Error('OCOMPAT_MODEL_KEY_TOKEN_INVALID')
|
||
}
|
||
return composed
|
||
}
|
||
|
||
async function pollOCompatTask(
|
||
type: 'VIDEO' | 'IMAGE' | 'BATCH' | 'UNKNOWN',
|
||
taskId: string,
|
||
userId: string,
|
||
providerToken?: string,
|
||
modelKeyToken?: string,
|
||
): Promise<PollResult> {
|
||
if (!providerToken) throw new Error('OCOMPAT_PROVIDER_TOKEN_MISSING')
|
||
if (!modelKeyToken) throw new Error('OCOMPAT_MODEL_KEY_TOKEN_MISSING')
|
||
const providerId = decodeProviderId(providerToken)
|
||
const modelKey = resolveOCompatModelKey(providerId, modelKeyToken)
|
||
const config = await getProviderConfig(userId, providerId)
|
||
if (!config.baseUrl) throw new Error(`PROVIDER_BASE_URL_MISSING: ${providerId}`)
|
||
|
||
const models = await getUserModels(userId)
|
||
const model = models.find((item) => item.modelKey === modelKey)
|
||
if (!model || !model.compatMediaTemplate) {
|
||
throw new Error(`OCOMPAT_TEMPLATE_NOT_FOUND: ${modelKey}`)
|
||
}
|
||
const template = model.compatMediaTemplate
|
||
if (template.mode !== 'async' || !template.status) {
|
||
throw new Error(`OCOMPAT_TEMPLATE_NOT_ASYNC: ${modelKey}`)
|
||
}
|
||
|
||
const variables = buildTemplateVariables({
|
||
model: model.modelId,
|
||
prompt: '',
|
||
taskId,
|
||
})
|
||
const statusRequest = await buildRenderedTemplateRequest({
|
||
baseUrl: config.baseUrl,
|
||
endpoint: template.status,
|
||
variables,
|
||
defaultAuthHeader: `Bearer ${config.apiKey}`,
|
||
})
|
||
const response = await fetch(statusRequest.endpointUrl, {
|
||
method: statusRequest.method,
|
||
headers: statusRequest.headers,
|
||
})
|
||
const rawText = await response.text().catch(() => '')
|
||
if (!response.ok) {
|
||
return {
|
||
status: 'failed',
|
||
error: `OCOMPAT status request failed: ${response.status}`,
|
||
}
|
||
}
|
||
const payload = normalizeResponseJson(rawText)
|
||
const statusRaw = readJsonPath(payload, template.response.statusPath)
|
||
const status = typeof statusRaw === 'string' ? statusRaw.trim().toLowerCase() : ''
|
||
if (!status) {
|
||
return {
|
||
status: 'failed',
|
||
error: 'OCOMPAT status path resolve failed',
|
||
}
|
||
}
|
||
const doneStates = (template.polling?.doneStates || []).map((item) => item.toLowerCase())
|
||
const failStates = (template.polling?.failStates || []).map((item) => item.toLowerCase())
|
||
if (doneStates.includes(status)) {
|
||
const outputUrl = readJsonPath(payload, template.response.outputUrlPath)
|
||
if (typeof outputUrl === 'string' && outputUrl.trim()) {
|
||
return {
|
||
status: 'completed',
|
||
resultUrl: outputUrl.trim(),
|
||
...(type === 'VIDEO'
|
||
? { videoUrl: outputUrl.trim() }
|
||
: { imageUrl: outputUrl.trim() }),
|
||
}
|
||
}
|
||
if (template.content) {
|
||
const contentRequest = await buildRenderedTemplateRequest({
|
||
baseUrl: config.baseUrl,
|
||
endpoint: template.content,
|
||
variables,
|
||
defaultAuthHeader: `Bearer ${config.apiKey}`,
|
||
})
|
||
return {
|
||
status: 'completed',
|
||
resultUrl: contentRequest.endpointUrl,
|
||
...(type === 'VIDEO'
|
||
? { videoUrl: contentRequest.endpointUrl }
|
||
: { imageUrl: contentRequest.endpointUrl }),
|
||
downloadHeaders: {
|
||
...contentRequest.headers,
|
||
},
|
||
}
|
||
}
|
||
return {
|
||
status: 'failed',
|
||
error: 'OCOMPAT completed but output URL missing',
|
||
}
|
||
}
|
||
if (failStates.includes(status)) {
|
||
const errorRaw = readJsonPath(payload, template.response.errorPath)
|
||
return {
|
||
status: 'failed',
|
||
error: typeof errorRaw === 'string' && errorRaw.trim() ? errorRaw.trim() : `OCOMPAT task failed: ${status}`,
|
||
}
|
||
}
|
||
return { status: 'pending' }
|
||
}
|
||
|
||
async function pollOpenAIVideoTask(
|
||
videoId: string,
|
||
userId: string,
|
||
providerToken?: string,
|
||
): Promise<PollResult> {
|
||
if (!providerToken) {
|
||
throw new Error('OPENAI_PROVIDER_TOKEN_MISSING')
|
||
}
|
||
const providerId = decodeProviderId(providerToken)
|
||
const config = await getProviderConfig(userId, providerId)
|
||
if (!config.baseUrl) {
|
||
throw new Error(`PROVIDER_BASE_URL_MISSING: ${config.id}`)
|
||
}
|
||
|
||
// Use raw fetch instead of SDK to handle varying response formats across gateways
|
||
const baseUrl = config.baseUrl.replace(/\/+$/, '')
|
||
const pollUrl = `${baseUrl}/videos/${encodeURIComponent(videoId)}`
|
||
const response = await fetch(pollUrl, {
|
||
method: 'GET',
|
||
headers: { Authorization: `Bearer ${config.apiKey}` },
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const text = await response.text().catch(() => '')
|
||
throw new Error(`OPENAI_VIDEO_POLL_FAILED: ${response.status} ${text.slice(0, 200)}`)
|
||
}
|
||
|
||
const task = await response.json() as Record<string, unknown>
|
||
const status = typeof task.status === 'string' ? task.status : ''
|
||
|
||
// Pending statuses: OpenAI uses "queued"/"in_progress", some gateways use "processing"
|
||
if (status === 'queued' || status === 'in_progress' || status === 'processing') {
|
||
return { status: 'pending' }
|
||
}
|
||
|
||
if (status === 'failed') {
|
||
const errorObj = task.error as Record<string, unknown> | undefined
|
||
const message = (typeof errorObj?.message === 'string' ? errorObj.message : '')
|
||
|| (typeof task.error === 'string' ? task.error : '')
|
||
|| `OpenAI video task failed: ${videoId}`
|
||
return { status: 'failed', error: message }
|
||
}
|
||
|
||
if (status !== 'completed') {
|
||
// Unknown status, treat as pending
|
||
return { status: 'pending' }
|
||
}
|
||
|
||
// Completed: prefer video_url from response body (some gateways provide it directly)
|
||
const videoUrl = typeof task.video_url === 'string' ? task.video_url.trim() : ''
|
||
if (videoUrl) {
|
||
return {
|
||
status: 'completed',
|
||
videoUrl,
|
||
resultUrl: videoUrl,
|
||
}
|
||
}
|
||
|
||
// Fallback: OpenAI standard /videos/:id/content endpoint
|
||
const taskId = typeof task.id === 'string' ? task.id : videoId
|
||
const contentUrl = `${baseUrl}/videos/${encodeURIComponent(taskId)}/content`
|
||
return {
|
||
status: 'completed',
|
||
videoUrl: contentUrl,
|
||
resultUrl: contentUrl,
|
||
downloadHeaders: {
|
||
Authorization: `Bearer ${config.apiKey}`,
|
||
},
|
||
}
|
||
}
|
||
|
||
/**
|
||
* FAL 任务轮询
|
||
*/
|
||
async function pollFalTask(
|
||
endpoint: string,
|
||
requestId: string,
|
||
userId: string
|
||
): Promise<PollResult> {
|
||
const { apiKey } = await getProviderConfig(userId, 'fal')
|
||
const result = await queryFalStatus(endpoint, requestId, apiKey)
|
||
|
||
return {
|
||
status: result.completed ? (result.failed ? 'failed' : 'completed') : 'pending',
|
||
resultUrl: result.resultUrl,
|
||
imageUrl: result.resultUrl,
|
||
videoUrl: result.resultUrl,
|
||
error: result.error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ark 任务轮询
|
||
*/
|
||
async function pollArkTask(
|
||
taskId: string,
|
||
userId: string
|
||
): Promise<PollResult> {
|
||
const { apiKey } = await getProviderConfig(userId, 'ark')
|
||
const result = await querySeedanceVideoStatus(taskId, apiKey)
|
||
|
||
return {
|
||
status: result.status,
|
||
videoUrl: result.videoUrl,
|
||
resultUrl: result.videoUrl,
|
||
...(typeof result.actualVideoTokens === 'number' ? { actualVideoTokens: result.actualVideoTokens } : {}),
|
||
error: result.error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Gemini Batch 任务轮询
|
||
*/
|
||
async function pollGeminiTask(
|
||
batchName: string,
|
||
userId: string
|
||
): Promise<PollResult> {
|
||
const { apiKey } = await getProviderConfig(userId, 'google')
|
||
const result = await queryGeminiBatchStatus(batchName, apiKey)
|
||
|
||
return {
|
||
status: result.status,
|
||
imageUrl: result.imageUrl,
|
||
resultUrl: result.imageUrl,
|
||
error: result.error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Google Veo 视频任务轮询
|
||
*/
|
||
async function pollGoogleVideoTask(
|
||
operationName: string,
|
||
userId: string
|
||
): Promise<PollResult> {
|
||
const { apiKey } = await getProviderConfig(userId, 'google')
|
||
const result = await queryGoogleVideoStatus(operationName, apiKey)
|
||
|
||
return {
|
||
status: result.status,
|
||
videoUrl: result.videoUrl,
|
||
resultUrl: result.videoUrl,
|
||
error: result.error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* MiniMax 任务轮询
|
||
*/
|
||
async function pollMinimaxTask(
|
||
taskId: string,
|
||
userId: string
|
||
): Promise<PollResult> {
|
||
const { apiKey } = await getProviderConfig(userId, 'minimax')
|
||
const result = await queryMinimaxTaskStatus(taskId, apiKey)
|
||
|
||
return {
|
||
status: result.status,
|
||
videoUrl: result.videoUrl,
|
||
imageUrl: result.imageUrl,
|
||
resultUrl: result.videoUrl || result.imageUrl,
|
||
error: result.error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 查询 MiniMax 任务状态
|
||
*/
|
||
async function queryMinimaxTaskStatus(
|
||
taskId: string,
|
||
apiKey: string
|
||
): Promise<{ status: 'pending' | 'completed' | 'failed'; videoUrl?: string; imageUrl?: string; error?: string }> {
|
||
const logPrefix = '[MiniMax Query]'
|
||
|
||
try {
|
||
const response = await fetch(`https://api.minimaxi.com/v1/query/video_generation?task_id=${taskId}`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${apiKey}`
|
||
}
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text()
|
||
_ulogError(`${logPrefix} 查询失败:`, response.status, errorText)
|
||
return {
|
||
status: 'failed',
|
||
error: `查询失败: ${response.status}`
|
||
}
|
||
}
|
||
|
||
const data = await response.json()
|
||
|
||
// 检查响应
|
||
if (data.base_resp?.status_code !== 0) {
|
||
const errMsg = data.base_resp?.status_msg || '未知错误'
|
||
_ulogError(`${logPrefix} task_id=${taskId} 错误:`, errMsg)
|
||
return {
|
||
status: 'failed',
|
||
error: errMsg
|
||
}
|
||
}
|
||
|
||
const status = data.status
|
||
|
||
if (status === 'Success') {
|
||
const fileId = data.file_id
|
||
if (!fileId) {
|
||
_ulogError(`${logPrefix} task_id=${taskId} 成功但无file_id`)
|
||
return {
|
||
status: 'failed',
|
||
error: '任务完成但未返回视频'
|
||
}
|
||
}
|
||
|
||
// 🔥 使用 file_id 调用文件检索API获取真实下载URL
|
||
_ulogInfo(`${logPrefix} task_id=${taskId} 完成,正在获取下载URL...`)
|
||
try {
|
||
const fileResponse = await fetch(`https://api.minimaxi.com/v1/files/retrieve?file_id=${fileId}`, {
|
||
headers: {
|
||
'Authorization': `Bearer ${apiKey}`
|
||
}
|
||
})
|
||
|
||
if (!fileResponse.ok) {
|
||
const errorText = await fileResponse.text()
|
||
_ulogError(`${logPrefix} 文件检索失败:`, fileResponse.status, errorText)
|
||
return {
|
||
status: 'failed',
|
||
error: `文件检索失败: ${fileResponse.status}`
|
||
}
|
||
}
|
||
|
||
const fileData = await fileResponse.json()
|
||
const downloadUrl = fileData.file?.download_url
|
||
|
||
if (!downloadUrl) {
|
||
_ulogError(`${logPrefix} 文件检索成功但无download_url:`, fileData)
|
||
return {
|
||
status: 'failed',
|
||
error: '无法获取视频下载链接'
|
||
}
|
||
}
|
||
|
||
_ulogInfo(`${logPrefix} 获取下载URL成功: ${downloadUrl.substring(0, 80)}...`)
|
||
return {
|
||
status: 'completed',
|
||
videoUrl: downloadUrl
|
||
}
|
||
} catch (error: unknown) {
|
||
const errorMessage = getErrorMessage(error)
|
||
_ulogError(`${logPrefix} 文件检索异常:`, error)
|
||
return {
|
||
status: 'failed',
|
||
error: `文件检索失败: ${errorMessage}`
|
||
}
|
||
}
|
||
} else if (status === 'Failed') {
|
||
const errMsg = data.error_message || '生成失败'
|
||
_ulogError(`${logPrefix} task_id=${taskId} 失败:`, errMsg)
|
||
return {
|
||
status: 'failed',
|
||
error: errMsg
|
||
}
|
||
} else {
|
||
// Processing 或其他状态都视为 pending
|
||
return {
|
||
status: 'pending'
|
||
}
|
||
}
|
||
} catch (error: unknown) {
|
||
const errorMessage = getErrorMessage(error)
|
||
_ulogError(`${logPrefix} task_id=${taskId} 异常:`, error)
|
||
return {
|
||
status: 'failed',
|
||
error: errorMessage
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Vidu 任务轮询
|
||
*/
|
||
async function pollViduTask(
|
||
taskId: string,
|
||
userId: string
|
||
): Promise<PollResult> {
|
||
_ulogInfo(`[Poll Vidu] 开始轮询 task_id=${taskId}, userId=${userId}`)
|
||
|
||
const { apiKey } = await getProviderConfig(userId, 'vidu')
|
||
_ulogInfo(`[Poll Vidu] API Key 长度: ${apiKey?.length || 0}`)
|
||
|
||
const result = await queryViduTaskStatus(taskId, apiKey)
|
||
_ulogInfo(`[Poll Vidu] 查询结果:`, result)
|
||
|
||
return {
|
||
status: result.status,
|
||
videoUrl: result.videoUrl,
|
||
resultUrl: result.videoUrl,
|
||
error: result.error
|
||
}
|
||
}
|
||
|
||
interface BailianTaskQueryResultItem {
|
||
url?: string
|
||
video_url?: string
|
||
image_url?: string
|
||
}
|
||
|
||
interface BailianTaskQueryResponse {
|
||
code?: string
|
||
message?: string
|
||
task_status?: string
|
||
output?: {
|
||
task_status?: string
|
||
code?: string
|
||
message?: string
|
||
video_url?: string
|
||
image_url?: string
|
||
results?: BailianTaskQueryResultItem[]
|
||
}
|
||
}
|
||
|
||
function readBailianTaskQueryMediaUrl(data: BailianTaskQueryResponse): {
|
||
mediaUrl?: string
|
||
videoUrl?: string
|
||
imageUrl?: string
|
||
} {
|
||
const output = data.output
|
||
const videoUrl = typeof output?.video_url === 'string' ? output.video_url.trim() : ''
|
||
if (videoUrl) {
|
||
return { mediaUrl: videoUrl, videoUrl }
|
||
}
|
||
|
||
const imageUrl = typeof output?.image_url === 'string' ? output.image_url.trim() : ''
|
||
if (imageUrl) {
|
||
return { mediaUrl: imageUrl, imageUrl }
|
||
}
|
||
|
||
const firstResult = Array.isArray(output?.results) ? output.results[0] : undefined
|
||
if (!firstResult || typeof firstResult !== 'object') {
|
||
return {}
|
||
}
|
||
const firstVideoUrl = typeof firstResult.video_url === 'string' ? firstResult.video_url.trim() : ''
|
||
if (firstVideoUrl) {
|
||
return { mediaUrl: firstVideoUrl, videoUrl: firstVideoUrl }
|
||
}
|
||
const firstImageUrl = typeof firstResult.image_url === 'string' ? firstResult.image_url.trim() : ''
|
||
if (firstImageUrl) {
|
||
return { mediaUrl: firstImageUrl, imageUrl: firstImageUrl }
|
||
}
|
||
const firstUrl = typeof firstResult.url === 'string' ? firstResult.url.trim() : ''
|
||
if (firstUrl) {
|
||
return { mediaUrl: firstUrl }
|
||
}
|
||
|
||
return {}
|
||
}
|
||
|
||
async function pollBailianTask(requestId: string, userId: string): Promise<PollResult> {
|
||
const logPrefix = '[Bailian Query]'
|
||
|
||
try {
|
||
const { apiKey } = await getProviderConfig(userId, 'bailian')
|
||
const response = await fetch(
|
||
`https://dashscope.aliyuncs.com/api/v1/tasks/${encodeURIComponent(requestId)}`,
|
||
{
|
||
headers: {
|
||
'Authorization': `Bearer ${apiKey}`,
|
||
},
|
||
},
|
||
)
|
||
|
||
const raw = await response.text()
|
||
let data: BailianTaskQueryResponse = {}
|
||
if (raw) {
|
||
try {
|
||
const parsed = JSON.parse(raw) as unknown
|
||
if (parsed && typeof parsed === 'object') {
|
||
data = parsed as BailianTaskQueryResponse
|
||
} else {
|
||
throw new Error('BAILIAN_TASK_QUERY_RESPONSE_INVALID')
|
||
}
|
||
} catch {
|
||
throw new Error('BAILIAN_TASK_QUERY_RESPONSE_INVALID_JSON')
|
||
}
|
||
}
|
||
|
||
const outputCode = typeof data.output?.code === 'string' ? data.output.code.trim() : ''
|
||
const outputMessage = typeof data.output?.message === 'string' ? data.output.message.trim() : ''
|
||
const topLevelCode = typeof data.code === 'string' ? data.code.trim() : ''
|
||
const topLevelMessage = typeof data.message === 'string' ? data.message.trim() : ''
|
||
const resolvedCode = outputCode || topLevelCode
|
||
const resolvedMessage = outputMessage || topLevelMessage
|
||
|
||
if (!response.ok) {
|
||
return {
|
||
status: 'failed',
|
||
error: `Bailian: 查询失败 ${response.status} ${resolvedCode || resolvedMessage}`.trim(),
|
||
}
|
||
}
|
||
|
||
const taskStatus = (typeof data.output?.task_status === 'string'
|
||
? data.output.task_status
|
||
: typeof data.task_status === 'string'
|
||
? data.task_status
|
||
: '').trim().toUpperCase()
|
||
|
||
if (taskStatus === 'FAILED' || taskStatus === 'CANCELED' || taskStatus === 'CANCELLED') {
|
||
return {
|
||
status: 'failed',
|
||
error: `Bailian: ${resolvedCode || resolvedMessage || '任务失败'}`,
|
||
}
|
||
}
|
||
|
||
if (taskStatus === 'SUCCEEDED' || taskStatus === 'SUCCESS') {
|
||
const { mediaUrl, videoUrl, imageUrl } = readBailianTaskQueryMediaUrl(data)
|
||
if (!mediaUrl) {
|
||
return {
|
||
status: 'failed',
|
||
error: 'Bailian: 任务完成但未返回结果URL',
|
||
}
|
||
}
|
||
return {
|
||
status: 'completed',
|
||
resultUrl: mediaUrl,
|
||
videoUrl,
|
||
imageUrl,
|
||
}
|
||
}
|
||
|
||
return {
|
||
status: 'pending',
|
||
}
|
||
} catch (error: unknown) {
|
||
const errorMessage = getErrorMessage(error)
|
||
_ulogError(`${logPrefix} task_id=${requestId} 异常:`, error)
|
||
return {
|
||
status: 'failed',
|
||
error: `Bailian: ${errorMessage}`,
|
||
}
|
||
}
|
||
}
|
||
|
||
async function pollSiliconFlowTask(requestId: string): Promise<PollResult> {
|
||
return {
|
||
status: 'failed',
|
||
error: `ASYNC_POLL_NOT_IMPLEMENTED: SILICONFLOW task polling not implemented (${requestId})`,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 查询 Vidu 任务状态
|
||
*/
|
||
async function queryViduTaskStatus(
|
||
taskId: string,
|
||
apiKey: string
|
||
): Promise<{ status: 'pending' | 'completed' | 'failed'; videoUrl?: string; error?: string }> {
|
||
const logPrefix = '[Vidu Query]'
|
||
|
||
try {
|
||
_ulogInfo(`${logPrefix} 查询任务 task_id=${taskId}`)
|
||
|
||
// 🔥 正确的查询接口路径:/tasks/{id}/creations
|
||
const response = await fetch(`https://api.vidu.cn/ent/v2/tasks/${taskId}/creations`, {
|
||
headers: {
|
||
'Authorization': `Token ${apiKey}`
|
||
}
|
||
})
|
||
|
||
_ulogInfo(`${logPrefix} HTTP状态: ${response.status}`)
|
||
|
||
if (!response.ok) {
|
||
const errorText = await response.text()
|
||
_ulogError(`${logPrefix} 查询失败:`, response.status, errorText)
|
||
return {
|
||
status: 'failed',
|
||
error: `Vidu: 查询失败 ${response.status}`
|
||
}
|
||
}
|
||
|
||
const data = await response.json()
|
||
_ulogInfo(`${logPrefix} 响应数据:`, JSON.stringify(data, null, 2))
|
||
|
||
// 检查任务状态
|
||
const state = data.state
|
||
|
||
if (state === 'success') {
|
||
// 🔥 任务成功,从 creations 数组中获取视频URL
|
||
const creations = data.creations
|
||
if (!creations || creations.length === 0) {
|
||
_ulogError(`${logPrefix} task_id=${taskId} 成功但无生成物`)
|
||
return {
|
||
status: 'failed',
|
||
error: 'Vidu: 任务完成但未返回视频'
|
||
}
|
||
}
|
||
|
||
const videoUrl = creations[0].url
|
||
if (!videoUrl) {
|
||
_ulogError(`${logPrefix} task_id=${taskId} 成功但生成物无URL`)
|
||
return {
|
||
status: 'failed',
|
||
error: 'Vidu: 任务完成但未返回视频URL'
|
||
}
|
||
}
|
||
|
||
_ulogInfo(`${logPrefix} task_id=${taskId} 完成,视频URL: ${videoUrl.substring(0, 80)}...`)
|
||
return {
|
||
status: 'completed',
|
||
videoUrl: videoUrl
|
||
}
|
||
} else if (state === 'failed') {
|
||
// 🔥 使用 err_code 作为错误消息,添加 Vidu: 前缀便于错误码映射
|
||
const errCode = data.err_code || 'Unknown'
|
||
_ulogError(`${logPrefix} task_id=${taskId} 失败: ${errCode}`)
|
||
return {
|
||
status: 'failed',
|
||
error: `Vidu: ${errCode}` // 添加前缀以便错误映射识别
|
||
}
|
||
} else {
|
||
// created, queueing, processing 都视为 pending
|
||
return {
|
||
status: 'pending'
|
||
}
|
||
}
|
||
} catch (error: unknown) {
|
||
const errorMessage = getErrorMessage(error)
|
||
_ulogError(`${logPrefix} task_id=${taskId} 异常:`, error)
|
||
return {
|
||
status: 'failed',
|
||
error: `Vidu: ${errorMessage}` // 添加前缀
|
||
}
|
||
}
|
||
}
|
||
|
||
// ==================== 格式化辅助函数 ====================
|
||
|
||
/**
|
||
* 创建标准格式的 externalId
|
||
*/
|
||
export function formatExternalId(
|
||
provider: 'FAL' | 'ARK' | 'GEMINI' | 'GOOGLE' | 'MINIMAX' | 'VIDU' | 'OPENAI' | 'OCOMPAT' | 'BAILIAN' | 'SILICONFLOW',
|
||
type: 'VIDEO' | 'IMAGE' | 'BATCH',
|
||
requestId: string,
|
||
endpoint?: string,
|
||
providerToken?: string,
|
||
modelKeyToken?: string,
|
||
): string {
|
||
if (provider === 'FAL') {
|
||
if (!endpoint) {
|
||
throw new Error('FAL externalId requires endpoint')
|
||
}
|
||
return `FAL:${type}:${endpoint}:${requestId}`
|
||
}
|
||
if (provider === 'OPENAI') {
|
||
if (!providerToken) {
|
||
throw new Error('OPENAI externalId requires providerToken')
|
||
}
|
||
return `OPENAI:${type}:${providerToken}:${requestId}`
|
||
}
|
||
if (provider === 'OCOMPAT') {
|
||
if (!providerToken) {
|
||
throw new Error('OCOMPAT externalId requires providerToken')
|
||
}
|
||
if (!modelKeyToken) {
|
||
throw new Error('OCOMPAT externalId requires modelKeyToken')
|
||
}
|
||
return `OCOMPAT:${type}:${providerToken}:${modelKeyToken}:${requestId}`
|
||
}
|
||
return `${provider}:${type}:${requestId}`
|
||
}
|