Files
waooplus/src/lib/async-poll.ts
2026-04-02 19:16:00 +08:00

983 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`
}