Files
Aether/_deprecated_py_src/api/handlers/openai/video_handler.py

1300 lines
51 KiB
Python
Raw Normal View History

"""
OpenAI Video Handler - Sora 视频生成实现
"""
from __future__ import annotations
import json
import time
from datetime import datetime, timedelta, timezone
from typing import Any, AsyncIterator
from uuid import uuid4
import httpx
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.api.handlers.base.request_builder import (
apply_body_rules,
evaluate_condition,
get_provider_auth,
)
from src.api.handlers.base.video_handler_base import VideoHandlerBase, sanitize_error_message
from src.config.settings import config
from src.core.api_format import (
ApiFamily,
EndpointKind,
build_upstream_headers_for_endpoint,
get_extra_headers_from_endpoint,
make_signature_key,
)
from src.core.api_format.conversion.internal_video import (
InternalVideoRequest,
InternalVideoTask,
VideoStatus,
)
from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer
from src.core.api_format.conversion.registry import format_conversion_registry
from src.core.api_format.headers import HOP_BY_HOP_HEADERS
from src.core.crypto import crypto_service
from src.core.exceptions import ProviderNotAvailableException
from src.core.logger import logger
from src.models.database import ApiKey, ProviderAPIKey, ProviderEndpoint, User, VideoTask
from src.services.billing.rule_service import BillingRuleLookupResult, BillingRuleService
from src.services.request.execution_runtime_plan import (
ExecutionPlan,
ExecutionPlanBody,
ExecutionPlanTimeouts,
)
from src.services.request.execution_runtime_client import (
ExecutionRuntimeClient,
ExecutionRuntimeClientError,
)
from src.services.scheduling.aware_scheduler import ProviderCandidate
from src.services.usage.service import UsageService
class OpenAIVideoHandler(VideoHandlerBase):
FORMAT_ID = "openai:video"
API_FAMILY = ApiFamily.OPENAI
ENDPOINT_KIND = EndpointKind.VIDEO
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:
# 请求解析失败,记录失败的使用记录
await self._record_failed_usage(
model="unknown",
error_message=str(e),
status_code=400,
original_request_body=original_request_body,
original_headers=original_headers,
)
raise HTTPException(status_code=400, detail=str(e))
# 异步任务:提前创建 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(
"Failed to create pending usage for video request_id={}: {}",
self.request_id,
sanitize_error_message(str(exc)),
)
# 用于跟踪是否发生了格式转换
format_conversion_info: dict[str, Any] = {
"converted": False,
"provider_format": None,
}
async def _submit(candidate: ProviderCandidate) -> Any:
upstream_key, endpoint, _provider_key = await self._resolve_upstream_key(candidate)
# 检测目标格式
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"])
# 应用端点的请求体规则
endpoint_body_rules = getattr(endpoint, "body_rules", None)
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
if endpoint_body_rules:
converted_body = apply_body_rules(
converted_body,
endpoint_body_rules,
original_body=original_request_body,
)
# 构建 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(
original_headers,
upstream_key,
endpoint,
auth_info,
body=converted_body,
original_body=original_request_body,
)
return await self._try_rust_sync_http_response(
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",
)
else:
# 原始 OpenAI 格式
if endpoint_body_rules:
request_body = apply_body_rules(
request_body,
endpoint_body_rules,
original_body=original_request_body,
)
upstream_url = self._build_upstream_url(endpoint.base_url)
headers = self._build_upstream_headers(
original_headers,
upstream_key,
endpoint,
body=request_body,
original_body=original_request_body,
)
return await self._try_rust_sync_http_response(
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",
)
def _extract_task_id(payload: dict[str, Any]) -> str | None:
# 根据响应格式提取 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
# 捕获提交阶段的所有错误,记录失败任务
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,
supported_auth_types={"api_key", "service_account", "vertex_ai"},
allow_format_conversion=True,
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
# 冻结 billing_rule 配置(用于异步任务的成本一致性)
# 复用 _select_candidate 中已查询的结果billing_require_rule=false 时需补查
rule_lookup = outcome.rule_lookup
if rule_lookup is None:
rule_lookup = BillingRuleService.find_rule(
self.db,
provider_id=outcome.candidate.provider.id,
model_name=internal_request.model,
task_type="video",
)
billing_rule_snapshot = self._build_billing_rule_snapshot(rule_lookup)
external_task_id = outcome.external_task_id
# 如果发生了格式转换,记录转换后的请求体
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(
"[OpenAIVideoHandler] Failed to record converted request: {}",
sanitize_error_message(str(e)),
)
task = self._create_task_record(
external_task_id=external_task_id,
candidate=outcome.candidate,
original_request_body=original_request_body,
converted_request_body=converted_request_body,
internal_request=internal_request,
candidate_keys=outcome.candidate_keys,
original_headers=original_headers,
billing_rule_snapshot=billing_rule_snapshot,
format_converted=format_conversion_info["converted"],
)
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")
# 先构建返回给客户端的响应OpenAI Sora 使用 UUID
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)
# 提交成功后补齐 Usage 的 provider 上下文,真正结算留到轮询完成时
response_time_ms = int((time.time() - self.start_time) * 1000)
try:
# 构建发送给上游的请求头(脱敏)
upstream_request_headers = self._build_upstream_headers(
original_headers,
"", # key 不重要,只是用于记录
outcome.candidate.endpoint,
body=converted_request_body,
original_body=original_request_body,
)
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(
"Failed to finalize submitted usage for video request_id={}: {}",
self.request_id,
sanitize_error_message(str(exc)),
)
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:
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]
items = [
self._normalizer.video_task_from_internal(self._task_to_internal(t)) for t in tasks
]
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)
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:
from src.services.task.service import TaskService
_ = (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)
return JSONResponse({})
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)
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",
)
if response.status_code >= 400 and response.status_code != 404:
# 404 表示上游已删除,不算错误
return self._build_error_response(response)
except ProviderNotAvailableException:
raise
except Exception as exc:
logger.warning(
"Failed to delete video from upstream task={}: {}",
task.id,
sanitize_error_message(str(exc)),
)
# 继续删除本地记录
# 删除本地任务记录
self.db.delete(task)
self.db.commit()
return JSONResponse({"id": task_id, "object": "video", "deleted": True})
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"
)
# 确保 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"])
# 应用端点的请求体规则
endpoint_body_rules = getattr(endpoint, "body_rules", None)
if endpoint_body_rules:
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,
)
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",
)
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(
id=task.id, # OpenAI Sora 使用 UUID
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)
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")
# 支持 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(
"[VideoDownload] Proxying direct URL task={} url={}",
task_id,
task.video_url,
)
return await self._try_rust_download_stream(
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",
)
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)
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)
headers = self._build_upstream_headers(original_headers, upstream_key, endpoint)
logger.debug(
"[VideoDownload] Requesting upstream url={} task={} external_task_id={}",
upstream_url,
task_id,
task.external_task_id,
)
return await self._try_rust_download_stream(
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",
)
# ------------------------------------------------------------------
# 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(
"Failed to decrypt provider key id={}: {}",
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
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,
) -> Response | StreamingResponse:
if config.execution_runtime_backend != "rust":
raise ProviderNotAvailableException(
"Video 下载仅支持 Rust executor",
provider_name="openai",
upstream_response=f"executor_backend={config.execution_runtime_backend}",
)
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
try:
rust_stream = await ExecutionRuntimeClient().execute_stream(plan)
except (ExecutionRuntimeClientError, httpx.HTTPError, json.JSONDecodeError) as exc:
logger.warning(
"[VideoDownload] Rust executor unavailable task={} url={}: {}",
task_id,
url,
sanitize_error_message(str(exc)),
)
raise ProviderNotAvailableException(
"执行器暂时不可用,请稍后重试",
provider_name="openai",
upstream_response=sanitize_error_message(str(exc)),
) from exc
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),
)
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(
self,
original_headers: dict[str, str],
upstream_key: str,
endpoint: ProviderEndpoint,
*,
body: dict[str, Any] | None = None,
original_body: dict[str, Any] | None = None,
) -> dict[str, str]:
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(),
)
return build_upstream_headers_for_endpoint(
original_headers,
endpoint_sig,
upstream_key,
endpoint_headers=extra_headers,
header_rules=getattr(endpoint, "header_rules", None),
body=body,
original_body=original_body,
condition_evaluator=evaluate_condition,
)
# ------------------------------------------------------------------
# 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,
*,
body: dict[str, Any] | None = None,
original_body: dict[str, Any] | None = None,
) -> 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,
header_rules=getattr(endpoint, "header_rules", None),
body=body,
original_body=original_body,
condition_evaluator=evaluate_condition,
)
if auth_info:
# 覆盖为 OAuth2 BearerVertex AI
headers.pop("x-goog-api-key", None)
headers[auth_info.auth_header] = auth_info.auth_value
return headers
# _build_error_response 继承自基类 VideoHandlerBase
def _create_task_record(
self,
*,
external_task_id: str,
candidate: ProviderCandidate,
original_request_body: dict[str, Any],
internal_request: InternalVideoRequest,
candidate_keys: list[dict[str, Any]] | None = None,
original_headers: dict[str, str] | None = None,
billing_rule_snapshot: dict[str, Any] | None = None,
converted_request_body: dict[str, Any] | None = None,
format_converted: bool = False,
) -> VideoTask:
now = datetime.now(timezone.utc)
size = internal_request.extra.get("original_size")
# 构建请求元数据(使用追踪信息)
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
provider_api_format = make_signature_key(
str(getattr(candidate.endpoint, "api_family", "")).strip().lower(),
str(getattr(candidate.endpoint, "endpoint_kind", "")).strip().lower(),
)
return VideoTask(
id=str(uuid4()),
request_id=self.request_id,
external_task_id=external_task_id,
user_id=self.user.id,
api_key_id=self.api_key.id,
username=self.user.username,
api_key_name=self.api_key.name,
provider_id=candidate.provider.id,
endpoint_id=candidate.endpoint.id,
key_id=candidate.key.id,
client_api_format=self.FORMAT_ID,
provider_api_format=provider_api_format,
format_converted=format_converted,
model=internal_request.model,
prompt=internal_request.prompt,
original_request_body=original_request_body,
converted_request_body=converted_request_body or original_request_body,
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,
poll_interval_seconds=config.video_poll_interval_seconds,
next_poll_at=now + timedelta(seconds=config.video_poll_interval_seconds),
poll_count=0,
max_poll_count=config.video_max_poll_count,
submitted_at=now,
request_metadata=request_metadata,
)
def _task_to_internal(self, task: VideoTask) -> InternalVideoTask:
try:
status = VideoStatus(task.status)
except ValueError:
status = VideoStatus.PENDING
# 构建 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
return InternalVideoTask(
id=task.id, # OpenAI Sora 使用 UUID
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,
extra=extra,
)
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,
api_family=self.API_FAMILY.value if self.API_FAMILY else None,
endpoint_kind=self.ENDPOINT_KIND.value if self.ENDPOINT_KIND else None,
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:
logger.warning("Failed to record failed usage: {}", sanitize_error_message(str(exc)))
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()),
request_id=self.request_id,
external_task_id=None,
user_id=self.user.id,
api_key_id=self.api_key.id,
username=self.user.username,
api_key_name=self.api_key.name,
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(
"Failed to create failed task record: {}", sanitize_error_message(str(exc))
)
# 即使任务记录失败,仍然尝试记录使用记录
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,
api_family=self.API_FAMILY.value if self.API_FAMILY else None,
endpoint_kind=self.ENDPOINT_KIND.value if self.ENDPOINT_KIND else None,
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:
logger.warning("Failed to record failed usage: {}", sanitize_error_message(str(exc)))
__all__ = ["OpenAIVideoHandler"]