2026-01-30 22:41:42 +08:00
|
|
|
|
"""
|
|
|
|
|
|
OpenAI Video Handler - Sora 视频生成实现
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import json
|
2026-02-02 03:16:52 +08:00
|
|
|
|
import time
|
2026-01-30 22:41:42 +08:00
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
from typing import Any, AsyncIterator
|
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
import httpx
|
2026-01-30 22:41:42 +08:00
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
|
|
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
2026-03-15 20:27:53 +08:00
|
|
|
|
from src.api.handlers.base.request_builder import (
|
|
|
|
|
|
apply_body_rules,
|
|
|
|
|
|
evaluate_condition,
|
|
|
|
|
|
get_provider_auth,
|
|
|
|
|
|
)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
from src.api.handlers.base.video_handler_base import VideoHandlerBase, sanitize_error_message
|
2026-01-31 19:11:25 +08:00
|
|
|
|
from src.config.settings import config
|
2026-02-01 17:28:27 +08:00
|
|
|
|
from src.core.api_format import (
|
|
|
|
|
|
ApiFamily,
|
|
|
|
|
|
EndpointKind,
|
|
|
|
|
|
build_upstream_headers_for_endpoint,
|
|
|
|
|
|
get_extra_headers_from_endpoint,
|
|
|
|
|
|
make_signature_key,
|
|
|
|
|
|
)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
from src.core.api_format.conversion.internal_video import (
|
|
|
|
|
|
InternalVideoRequest,
|
|
|
|
|
|
InternalVideoTask,
|
|
|
|
|
|
VideoStatus,
|
|
|
|
|
|
)
|
|
|
|
|
|
from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer
|
2026-02-02 03:16:52 +08:00
|
|
|
|
from src.core.api_format.conversion.registry import format_conversion_registry
|
2026-01-30 22:41:42 +08:00
|
|
|
|
from src.core.api_format.headers import HOP_BY_HOP_HEADERS
|
|
|
|
|
|
from src.core.crypto import crypto_service
|
2026-03-31 19:19:04 +08:00
|
|
|
|
from src.core.exceptions import ProviderNotAvailableException
|
2026-01-30 22:41:42 +08:00
|
|
|
|
from src.core.logger import logger
|
|
|
|
|
|
from src.models.database import ApiKey, ProviderAPIKey, ProviderEndpoint, User, VideoTask
|
2026-01-31 19:11:25 +08:00
|
|
|
|
from src.services.billing.rule_service import BillingRuleLookupResult, BillingRuleService
|
2026-04-03 14:59:58 +08:00
|
|
|
|
from src.services.request.execution_runtime_plan import (
|
2026-03-21 12:57:09 +08:00
|
|
|
|
ExecutionPlan,
|
|
|
|
|
|
ExecutionPlanBody,
|
|
|
|
|
|
ExecutionPlanTimeouts,
|
|
|
|
|
|
)
|
2026-04-03 14:59:58 +08:00
|
|
|
|
from src.services.request.execution_runtime_client import (
|
|
|
|
|
|
ExecutionRuntimeClient,
|
|
|
|
|
|
ExecutionRuntimeClientError,
|
2026-03-21 12:57:09 +08:00
|
|
|
|
)
|
2026-02-16 11:00:48 +08:00
|
|
|
|
from src.services.scheduling.aware_scheduler import ProviderCandidate
|
2026-02-01 17:28:27 +08:00
|
|
|
|
from src.services.usage.service import UsageService
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class OpenAIVideoHandler(VideoHandlerBase):
|
2026-02-01 17:28:27 +08:00
|
|
|
|
FORMAT_ID = "openai:video"
|
|
|
|
|
|
API_FAMILY = ApiFamily.OPENAI
|
|
|
|
|
|
ENDPOINT_KIND = EndpointKind.VIDEO
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
|
|
|
|
|
DEFAULT_BASE_URL = "https://api.openai.com"
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
db: Session,
|
|
|
|
|
|
user: User,
|
|
|
|
|
|
api_key: ApiKey,
|
|
|
|
|
|
request_id: str,
|
|
|
|
|
|
client_ip: str,
|
|
|
|
|
|
user_agent: str,
|
|
|
|
|
|
start_time: float,
|
|
|
|
|
|
allowed_api_formats: list[str] | None = None,
|
|
|
|
|
|
):
|
|
|
|
|
|
super().__init__(
|
|
|
|
|
|
db=db,
|
|
|
|
|
|
user=user,
|
|
|
|
|
|
api_key=api_key,
|
|
|
|
|
|
request_id=request_id,
|
|
|
|
|
|
client_ip=client_ip,
|
|
|
|
|
|
user_agent=user_agent,
|
|
|
|
|
|
start_time=start_time,
|
|
|
|
|
|
allowed_api_formats=allowed_api_formats,
|
|
|
|
|
|
)
|
|
|
|
|
|
self._normalizer = OpenAINormalizer()
|
|
|
|
|
|
|
|
|
|
|
|
async def handle_create_task(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
http_request: Request,
|
|
|
|
|
|
original_headers: dict[str, str],
|
|
|
|
|
|
original_request_body: dict[str, Any],
|
|
|
|
|
|
query_params: dict[str, str] | None = None,
|
|
|
|
|
|
path_params: dict[str, Any] | None = None,
|
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
|
try:
|
|
|
|
|
|
internal_request = self._normalizer.video_request_to_internal(original_request_body)
|
|
|
|
|
|
except ValueError as e:
|
2026-02-01 17:28:27 +08:00
|
|
|
|
# 请求解析失败,记录失败的使用记录
|
|
|
|
|
|
await self._record_failed_usage(
|
|
|
|
|
|
model="unknown",
|
|
|
|
|
|
error_message=str(e),
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
original_request_body=original_request_body,
|
|
|
|
|
|
original_headers=original_headers,
|
|
|
|
|
|
)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
2026-02-01 17:28:27 +08:00
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 异步任务:提前创建 pending usage,便于前端看到“处理中”
|
|
|
|
|
|
try:
|
|
|
|
|
|
UsageService.create_pending_usage(
|
|
|
|
|
|
db=self.db,
|
|
|
|
|
|
request_id=self.request_id,
|
|
|
|
|
|
user=self.user,
|
|
|
|
|
|
api_key=self.api_key,
|
|
|
|
|
|
model=internal_request.model,
|
|
|
|
|
|
is_stream=False,
|
|
|
|
|
|
request_type="video",
|
|
|
|
|
|
api_format=self.FORMAT_ID,
|
|
|
|
|
|
request_headers=original_headers,
|
|
|
|
|
|
request_body=original_request_body,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"Failed to create pending usage for video request_id={}: {}",
|
2026-02-02 03:16:52 +08:00
|
|
|
|
self.request_id,
|
|
|
|
|
|
sanitize_error_message(str(exc)),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 用于跟踪是否发生了格式转换
|
|
|
|
|
|
format_conversion_info: dict[str, Any] = {
|
|
|
|
|
|
"converted": False,
|
|
|
|
|
|
"provider_format": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 17:28:27 +08:00
|
|
|
|
async def _submit(candidate: ProviderCandidate) -> Any:
|
|
|
|
|
|
upstream_key, endpoint, _provider_key = await self._resolve_upstream_key(candidate)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
|
|
|
|
|
|
# 检测目标格式
|
|
|
|
|
|
provider_format = make_signature_key(
|
|
|
|
|
|
str(getattr(endpoint, "api_family", "")).strip().lower(),
|
|
|
|
|
|
str(getattr(endpoint, "endpoint_kind", "")).strip().lower(),
|
|
|
|
|
|
)
|
|
|
|
|
|
needs_conversion = provider_format.upper() != self.FORMAT_ID.upper()
|
|
|
|
|
|
format_conversion_info["provider_format"] = provider_format
|
|
|
|
|
|
format_conversion_info["converted"] = needs_conversion
|
|
|
|
|
|
|
|
|
|
|
|
# 确保 seconds 字段为字符串类型(上游 Go 服务要求 string)
|
|
|
|
|
|
request_body = original_request_body.copy()
|
|
|
|
|
|
if "seconds" in request_body and request_body["seconds"] is not None:
|
|
|
|
|
|
request_body["seconds"] = str(request_body["seconds"])
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
# 应用端点的请求体规则
|
|
|
|
|
|
endpoint_body_rules = getattr(endpoint, "body_rules", None)
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
if needs_conversion and provider_format.upper().startswith("GEMINI:"):
|
|
|
|
|
|
# OpenAI -> Gemini 格式转换
|
|
|
|
|
|
converted_body = format_conversion_registry.convert_video_request(
|
|
|
|
|
|
request_body,
|
|
|
|
|
|
self.FORMAT_ID,
|
|
|
|
|
|
provider_format,
|
|
|
|
|
|
)
|
|
|
|
|
|
# 如果 model 不在请求体中,从路径或内部请求中获取
|
|
|
|
|
|
if "model" not in converted_body:
|
|
|
|
|
|
converted_body["model"] = internal_request.model
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
if endpoint_body_rules:
|
2026-03-15 20:27:53 +08:00
|
|
|
|
converted_body = apply_body_rules(
|
|
|
|
|
|
converted_body,
|
|
|
|
|
|
endpoint_body_rules,
|
|
|
|
|
|
original_body=original_request_body,
|
|
|
|
|
|
)
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 构建 Gemini 风格的 URL
|
|
|
|
|
|
upstream_url = self._build_gemini_upstream_url(
|
|
|
|
|
|
endpoint.base_url, internal_request.model
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 构建 Gemini 风格的请求头
|
|
|
|
|
|
auth_info = await get_provider_auth(endpoint, _provider_key)
|
|
|
|
|
|
headers = self._build_gemini_upstream_headers(
|
2026-03-15 20:27:53 +08:00
|
|
|
|
original_headers,
|
|
|
|
|
|
upstream_key,
|
|
|
|
|
|
endpoint,
|
|
|
|
|
|
auth_info,
|
|
|
|
|
|
body=converted_body,
|
|
|
|
|
|
original_body=original_request_body,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
return await self._try_rust_sync_http_response(
|
2026-03-21 12:57:09 +08:00
|
|
|
|
method="POST",
|
|
|
|
|
|
url=upstream_url,
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
body=converted_body,
|
|
|
|
|
|
provider_name=str(candidate.provider.name),
|
|
|
|
|
|
provider_id=str(candidate.provider.id),
|
|
|
|
|
|
endpoint_id=str(endpoint.id),
|
|
|
|
|
|
key_id=str(_provider_key.id),
|
|
|
|
|
|
provider_api_format=provider_format,
|
|
|
|
|
|
client_api_format=self.FORMAT_ID,
|
|
|
|
|
|
model_name=internal_request.model,
|
|
|
|
|
|
content_type=str(headers.get("content-type") or "").strip()
|
|
|
|
|
|
or "application/json",
|
|
|
|
|
|
log_label="OpenAIVideoCreate",
|
|
|
|
|
|
)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
else:
|
|
|
|
|
|
# 原始 OpenAI 格式
|
2026-02-06 16:37:06 +08:00
|
|
|
|
if endpoint_body_rules:
|
2026-03-15 20:27:53 +08:00
|
|
|
|
request_body = apply_body_rules(
|
|
|
|
|
|
request_body,
|
|
|
|
|
|
endpoint_body_rules,
|
|
|
|
|
|
original_body=original_request_body,
|
|
|
|
|
|
)
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
upstream_url = self._build_upstream_url(endpoint.base_url)
|
2026-03-15 20:27:53 +08:00
|
|
|
|
headers = self._build_upstream_headers(
|
|
|
|
|
|
original_headers,
|
|
|
|
|
|
upstream_key,
|
|
|
|
|
|
endpoint,
|
|
|
|
|
|
body=request_body,
|
|
|
|
|
|
original_body=original_request_body,
|
|
|
|
|
|
)
|
2026-03-31 19:19:04 +08:00
|
|
|
|
return await self._try_rust_sync_http_response(
|
2026-03-21 12:57:09 +08:00
|
|
|
|
method="POST",
|
|
|
|
|
|
url=upstream_url,
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
body=request_body,
|
|
|
|
|
|
provider_name=str(candidate.provider.name),
|
|
|
|
|
|
provider_id=str(candidate.provider.id),
|
|
|
|
|
|
endpoint_id=str(endpoint.id),
|
|
|
|
|
|
key_id=str(_provider_key.id),
|
|
|
|
|
|
provider_api_format=provider_format,
|
|
|
|
|
|
client_api_format=self.FORMAT_ID,
|
|
|
|
|
|
model_name=internal_request.model,
|
|
|
|
|
|
content_type=str(headers.get("content-type") or "").strip()
|
|
|
|
|
|
or "application/json",
|
|
|
|
|
|
log_label="OpenAIVideoCreate",
|
|
|
|
|
|
)
|
2026-02-01 17:28:27 +08:00
|
|
|
|
|
|
|
|
|
|
def _extract_task_id(payload: dict[str, Any]) -> str | None:
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 根据响应格式提取 task ID
|
|
|
|
|
|
# OpenAI: {"id": "..."}
|
|
|
|
|
|
# Gemini: {"name": "operations/..."}
|
|
|
|
|
|
if "id" in payload:
|
|
|
|
|
|
return str(payload["id"])
|
|
|
|
|
|
if "name" in payload:
|
|
|
|
|
|
# Gemini 格式
|
|
|
|
|
|
return str(payload["name"])
|
|
|
|
|
|
return None
|
2026-02-01 17:28:27 +08:00
|
|
|
|
|
|
|
|
|
|
# 捕获提交阶段的所有错误,记录失败任务
|
|
|
|
|
|
try:
|
|
|
|
|
|
outcome_or_response = await self._submit_with_failover(
|
|
|
|
|
|
api_format=self.FORMAT_ID,
|
|
|
|
|
|
model_name=internal_request.model,
|
|
|
|
|
|
task_type="video",
|
|
|
|
|
|
submit_func=_submit,
|
|
|
|
|
|
extract_external_task_id=_extract_task_id,
|
2026-03-01 23:32:48 +08:00
|
|
|
|
supported_auth_types={"api_key", "service_account", "vertex_ai"},
|
2026-02-02 03:16:52 +08:00
|
|
|
|
allow_format_conversion=True,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
max_candidates=10,
|
|
|
|
|
|
)
|
|
|
|
|
|
except HTTPException as exc:
|
|
|
|
|
|
# 提交失败(如无可用 provider),创建失败任务记录
|
|
|
|
|
|
# 尝试获取 candidate_keys(如果是 AllCandidatesFailedError 转换来的)
|
|
|
|
|
|
candidate_keys = getattr(exc, "candidate_keys", None)
|
|
|
|
|
|
await self._create_failed_task_and_usage(
|
|
|
|
|
|
internal_request=internal_request,
|
|
|
|
|
|
original_request_body=original_request_body,
|
|
|
|
|
|
original_headers=original_headers,
|
|
|
|
|
|
error_code="provider_unavailable",
|
|
|
|
|
|
error_message=exc.detail if isinstance(exc.detail, str) else str(exc.detail),
|
|
|
|
|
|
status_code=exc.status_code,
|
|
|
|
|
|
candidate_keys=candidate_keys,
|
|
|
|
|
|
)
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
if isinstance(outcome_or_response, JSONResponse):
|
|
|
|
|
|
# 上游返回客户端错误,也要记录
|
|
|
|
|
|
await self._create_failed_task_and_usage(
|
|
|
|
|
|
internal_request=internal_request,
|
|
|
|
|
|
original_request_body=original_request_body,
|
|
|
|
|
|
original_headers=original_headers,
|
|
|
|
|
|
error_code="upstream_client_error",
|
|
|
|
|
|
error_message="Upstream rejected the request",
|
|
|
|
|
|
status_code=outcome_or_response.status_code,
|
|
|
|
|
|
)
|
|
|
|
|
|
return outcome_or_response
|
|
|
|
|
|
outcome = outcome_or_response
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
# 冻结 billing_rule 配置(用于异步任务的成本一致性)
|
|
|
|
|
|
# 复用 _select_candidate 中已查询的结果;billing_require_rule=false 时需补查
|
2026-02-01 17:28:27 +08:00
|
|
|
|
rule_lookup = outcome.rule_lookup
|
2026-01-31 19:11:25 +08:00
|
|
|
|
if rule_lookup is None:
|
|
|
|
|
|
rule_lookup = BillingRuleService.find_rule(
|
|
|
|
|
|
self.db,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
provider_id=outcome.candidate.provider.id,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
model_name=internal_request.model,
|
|
|
|
|
|
task_type="video",
|
2026-01-30 22:41:42 +08:00
|
|
|
|
)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
billing_rule_snapshot = self._build_billing_rule_snapshot(rule_lookup)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
2026-02-01 17:28:27 +08:00
|
|
|
|
external_task_id = outcome.external_task_id
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 如果发生了格式转换,记录转换后的请求体
|
|
|
|
|
|
converted_request_body = original_request_body
|
|
|
|
|
|
if format_conversion_info["converted"]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
converted_request_body = format_conversion_registry.convert_video_request(
|
|
|
|
|
|
original_request_body,
|
|
|
|
|
|
self.FORMAT_ID,
|
|
|
|
|
|
format_conversion_info["provider_format"],
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"[OpenAIVideoHandler] Failed to record converted request: {}",
|
2026-02-02 03:16:52 +08:00
|
|
|
|
sanitize_error_message(str(e)),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
task = self._create_task_record(
|
|
|
|
|
|
external_task_id=external_task_id,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
candidate=outcome.candidate,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
original_request_body=original_request_body,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
converted_request_body=converted_request_body,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
internal_request=internal_request,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
candidate_keys=outcome.candidate_keys,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
original_headers=original_headers,
|
|
|
|
|
|
billing_rule_snapshot=billing_rule_snapshot,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
format_converted=format_conversion_info["converted"],
|
2026-01-30 22:41:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.db.add(task)
|
|
|
|
|
|
self.db.flush() # 先 flush 检测冲突
|
|
|
|
|
|
self.db.commit()
|
|
|
|
|
|
self.db.refresh(task)
|
|
|
|
|
|
except IntegrityError:
|
|
|
|
|
|
self.db.rollback()
|
|
|
|
|
|
raise HTTPException(status_code=409, detail="Task already exists")
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 先构建返回给客户端的响应(OpenAI Sora 使用 UUID)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
internal_task = InternalVideoTask(
|
|
|
|
|
|
id=task.id,
|
|
|
|
|
|
external_id=external_task_id,
|
|
|
|
|
|
status=VideoStatus.SUBMITTED,
|
|
|
|
|
|
created_at=task.created_at,
|
|
|
|
|
|
original_request=internal_request,
|
|
|
|
|
|
)
|
|
|
|
|
|
response_body = self._normalizer.video_task_from_internal(internal_task)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
|
2026-03-08 00:05:48 +08:00
|
|
|
|
# 提交成功后补齐 Usage 的 provider 上下文,真正结算留到轮询完成时
|
2026-02-02 03:16:52 +08:00
|
|
|
|
response_time_ms = int((time.time() - self.start_time) * 1000)
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 构建发送给上游的请求头(脱敏)
|
|
|
|
|
|
upstream_request_headers = self._build_upstream_headers(
|
|
|
|
|
|
original_headers,
|
|
|
|
|
|
"", # key 不重要,只是用于记录
|
|
|
|
|
|
outcome.candidate.endpoint,
|
2026-03-15 20:27:53 +08:00
|
|
|
|
body=converted_request_body,
|
|
|
|
|
|
original_body=original_request_body,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
UsageService.finalize_submitted(
|
|
|
|
|
|
self.db,
|
|
|
|
|
|
request_id=self.request_id,
|
|
|
|
|
|
provider_name=outcome.candidate.provider.name,
|
|
|
|
|
|
provider_id=outcome.candidate.provider.id,
|
|
|
|
|
|
provider_endpoint_id=outcome.candidate.endpoint.id,
|
|
|
|
|
|
provider_api_key_id=outcome.candidate.key.id,
|
|
|
|
|
|
response_time_ms=response_time_ms,
|
|
|
|
|
|
status_code=outcome.upstream_status_code or 200,
|
|
|
|
|
|
endpoint_api_format=make_signature_key(
|
|
|
|
|
|
str(getattr(outcome.candidate.endpoint, "api_family", "")).strip().lower(),
|
|
|
|
|
|
str(getattr(outcome.candidate.endpoint, "endpoint_kind", "")).strip().lower(),
|
|
|
|
|
|
),
|
|
|
|
|
|
provider_request_headers=upstream_request_headers,
|
|
|
|
|
|
response_headers=outcome.upstream_headers,
|
|
|
|
|
|
response_body=response_body, # 使用我们转换后的响应(包含我们的 ID)
|
|
|
|
|
|
)
|
|
|
|
|
|
self.db.commit()
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"Failed to finalize submitted usage for video request_id={}: {}",
|
2026-02-02 03:16:52 +08:00
|
|
|
|
self.request_id,
|
|
|
|
|
|
sanitize_error_message(str(exc)),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
return JSONResponse(response_body)
|
|
|
|
|
|
|
|
|
|
|
|
async def handle_get_task(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
task_id: str,
|
|
|
|
|
|
http_request: Request,
|
|
|
|
|
|
original_headers: dict[str, str],
|
|
|
|
|
|
query_params: dict[str, str] | None = None,
|
|
|
|
|
|
path_params: dict[str, Any] | None = None,
|
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
|
task = self._get_task(task_id)
|
|
|
|
|
|
internal_task = self._task_to_internal(task)
|
|
|
|
|
|
response_body = self._normalizer.video_task_from_internal(internal_task)
|
|
|
|
|
|
return JSONResponse(response_body)
|
|
|
|
|
|
|
|
|
|
|
|
async def handle_list_tasks(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
http_request: Request,
|
|
|
|
|
|
original_headers: dict[str, str],
|
|
|
|
|
|
query_params: dict[str, str] | None = None,
|
|
|
|
|
|
path_params: dict[str, Any] | None = None,
|
|
|
|
|
|
) -> JSONResponse:
|
2026-02-02 03:16:52 +08:00
|
|
|
|
params = query_params or {}
|
|
|
|
|
|
|
|
|
|
|
|
# 解析分页参数
|
|
|
|
|
|
after = params.get("after")
|
|
|
|
|
|
try:
|
|
|
|
|
|
limit = min(int(params.get("limit") or 20), 100) # 默认 20,最大 100
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
limit = 20
|
|
|
|
|
|
order = params.get("order", "desc").lower()
|
|
|
|
|
|
if order not in ("asc", "desc"):
|
|
|
|
|
|
order = "desc"
|
|
|
|
|
|
|
|
|
|
|
|
# 构建查询
|
|
|
|
|
|
query = self.db.query(VideoTask).filter(VideoTask.user_id == self.user.id)
|
|
|
|
|
|
|
|
|
|
|
|
# 处理游标分页(after 参数使用 UUID)
|
|
|
|
|
|
if after:
|
|
|
|
|
|
after_task = (
|
|
|
|
|
|
self.db.query(VideoTask)
|
|
|
|
|
|
.filter(VideoTask.id == after, VideoTask.user_id == self.user.id)
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
|
|
|
|
|
if after_task and after_task.created_at:
|
|
|
|
|
|
if order == "desc":
|
|
|
|
|
|
query = query.filter(VideoTask.created_at < after_task.created_at)
|
|
|
|
|
|
else:
|
|
|
|
|
|
query = query.filter(VideoTask.created_at > after_task.created_at)
|
|
|
|
|
|
|
|
|
|
|
|
# 排序
|
|
|
|
|
|
if order == "asc":
|
|
|
|
|
|
query = query.order_by(VideoTask.created_at.asc())
|
|
|
|
|
|
else:
|
|
|
|
|
|
query = query.order_by(VideoTask.created_at.desc())
|
|
|
|
|
|
|
|
|
|
|
|
# 获取 limit + 1 条记录以判断是否有更多数据
|
|
|
|
|
|
tasks = query.limit(limit + 1).all()
|
|
|
|
|
|
has_more = len(tasks) > limit
|
|
|
|
|
|
tasks = tasks[:limit]
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
items = [
|
|
|
|
|
|
self._normalizer.video_task_from_internal(self._task_to_internal(t)) for t in tasks
|
|
|
|
|
|
]
|
2026-02-02 03:16:52 +08:00
|
|
|
|
|
|
|
|
|
|
response_data: dict[str, Any] = {
|
|
|
|
|
|
"object": "list",
|
|
|
|
|
|
"data": items,
|
|
|
|
|
|
"has_more": has_more,
|
|
|
|
|
|
}
|
|
|
|
|
|
# 如果有更多数据,返回最后一条的 ID 作为下一页游标
|
|
|
|
|
|
if has_more and tasks:
|
|
|
|
|
|
response_data["last_id"] = tasks[-1].id
|
|
|
|
|
|
|
|
|
|
|
|
return JSONResponse(response_data)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
|
|
|
|
|
async def handle_cancel_task(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
task_id: str,
|
|
|
|
|
|
http_request: Request,
|
|
|
|
|
|
original_headers: dict[str, str],
|
|
|
|
|
|
query_params: dict[str, str] | None = None,
|
|
|
|
|
|
path_params: dict[str, Any] | None = None,
|
|
|
|
|
|
) -> JSONResponse:
|
2026-02-02 21:16:28 +08:00
|
|
|
|
from src.services.task.service import TaskService
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
2026-02-02 21:16:28 +08:00
|
|
|
|
_ = (http_request, query_params, path_params) # reserved for future extensions
|
|
|
|
|
|
err_resp = await TaskService(self.db).cancel(
|
|
|
|
|
|
task_id,
|
|
|
|
|
|
user_id=str(self.user.id),
|
|
|
|
|
|
original_headers=original_headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
if err_resp is not None:
|
|
|
|
|
|
return self._build_error_response(err_resp)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
return JSONResponse({})
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
async def handle_delete_task(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
task_id: str,
|
|
|
|
|
|
http_request: Request,
|
|
|
|
|
|
original_headers: dict[str, str],
|
|
|
|
|
|
query_params: dict[str, str] | None = None,
|
|
|
|
|
|
path_params: dict[str, Any] | None = None,
|
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
|
"""删除已完成或失败的视频及其存储资源"""
|
|
|
|
|
|
task = self._get_task(task_id)
|
|
|
|
|
|
|
|
|
|
|
|
# 只能删除已完成或失败的视频
|
|
|
|
|
|
if task.status not in (VideoStatus.COMPLETED.value, VideoStatus.FAILED.value):
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"Can only delete completed or failed videos (current status: {task.status})",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 如果有 external_task_id,向上游发送删除请求
|
|
|
|
|
|
if task.external_task_id:
|
|
|
|
|
|
try:
|
|
|
|
|
|
endpoint, key = self._get_endpoint_and_key(task)
|
|
|
|
|
|
if key.api_key:
|
|
|
|
|
|
upstream_key = crypto_service.decrypt(key.api_key)
|
|
|
|
|
|
upstream_url = self._build_upstream_url(
|
|
|
|
|
|
endpoint.base_url, task.external_task_id
|
|
|
|
|
|
)
|
|
|
|
|
|
headers = self._build_upstream_headers(original_headers, upstream_key, endpoint)
|
|
|
|
|
|
|
2026-03-21 12:57:09 +08:00
|
|
|
|
response = await self._try_rust_sync_http_response(
|
|
|
|
|
|
method="DELETE",
|
|
|
|
|
|
url=upstream_url,
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
body=None,
|
|
|
|
|
|
provider_name="openai",
|
|
|
|
|
|
provider_id=str(getattr(endpoint, "provider_id", "") or ""),
|
|
|
|
|
|
endpoint_id=str(getattr(endpoint, "id", "") or ""),
|
|
|
|
|
|
key_id=str(getattr(key, "id", "") or ""),
|
|
|
|
|
|
provider_api_format=self.FORMAT_ID,
|
|
|
|
|
|
client_api_format=self.FORMAT_ID,
|
|
|
|
|
|
model_name=str(getattr(task, "model", "") or ""),
|
|
|
|
|
|
log_label="OpenAIVideoDelete",
|
|
|
|
|
|
)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
if response.status_code >= 400 and response.status_code != 404:
|
|
|
|
|
|
# 404 表示上游已删除,不算错误
|
|
|
|
|
|
return self._build_error_response(response)
|
2026-03-31 19:19:04 +08:00
|
|
|
|
except ProviderNotAvailableException:
|
|
|
|
|
|
raise
|
2026-02-02 03:16:52 +08:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"Failed to delete video from upstream task={}: {}",
|
2026-02-02 03:16:52 +08:00
|
|
|
|
task.id,
|
|
|
|
|
|
sanitize_error_message(str(exc)),
|
|
|
|
|
|
)
|
|
|
|
|
|
# 继续删除本地记录
|
|
|
|
|
|
|
|
|
|
|
|
# 删除本地任务记录
|
|
|
|
|
|
self.db.delete(task)
|
|
|
|
|
|
self.db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
return JSONResponse({"id": task_id, "object": "video", "deleted": True})
|
|
|
|
|
|
|
2026-02-01 17:28:27 +08:00
|
|
|
|
async def handle_remix_task(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
task_id: str,
|
|
|
|
|
|
http_request: Request,
|
|
|
|
|
|
original_headers: dict[str, str],
|
|
|
|
|
|
original_request_body: dict[str, Any],
|
|
|
|
|
|
query_params: dict[str, str] | None = None,
|
|
|
|
|
|
path_params: dict[str, Any] | None = None,
|
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
|
# 获取原始任务以验证所有权和状态
|
|
|
|
|
|
original_task = self._get_task(task_id)
|
|
|
|
|
|
if original_task.status != VideoStatus.COMPLETED.value:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"Can only remix completed videos (current status: {original_task.status})",
|
|
|
|
|
|
)
|
|
|
|
|
|
if not original_task.external_task_id:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="Original task missing external_task_id")
|
|
|
|
|
|
|
|
|
|
|
|
# 获取原始任务的 endpoint 和 key
|
|
|
|
|
|
endpoint, key = self._get_endpoint_and_key(original_task)
|
|
|
|
|
|
if not key.api_key:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="Provider key not configured")
|
|
|
|
|
|
upstream_key = crypto_service.decrypt(key.api_key)
|
|
|
|
|
|
|
|
|
|
|
|
# 构建 remix 请求的上游 URL
|
|
|
|
|
|
upstream_url = self._build_upstream_url(
|
|
|
|
|
|
endpoint.base_url, f"{original_task.external_task_id}/remix"
|
|
|
|
|
|
)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 确保 seconds 字段为字符串类型(上游 Go 服务要求 string)
|
|
|
|
|
|
request_body = original_request_body.copy()
|
|
|
|
|
|
if "seconds" in request_body and request_body["seconds"] is not None:
|
|
|
|
|
|
request_body["seconds"] = str(request_body["seconds"])
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
# 应用端点的请求体规则
|
|
|
|
|
|
endpoint_body_rules = getattr(endpoint, "body_rules", None)
|
|
|
|
|
|
if endpoint_body_rules:
|
2026-03-15 20:27:53 +08:00
|
|
|
|
request_body = apply_body_rules(
|
|
|
|
|
|
request_body,
|
|
|
|
|
|
endpoint_body_rules,
|
|
|
|
|
|
original_body=original_request_body,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
headers = self._build_upstream_headers(
|
|
|
|
|
|
original_headers,
|
|
|
|
|
|
upstream_key,
|
|
|
|
|
|
endpoint,
|
|
|
|
|
|
body=request_body,
|
|
|
|
|
|
original_body=original_request_body,
|
|
|
|
|
|
)
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
2026-03-21 12:57:09 +08:00
|
|
|
|
response = await self._try_rust_sync_http_response(
|
|
|
|
|
|
method="POST",
|
|
|
|
|
|
url=upstream_url,
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
body=request_body,
|
|
|
|
|
|
provider_name="openai",
|
|
|
|
|
|
provider_id=str(getattr(endpoint, "provider_id", "") or ""),
|
|
|
|
|
|
endpoint_id=str(getattr(endpoint, "id", "") or ""),
|
|
|
|
|
|
key_id=str(getattr(key, "id", "") or ""),
|
|
|
|
|
|
provider_api_format=self.FORMAT_ID,
|
|
|
|
|
|
client_api_format=self.FORMAT_ID,
|
|
|
|
|
|
model_name=str(getattr(original_task, "model", "") or ""),
|
|
|
|
|
|
content_type=str(headers.get("content-type") or "").strip() or "application/json",
|
|
|
|
|
|
log_label="OpenAIVideoRemix",
|
|
|
|
|
|
)
|
2026-02-01 17:28:27 +08:00
|
|
|
|
if response.status_code >= 400:
|
|
|
|
|
|
return self._build_error_response(response)
|
|
|
|
|
|
|
|
|
|
|
|
# 解析上游响应
|
|
|
|
|
|
try:
|
|
|
|
|
|
response_data = response.json()
|
|
|
|
|
|
except (ValueError, TypeError):
|
|
|
|
|
|
raise HTTPException(status_code=502, detail="Invalid response from upstream")
|
|
|
|
|
|
|
|
|
|
|
|
external_task_id = response_data.get("id")
|
|
|
|
|
|
if not external_task_id:
|
|
|
|
|
|
raise HTTPException(status_code=502, detail="Upstream did not return task ID")
|
|
|
|
|
|
|
|
|
|
|
|
# 解析 remix 请求
|
|
|
|
|
|
try:
|
|
|
|
|
|
internal_request = self._normalizer.video_request_to_internal(
|
|
|
|
|
|
{
|
|
|
|
|
|
"prompt": original_request_body.get("prompt", ""),
|
|
|
|
|
|
"model": original_task.model,
|
|
|
|
|
|
"size": original_task.size,
|
|
|
|
|
|
"seconds": original_task.duration_seconds,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
# 复用原始任务的 billing rule snapshot
|
|
|
|
|
|
billing_rule_snapshot = None
|
|
|
|
|
|
if original_task.request_metadata:
|
|
|
|
|
|
billing_rule_snapshot = original_task.request_metadata.get("billing_rule_snapshot")
|
|
|
|
|
|
|
|
|
|
|
|
# 构建 ProviderCandidate(复用原始任务的 provider 配置)
|
|
|
|
|
|
from src.models.database import Provider
|
|
|
|
|
|
|
|
|
|
|
|
provider = self.db.query(Provider).filter(Provider.id == original_task.provider_id).first()
|
|
|
|
|
|
if not provider:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="Provider not found")
|
|
|
|
|
|
|
|
|
|
|
|
candidate = ProviderCandidate(
|
|
|
|
|
|
provider=provider,
|
|
|
|
|
|
endpoint=endpoint,
|
|
|
|
|
|
key=key,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 创建新任务记录
|
|
|
|
|
|
task = self._create_task_record(
|
|
|
|
|
|
external_task_id=external_task_id,
|
|
|
|
|
|
candidate=candidate,
|
|
|
|
|
|
original_request_body={
|
|
|
|
|
|
**original_request_body,
|
|
|
|
|
|
"remix_video_id": task_id,
|
|
|
|
|
|
},
|
|
|
|
|
|
internal_request=internal_request,
|
|
|
|
|
|
original_headers=original_headers,
|
|
|
|
|
|
billing_rule_snapshot=billing_rule_snapshot,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.db.add(task)
|
|
|
|
|
|
self.db.flush()
|
|
|
|
|
|
self.db.commit()
|
|
|
|
|
|
self.db.refresh(task)
|
|
|
|
|
|
except IntegrityError:
|
|
|
|
|
|
self.db.rollback()
|
|
|
|
|
|
raise HTTPException(status_code=409, detail="Task already exists")
|
|
|
|
|
|
|
|
|
|
|
|
internal_task = InternalVideoTask(
|
2026-02-02 03:16:52 +08:00
|
|
|
|
id=task.id, # OpenAI Sora 使用 UUID
|
2026-02-01 17:28:27 +08:00
|
|
|
|
external_id=external_task_id,
|
|
|
|
|
|
status=VideoStatus.SUBMITTED,
|
|
|
|
|
|
created_at=task.created_at,
|
|
|
|
|
|
original_request=internal_request,
|
|
|
|
|
|
)
|
|
|
|
|
|
response_body = self._normalizer.video_task_from_internal(internal_task)
|
|
|
|
|
|
return JSONResponse(response_body)
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
async def handle_download_content(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
task_id: str,
|
|
|
|
|
|
http_request: Request,
|
|
|
|
|
|
original_headers: dict[str, str],
|
|
|
|
|
|
query_params: dict[str, str] | None = None,
|
|
|
|
|
|
path_params: dict[str, Any] | None = None,
|
|
|
|
|
|
) -> Response | StreamingResponse:
|
|
|
|
|
|
task = self._get_task(task_id)
|
|
|
|
|
|
|
|
|
|
|
|
# 根据本地任务状态提前返回适当错误(避免不必要的上游请求)
|
|
|
|
|
|
if task.status in (
|
|
|
|
|
|
VideoStatus.PENDING.value,
|
|
|
|
|
|
VideoStatus.SUBMITTED.value,
|
|
|
|
|
|
VideoStatus.QUEUED.value,
|
|
|
|
|
|
VideoStatus.PROCESSING.value,
|
|
|
|
|
|
):
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=202,
|
|
|
|
|
|
detail=f"Video is still processing (status: {task.status})",
|
|
|
|
|
|
)
|
|
|
|
|
|
if task.status == VideoStatus.FAILED.value:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=422,
|
|
|
|
|
|
detail=f"Video generation failed: {task.error_message or 'Unknown error'}",
|
|
|
|
|
|
)
|
|
|
|
|
|
if task.status == VideoStatus.CANCELLED.value:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Video task was cancelled")
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 支持 variant 查询参数: video (默认), thumbnail, spritesheet
|
|
|
|
|
|
variant = (query_params or {}).get("variant", "video")
|
|
|
|
|
|
|
|
|
|
|
|
# 如果 video_url 是完整的 HTTP URL,直接代理该 URL(适用于不支持 /content 端点的上游如 API易)
|
|
|
|
|
|
# 保持流式代理而非重定向,确保客户端行为与官方 OpenAI 一致
|
|
|
|
|
|
if variant == "video" and task.video_url and task.video_url.startswith("http"):
|
|
|
|
|
|
logger.debug(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"[VideoDownload] Proxying direct URL task={} url={}",
|
2026-02-02 03:16:52 +08:00
|
|
|
|
task_id,
|
|
|
|
|
|
task.video_url,
|
|
|
|
|
|
)
|
2026-03-31 19:19:04 +08:00
|
|
|
|
return await self._try_rust_download_stream(
|
2026-03-21 12:57:09 +08:00
|
|
|
|
url=task.video_url,
|
|
|
|
|
|
headers={},
|
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
|
model_name=str(getattr(task, "model", "") or "") or None,
|
|
|
|
|
|
default_media_type="video/mp4",
|
|
|
|
|
|
default_error_message="Video not available",
|
|
|
|
|
|
)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
if not task.external_task_id:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="Task missing external_task_id")
|
|
|
|
|
|
endpoint, key = self._get_endpoint_and_key(task)
|
|
|
|
|
|
if not key.api_key:
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="Provider key not configured")
|
|
|
|
|
|
upstream_key = crypto_service.decrypt(key.api_key)
|
2026-02-01 17:28:27 +08:00
|
|
|
|
if variant not in {"video", "thumbnail", "spritesheet"}:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400,
|
|
|
|
|
|
detail=f"Invalid variant '{variant}'. Must be one of: video, thumbnail, spritesheet",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 构建上游 URL,透传 variant 参数
|
|
|
|
|
|
content_path = f"{task.external_task_id}/content"
|
|
|
|
|
|
if variant != "video":
|
|
|
|
|
|
content_path = f"{content_path}?variant={variant}"
|
|
|
|
|
|
upstream_url = self._build_upstream_url(endpoint.base_url, content_path)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
headers = self._build_upstream_headers(original_headers, upstream_key, endpoint)
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
logger.debug(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"[VideoDownload] Requesting upstream url={} task={} external_task_id={}",
|
2026-02-02 03:16:52 +08:00
|
|
|
|
upstream_url,
|
|
|
|
|
|
task_id,
|
|
|
|
|
|
task.external_task_id,
|
|
|
|
|
|
)
|
2026-03-31 19:19:04 +08:00
|
|
|
|
return await self._try_rust_download_stream(
|
2026-03-21 12:57:09 +08:00
|
|
|
|
url=upstream_url,
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
|
provider_id=str(getattr(endpoint, "provider_id", "") or "") or None,
|
|
|
|
|
|
endpoint_id=str(getattr(endpoint, "id", "") or "") or None,
|
|
|
|
|
|
key_id=str(getattr(key, "id", "") or "") or None,
|
|
|
|
|
|
model_name=str(getattr(task, "model", "") or "") or None,
|
|
|
|
|
|
default_media_type="application/octet-stream",
|
|
|
|
|
|
default_error_message="Upstream connection failed",
|
|
|
|
|
|
)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Helpers
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async def _resolve_upstream_key(
|
|
|
|
|
|
self, candidate: ProviderCandidate
|
|
|
|
|
|
) -> tuple[str, ProviderEndpoint, ProviderAPIKey]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
upstream_key = crypto_service.decrypt(candidate.key.api_key)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.error(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"Failed to decrypt provider key id={}: {}",
|
2026-01-30 22:41:42 +08:00
|
|
|
|
candidate.key.id,
|
|
|
|
|
|
sanitize_error_message(str(exc)),
|
|
|
|
|
|
)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to decrypt provider key")
|
|
|
|
|
|
return upstream_key, candidate.endpoint, candidate.key
|
|
|
|
|
|
|
2026-03-21 12:57:09 +08:00
|
|
|
|
async def _try_rust_download_stream(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
url: str,
|
|
|
|
|
|
headers: dict[str, str],
|
|
|
|
|
|
task_id: str,
|
|
|
|
|
|
provider_id: str | None = None,
|
|
|
|
|
|
endpoint_id: str | None = None,
|
|
|
|
|
|
key_id: str | None = None,
|
|
|
|
|
|
model_name: str | None = None,
|
|
|
|
|
|
default_media_type: str,
|
|
|
|
|
|
default_error_message: str,
|
2026-03-31 19:19:04 +08:00
|
|
|
|
) -> Response | StreamingResponse:
|
2026-04-03 14:59:58 +08:00
|
|
|
|
if config.execution_runtime_backend != "rust":
|
2026-03-31 19:19:04 +08:00
|
|
|
|
raise ProviderNotAvailableException(
|
|
|
|
|
|
"Video 下载仅支持 Rust executor",
|
|
|
|
|
|
provider_name="openai",
|
2026-04-03 14:59:58 +08:00
|
|
|
|
upstream_response=f"executor_backend={config.execution_runtime_backend}",
|
2026-03-31 19:19:04 +08:00
|
|
|
|
)
|
2026-03-21 12:57:09 +08:00
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
try:
|
|
|
|
|
|
plan = ExecutionPlan(
|
|
|
|
|
|
request_id=str(self.request_id or ""),
|
|
|
|
|
|
candidate_id=None,
|
|
|
|
|
|
provider_name="openai",
|
|
|
|
|
|
provider_id=str(provider_id or ""),
|
|
|
|
|
|
endpoint_id=str(endpoint_id or ""),
|
|
|
|
|
|
key_id=str(key_id or ""),
|
|
|
|
|
|
method="GET",
|
|
|
|
|
|
url=url,
|
|
|
|
|
|
headers=dict(headers),
|
|
|
|
|
|
body=ExecutionPlanBody(),
|
|
|
|
|
|
stream=True,
|
|
|
|
|
|
provider_api_format=self.FORMAT_ID,
|
|
|
|
|
|
client_api_format=self.FORMAT_ID,
|
|
|
|
|
|
model_name=str(model_name or "") or "",
|
|
|
|
|
|
timeouts=ExecutionPlanTimeouts(
|
|
|
|
|
|
connect_ms=30_000,
|
|
|
|
|
|
read_ms=300_000,
|
|
|
|
|
|
write_ms=300_000,
|
|
|
|
|
|
pool_ms=30_000,
|
|
|
|
|
|
total_ms=None,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[VideoDownload] Rust plan build failed task={} url={}: {}",
|
|
|
|
|
|
task_id,
|
|
|
|
|
|
url,
|
|
|
|
|
|
sanitize_error_message(str(exc)),
|
|
|
|
|
|
)
|
|
|
|
|
|
raise ProviderNotAvailableException(
|
|
|
|
|
|
"Rust executor 请求计划构建失败",
|
|
|
|
|
|
provider_name="openai",
|
|
|
|
|
|
upstream_response=sanitize_error_message(str(exc)),
|
|
|
|
|
|
) from exc
|
2026-03-21 12:57:09 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
2026-04-03 14:59:58 +08:00
|
|
|
|
rust_stream = await ExecutionRuntimeClient().execute_stream(plan)
|
|
|
|
|
|
except (ExecutionRuntimeClientError, httpx.HTTPError, json.JSONDecodeError) as exc:
|
2026-03-21 12:57:09 +08:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[VideoDownload] Rust executor unavailable task={} url={}: {}",
|
|
|
|
|
|
task_id,
|
|
|
|
|
|
url,
|
|
|
|
|
|
sanitize_error_message(str(exc)),
|
|
|
|
|
|
)
|
2026-03-31 19:19:04 +08:00
|
|
|
|
raise ProviderNotAvailableException(
|
|
|
|
|
|
"执行器暂时不可用,请稍后重试",
|
|
|
|
|
|
provider_name="openai",
|
|
|
|
|
|
upstream_response=sanitize_error_message(str(exc)),
|
|
|
|
|
|
) from exc
|
2026-03-21 12:57:09 +08:00
|
|
|
|
|
|
|
|
|
|
safe_headers = {
|
|
|
|
|
|
k: v for k, v in rust_stream.headers.items() if k.lower() not in HOP_BY_HOP_HEADERS
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if rust_stream.status_code >= 400:
|
|
|
|
|
|
error_chunks: list[bytes] = []
|
|
|
|
|
|
try:
|
|
|
|
|
|
async for chunk in rust_stream.byte_iterator:
|
|
|
|
|
|
if chunk:
|
|
|
|
|
|
error_chunks.append(chunk)
|
|
|
|
|
|
if sum(len(item) for item in error_chunks) >= 16_384:
|
|
|
|
|
|
break
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await rust_stream.response_ctx.__aexit__(None, None, None)
|
|
|
|
|
|
|
|
|
|
|
|
error_body = b"".join(error_chunks)[:16_384]
|
|
|
|
|
|
content_type = str(safe_headers.get("content-type") or "").lower()
|
|
|
|
|
|
if "application/json" in content_type:
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = json.loads(error_body)
|
|
|
|
|
|
if isinstance(data, dict) and isinstance(data.get("error"), dict):
|
|
|
|
|
|
if "message" in data["error"]:
|
|
|
|
|
|
data["error"]["message"] = sanitize_error_message(
|
|
|
|
|
|
str(data["error"]["message"])
|
|
|
|
|
|
)
|
|
|
|
|
|
return JSONResponse(status_code=rust_stream.status_code, content=data)
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
message = sanitize_error_message(error_body.decode(errors="ignore"))
|
|
|
|
|
|
return JSONResponse(
|
|
|
|
|
|
status_code=rust_stream.status_code,
|
|
|
|
|
|
content={
|
|
|
|
|
|
"error": {
|
|
|
|
|
|
"type": "upstream_error",
|
|
|
|
|
|
"message": message or default_error_message,
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def _iter_bytes() -> AsyncIterator[bytes]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
async for chunk in rust_stream.byte_iterator:
|
|
|
|
|
|
yield chunk
|
|
|
|
|
|
finally:
|
|
|
|
|
|
await rust_stream.response_ctx.__aexit__(None, None, None)
|
|
|
|
|
|
|
|
|
|
|
|
return StreamingResponse(
|
|
|
|
|
|
_iter_bytes(),
|
|
|
|
|
|
status_code=rust_stream.status_code,
|
|
|
|
|
|
headers=safe_headers,
|
|
|
|
|
|
media_type=safe_headers.get("content-type", default_media_type),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
def _build_upstream_url(self, base_url: str | None, suffix: str | None = None) -> str:
|
|
|
|
|
|
base = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
|
|
|
|
|
|
if base.endswith("/v1"):
|
|
|
|
|
|
url = f"{base}/videos"
|
|
|
|
|
|
else:
|
|
|
|
|
|
url = f"{base}/v1/videos"
|
|
|
|
|
|
if suffix:
|
|
|
|
|
|
return f"{url}/{suffix}"
|
|
|
|
|
|
return url
|
|
|
|
|
|
|
|
|
|
|
|
def _build_upstream_headers(
|
2026-03-15 20:27:53 +08:00
|
|
|
|
self,
|
|
|
|
|
|
original_headers: dict[str, str],
|
|
|
|
|
|
upstream_key: str,
|
|
|
|
|
|
endpoint: ProviderEndpoint,
|
|
|
|
|
|
*,
|
|
|
|
|
|
body: dict[str, Any] | None = None,
|
|
|
|
|
|
original_body: dict[str, Any] | None = None,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
) -> dict[str, str]:
|
|
|
|
|
|
extra_headers = get_extra_headers_from_endpoint(endpoint)
|
2026-02-01 17:28:27 +08:00
|
|
|
|
endpoint_sig = make_signature_key(
|
|
|
|
|
|
str(getattr(endpoint, "api_family", "")).strip().lower(),
|
|
|
|
|
|
str(getattr(endpoint, "endpoint_kind", "")).strip().lower(),
|
|
|
|
|
|
)
|
|
|
|
|
|
return build_upstream_headers_for_endpoint(
|
2026-01-30 22:41:42 +08:00
|
|
|
|
original_headers,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
endpoint_sig,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
upstream_key,
|
|
|
|
|
|
endpoint_headers=extra_headers,
|
2026-02-06 16:37:06 +08:00
|
|
|
|
header_rules=getattr(endpoint, "header_rules", None),
|
2026-03-15 20:27:53 +08:00
|
|
|
|
body=body,
|
|
|
|
|
|
original_body=original_body,
|
|
|
|
|
|
condition_evaluator=evaluate_condition,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# Gemini format conversion helpers
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _build_gemini_upstream_url(self, base_url: str | None, model: str) -> str:
|
|
|
|
|
|
"""构建 Gemini Veo API 的上游 URL"""
|
|
|
|
|
|
base = (base_url or "https://generativelanguage.googleapis.com").rstrip("/")
|
|
|
|
|
|
if base.endswith("/v1beta"):
|
|
|
|
|
|
base = base[: -len("/v1beta")]
|
|
|
|
|
|
return f"{base}/v1beta/models/{model}:predictLongRunning"
|
|
|
|
|
|
|
|
|
|
|
|
def _build_gemini_upstream_headers(
|
|
|
|
|
|
self,
|
|
|
|
|
|
original_headers: dict[str, str],
|
|
|
|
|
|
upstream_key: str,
|
|
|
|
|
|
endpoint: ProviderEndpoint,
|
|
|
|
|
|
auth_info: Any | None,
|
2026-03-15 20:27:53 +08:00
|
|
|
|
*,
|
|
|
|
|
|
body: dict[str, Any] | None = None,
|
|
|
|
|
|
original_body: dict[str, Any] | None = None,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
) -> dict[str, str]:
|
|
|
|
|
|
"""构建 Gemini 格式的请求头"""
|
|
|
|
|
|
extra_headers = get_extra_headers_from_endpoint(endpoint)
|
|
|
|
|
|
endpoint_sig = make_signature_key(
|
|
|
|
|
|
str(getattr(endpoint, "api_family", "")).strip().lower(),
|
|
|
|
|
|
str(getattr(endpoint, "endpoint_kind", "")).strip().lower(),
|
|
|
|
|
|
)
|
|
|
|
|
|
headers = build_upstream_headers_for_endpoint(
|
|
|
|
|
|
original_headers,
|
|
|
|
|
|
endpoint_sig,
|
|
|
|
|
|
upstream_key,
|
|
|
|
|
|
endpoint_headers=extra_headers,
|
2026-02-06 16:37:06 +08:00
|
|
|
|
header_rules=getattr(endpoint, "header_rules", None),
|
2026-03-15 20:27:53 +08:00
|
|
|
|
body=body,
|
|
|
|
|
|
original_body=original_body,
|
|
|
|
|
|
condition_evaluator=evaluate_condition,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
)
|
|
|
|
|
|
if auth_info:
|
|
|
|
|
|
# 覆盖为 OAuth2 Bearer(Vertex AI)
|
|
|
|
|
|
headers.pop("x-goog-api-key", None)
|
|
|
|
|
|
headers[auth_info.auth_header] = auth_info.auth_value
|
|
|
|
|
|
return headers
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
# _build_error_response 继承自基类 VideoHandlerBase
|
|
|
|
|
|
|
|
|
|
|
|
def _create_task_record(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
external_task_id: str,
|
|
|
|
|
|
candidate: ProviderCandidate,
|
|
|
|
|
|
original_request_body: dict[str, Any],
|
|
|
|
|
|
internal_request: InternalVideoRequest,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
candidate_keys: list[dict[str, Any]] | None = None,
|
|
|
|
|
|
original_headers: dict[str, str] | None = None,
|
|
|
|
|
|
billing_rule_snapshot: dict[str, Any] | None = None,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
converted_request_body: dict[str, Any] | None = None,
|
|
|
|
|
|
format_converted: bool = False,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
) -> VideoTask:
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
|
size = internal_request.extra.get("original_size")
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
# 构建请求元数据(使用追踪信息)
|
|
|
|
|
|
request_metadata = {
|
|
|
|
|
|
"candidate_keys": candidate_keys or [],
|
|
|
|
|
|
"selected_key_id": candidate.key.id,
|
|
|
|
|
|
"selected_endpoint_id": candidate.endpoint.id,
|
|
|
|
|
|
"client_ip": self.client_ip,
|
|
|
|
|
|
"user_agent": self.user_agent,
|
|
|
|
|
|
"request_id": self.request_id,
|
|
|
|
|
|
"billing_rule_snapshot": billing_rule_snapshot,
|
|
|
|
|
|
}
|
|
|
|
|
|
# 记录请求头(脱敏处理)
|
|
|
|
|
|
if original_headers:
|
|
|
|
|
|
safe_headers = {
|
|
|
|
|
|
k: v
|
|
|
|
|
|
for k, v in original_headers.items()
|
|
|
|
|
|
if k.lower() not in {"authorization", "x-api-key", "cookie"}
|
|
|
|
|
|
}
|
|
|
|
|
|
request_metadata["request_headers"] = safe_headers
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
provider_api_format = make_signature_key(
|
|
|
|
|
|
str(getattr(candidate.endpoint, "api_family", "")).strip().lower(),
|
|
|
|
|
|
str(getattr(candidate.endpoint, "endpoint_kind", "")).strip().lower(),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
return VideoTask(
|
|
|
|
|
|
id=str(uuid4()),
|
2026-02-02 03:16:52 +08:00
|
|
|
|
request_id=self.request_id,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
external_task_id=external_task_id,
|
|
|
|
|
|
user_id=self.user.id,
|
|
|
|
|
|
api_key_id=self.api_key.id,
|
2026-03-08 14:31:15 +08:00
|
|
|
|
username=self.user.username,
|
|
|
|
|
|
api_key_name=self.api_key.name,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
provider_id=candidate.provider.id,
|
|
|
|
|
|
endpoint_id=candidate.endpoint.id,
|
|
|
|
|
|
key_id=candidate.key.id,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
client_api_format=self.FORMAT_ID,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
provider_api_format=provider_api_format,
|
|
|
|
|
|
format_converted=format_converted,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
model=internal_request.model,
|
|
|
|
|
|
prompt=internal_request.prompt,
|
|
|
|
|
|
original_request_body=original_request_body,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
converted_request_body=converted_request_body or original_request_body,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
duration_seconds=internal_request.duration_seconds,
|
|
|
|
|
|
resolution=internal_request.resolution,
|
|
|
|
|
|
aspect_ratio=internal_request.aspect_ratio,
|
|
|
|
|
|
size=size,
|
|
|
|
|
|
status=VideoStatus.SUBMITTED.value,
|
|
|
|
|
|
progress_percent=0,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
poll_interval_seconds=config.video_poll_interval_seconds,
|
|
|
|
|
|
next_poll_at=now + timedelta(seconds=config.video_poll_interval_seconds),
|
2026-01-30 22:41:42 +08:00
|
|
|
|
poll_count=0,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
max_poll_count=config.video_max_poll_count,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
submitted_at=now,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
request_metadata=request_metadata,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _task_to_internal(self, task: VideoTask) -> InternalVideoTask:
|
|
|
|
|
|
try:
|
|
|
|
|
|
status = VideoStatus(task.status)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
status = VideoStatus.PENDING
|
2026-02-02 03:16:52 +08:00
|
|
|
|
|
|
|
|
|
|
# 构建 extra 字段
|
|
|
|
|
|
extra: dict[str, Any] = {
|
|
|
|
|
|
"model": task.model,
|
|
|
|
|
|
"size": task.size,
|
|
|
|
|
|
"seconds": str(task.duration_seconds) if task.duration_seconds else None,
|
|
|
|
|
|
"prompt": task.prompt,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 检查是否是 remix 视频
|
|
|
|
|
|
if task.original_request_body and isinstance(task.original_request_body, dict):
|
|
|
|
|
|
remixed_from = task.original_request_body.get("remix_video_id")
|
|
|
|
|
|
if remixed_from:
|
|
|
|
|
|
extra["remixed_from_video_id"] = remixed_from
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
return InternalVideoTask(
|
2026-02-02 03:16:52 +08:00
|
|
|
|
id=task.id, # OpenAI Sora 使用 UUID
|
2026-01-30 22:41:42 +08:00
|
|
|
|
external_id=task.external_task_id,
|
|
|
|
|
|
status=status,
|
|
|
|
|
|
progress_percent=task.progress_percent or 0,
|
|
|
|
|
|
progress_message=task.progress_message,
|
|
|
|
|
|
video_url=task.video_url,
|
|
|
|
|
|
video_urls=task.video_urls or [],
|
|
|
|
|
|
thumbnail_url=task.thumbnail_url,
|
|
|
|
|
|
video_duration_seconds=task.duration_seconds,
|
|
|
|
|
|
video_size_bytes=task.video_size_bytes,
|
|
|
|
|
|
created_at=task.created_at,
|
|
|
|
|
|
completed_at=task.completed_at,
|
|
|
|
|
|
expires_at=task.video_expires_at,
|
|
|
|
|
|
error_code=task.error_code,
|
|
|
|
|
|
error_message=task.error_message,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
extra=extra,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-01 17:28:27 +08:00
|
|
|
|
async def _record_failed_usage(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
model: str,
|
|
|
|
|
|
error_message: str,
|
|
|
|
|
|
status_code: int,
|
|
|
|
|
|
original_request_body: dict[str, Any],
|
|
|
|
|
|
original_headers: dict[str, str],
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""记录失败请求的使用记录(无任务记录)"""
|
|
|
|
|
|
response_time_ms = int((time.time() - self.start_time) * 1000)
|
|
|
|
|
|
safe_headers = {
|
|
|
|
|
|
k: v
|
|
|
|
|
|
for k, v in original_headers.items()
|
|
|
|
|
|
if k.lower() not in {"authorization", "x-api-key", "cookie"}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
await UsageService.record_usage_with_custom_cost(
|
|
|
|
|
|
db=self.db,
|
|
|
|
|
|
user=self.user,
|
|
|
|
|
|
api_key=self.api_key,
|
|
|
|
|
|
provider="unknown",
|
|
|
|
|
|
model=model,
|
|
|
|
|
|
request_type="video",
|
|
|
|
|
|
total_cost_usd=0.0,
|
|
|
|
|
|
request_cost_usd=0.0,
|
|
|
|
|
|
input_tokens=0,
|
|
|
|
|
|
output_tokens=0,
|
|
|
|
|
|
cache_creation_input_tokens=0,
|
|
|
|
|
|
cache_read_input_tokens=0,
|
|
|
|
|
|
api_format=self.FORMAT_ID,
|
2026-02-21 13:17:11 +08:00
|
|
|
|
api_family=self.API_FAMILY.value if self.API_FAMILY else None,
|
|
|
|
|
|
endpoint_kind=self.ENDPOINT_KIND.value if self.ENDPOINT_KIND else None,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
endpoint_api_format=None,
|
|
|
|
|
|
has_format_conversion=False,
|
|
|
|
|
|
is_stream=False,
|
|
|
|
|
|
response_time_ms=response_time_ms,
|
|
|
|
|
|
first_byte_time_ms=None,
|
|
|
|
|
|
status_code=status_code,
|
|
|
|
|
|
error_message=error_message,
|
|
|
|
|
|
metadata={"failure_stage": "request_parsing"},
|
|
|
|
|
|
request_headers=safe_headers,
|
|
|
|
|
|
request_body=original_request_body,
|
|
|
|
|
|
provider_request_headers=None,
|
|
|
|
|
|
response_headers=None,
|
|
|
|
|
|
client_response_headers=None,
|
|
|
|
|
|
response_body=None,
|
|
|
|
|
|
request_id=self.request_id,
|
|
|
|
|
|
provider_id=None,
|
|
|
|
|
|
provider_endpoint_id=None,
|
|
|
|
|
|
provider_api_key_id=None,
|
|
|
|
|
|
status="failed",
|
|
|
|
|
|
target_model=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
2026-02-02 21:16:28 +08:00
|
|
|
|
logger.warning("Failed to record failed usage: {}", sanitize_error_message(str(exc)))
|
2026-02-01 17:28:27 +08:00
|
|
|
|
|
|
|
|
|
|
async def _create_failed_task_and_usage(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
internal_request: InternalVideoRequest,
|
|
|
|
|
|
original_request_body: dict[str, Any],
|
|
|
|
|
|
original_headers: dict[str, str],
|
|
|
|
|
|
error_code: str,
|
|
|
|
|
|
error_message: str,
|
|
|
|
|
|
status_code: int,
|
|
|
|
|
|
candidate_keys: list[dict[str, Any]] | None = None,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""创建失败的任务记录和使用记录"""
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
|
response_time_ms = int((time.time() - self.start_time) * 1000)
|
|
|
|
|
|
|
|
|
|
|
|
# 构建请求元数据
|
|
|
|
|
|
safe_headers = {
|
|
|
|
|
|
k: v
|
|
|
|
|
|
for k, v in original_headers.items()
|
|
|
|
|
|
if k.lower() not in {"authorization", "x-api-key", "cookie"}
|
|
|
|
|
|
}
|
|
|
|
|
|
request_metadata: dict[str, Any] = {
|
|
|
|
|
|
"client_ip": self.client_ip,
|
|
|
|
|
|
"user_agent": self.user_agent,
|
|
|
|
|
|
"request_id": self.request_id,
|
|
|
|
|
|
"request_headers": safe_headers,
|
|
|
|
|
|
"failure_stage": "submit",
|
|
|
|
|
|
}
|
|
|
|
|
|
# 添加候选链路追踪信息
|
|
|
|
|
|
if candidate_keys:
|
|
|
|
|
|
request_metadata["candidate_keys"] = candidate_keys
|
|
|
|
|
|
|
|
|
|
|
|
size = internal_request.extra.get("original_size")
|
|
|
|
|
|
|
|
|
|
|
|
# 创建失败的任务记录
|
|
|
|
|
|
task = VideoTask(
|
|
|
|
|
|
id=str(uuid4()),
|
2026-02-02 03:16:52 +08:00
|
|
|
|
request_id=self.request_id,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
external_task_id=None,
|
|
|
|
|
|
user_id=self.user.id,
|
|
|
|
|
|
api_key_id=self.api_key.id,
|
2026-03-08 14:31:15 +08:00
|
|
|
|
username=self.user.username,
|
|
|
|
|
|
api_key_name=self.api_key.name,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
provider_id=None,
|
|
|
|
|
|
endpoint_id=None,
|
|
|
|
|
|
key_id=None,
|
|
|
|
|
|
client_api_format=self.FORMAT_ID,
|
|
|
|
|
|
provider_api_format=self.FORMAT_ID,
|
|
|
|
|
|
format_converted=False,
|
|
|
|
|
|
model=internal_request.model,
|
|
|
|
|
|
prompt=internal_request.prompt,
|
|
|
|
|
|
original_request_body=original_request_body,
|
|
|
|
|
|
converted_request_body=original_request_body,
|
|
|
|
|
|
duration_seconds=internal_request.duration_seconds,
|
|
|
|
|
|
resolution=internal_request.resolution,
|
|
|
|
|
|
aspect_ratio=internal_request.aspect_ratio,
|
|
|
|
|
|
size=size,
|
|
|
|
|
|
status=VideoStatus.FAILED.value,
|
|
|
|
|
|
progress_percent=0,
|
|
|
|
|
|
error_code=error_code,
|
|
|
|
|
|
error_message=error_message,
|
|
|
|
|
|
submitted_at=now,
|
|
|
|
|
|
completed_at=now,
|
|
|
|
|
|
request_metadata=request_metadata,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.db.add(task)
|
|
|
|
|
|
self.db.commit()
|
|
|
|
|
|
self.db.refresh(task)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
self.db.rollback()
|
|
|
|
|
|
logger.warning(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"Failed to create failed task record: {}", sanitize_error_message(str(exc))
|
2026-02-01 17:28:27 +08:00
|
|
|
|
)
|
|
|
|
|
|
# 即使任务记录失败,仍然尝试记录使用记录
|
|
|
|
|
|
task = None
|
|
|
|
|
|
|
|
|
|
|
|
# 记录使用记录
|
|
|
|
|
|
try:
|
|
|
|
|
|
await UsageService.record_usage_with_custom_cost(
|
|
|
|
|
|
db=self.db,
|
|
|
|
|
|
user=self.user,
|
|
|
|
|
|
api_key=self.api_key,
|
|
|
|
|
|
provider="unknown",
|
|
|
|
|
|
model=internal_request.model,
|
|
|
|
|
|
request_type="video",
|
|
|
|
|
|
total_cost_usd=0.0,
|
|
|
|
|
|
request_cost_usd=0.0,
|
|
|
|
|
|
input_tokens=0,
|
|
|
|
|
|
output_tokens=0,
|
|
|
|
|
|
cache_creation_input_tokens=0,
|
|
|
|
|
|
cache_read_input_tokens=0,
|
|
|
|
|
|
api_format=self.FORMAT_ID,
|
2026-02-21 13:17:11 +08:00
|
|
|
|
api_family=self.API_FAMILY.value if self.API_FAMILY else None,
|
|
|
|
|
|
endpoint_kind=self.ENDPOINT_KIND.value if self.ENDPOINT_KIND else None,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
endpoint_api_format=None,
|
|
|
|
|
|
has_format_conversion=False,
|
|
|
|
|
|
is_stream=False,
|
|
|
|
|
|
response_time_ms=response_time_ms,
|
|
|
|
|
|
first_byte_time_ms=None,
|
|
|
|
|
|
status_code=status_code,
|
|
|
|
|
|
error_message=error_message,
|
|
|
|
|
|
metadata={
|
|
|
|
|
|
"failure_stage": "submit",
|
|
|
|
|
|
"error_code": error_code,
|
|
|
|
|
|
"video_task_id": task.id if task else None,
|
|
|
|
|
|
},
|
|
|
|
|
|
request_headers=safe_headers,
|
|
|
|
|
|
request_body=original_request_body,
|
|
|
|
|
|
provider_request_headers=None,
|
|
|
|
|
|
response_headers=None,
|
|
|
|
|
|
client_response_headers=None,
|
|
|
|
|
|
response_body=None,
|
|
|
|
|
|
request_id=self.request_id,
|
|
|
|
|
|
provider_id=None,
|
|
|
|
|
|
provider_endpoint_id=None,
|
|
|
|
|
|
provider_api_key_id=None,
|
|
|
|
|
|
status="failed",
|
|
|
|
|
|
target_model=None,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
2026-02-02 21:16:28 +08:00
|
|
|
|
logger.warning("Failed to record failed usage: {}", sanitize_error_message(str(exc)))
|
2026-02-01 17:28:27 +08:00
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
|
|
|
|
|
__all__ = ["OpenAIVideoHandler"]
|