feat: initial release v0.3.0

This commit is contained in:
saturn
2026-03-08 03:15:27 +08:00
commit 881ed44996
1311 changed files with 225407 additions and 0 deletions

980
src/lib/async-poll.ts Normal file
View File

@@ -0,0 +1,980 @@
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
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,
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}`
}