""" 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 Bearer(Vertex 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"]