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 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 { 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 { 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 { 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 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 | 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 { 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 { 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 { 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 { 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 { 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 { _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 { 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 { 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}` }