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

871 lines
34 KiB
Python
Raw Normal View History

"""
Gemini Video Handler - Veo 视频生成实现
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
from typing import Any, AsyncIterator
from uuid import uuid4
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,
normalize_gemini_operation_id,
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.gemini import GeminiNormalizer
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.provider.provider_context import resolve_provider_proxy
from src.services.scheduling.aware_scheduler import ProviderCandidate
from src.services.usage.service import UsageService
class GeminiVeoHandler(VideoHandlerBase):
FORMAT_ID = "gemini:video"
API_FAMILY = ApiFamily.GEMINI
ENDPOINT_KIND = EndpointKind.VIDEO
DEFAULT_BASE_URL = "https://generativelanguage.googleapis.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 = GeminiNormalizer()
@staticmethod
def _get_request_base_url(http_request: Request) -> str:
"""从 HTTP 请求中获取基础 URL协议 + 主机)"""
# 优先使用 X-Forwarded-Proto 和 X-Forwarded-Host代理场景
proto = http_request.headers.get("x-forwarded-proto") or http_request.url.scheme
host = http_request.headers.get("x-forwarded-host") or http_request.headers.get("host")
if host:
return f"{proto}://{host}"
# 回退到 request.url
return f"{http_request.url.scheme}://{http_request.url.netloc}"
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:
# 将路径中的 model 合并到请求体再解析
model = path_params.get("model") if path_params else None
request_with_model = {**original_request_body}
if model:
request_with_model["model"] = str(model)
try:
internal_request = self._normalizer.video_request_to_internal(request_with_model)
except ValueError as e:
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, _key, auth_info = 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
# 应用端点的请求体规则
endpoint_body_rules = getattr(endpoint, "body_rules", None)
if needs_conversion and provider_format.upper().startswith("OPENAI:"):
# Gemini -> OpenAI 格式转换
converted_body = format_conversion_registry.convert_video_request(
original_request_body,
self.FORMAT_ID,
provider_format,
)
# 确保 seconds 字段为字符串类型(上游 Go 服务要求 string
if "seconds" in converted_body and converted_body["seconds"] is not None:
converted_body["seconds"] = str(converted_body["seconds"])
if endpoint_body_rules:
converted_body = apply_body_rules(
converted_body,
endpoint_body_rules,
original_body=original_request_body,
)
# 构建 OpenAI 风格的 URL
upstream_url = self._build_openai_upstream_url(endpoint.base_url)
# 构建 OpenAI 风格的请求头
headers = self._build_openai_upstream_headers(
original_headers,
upstream_key,
endpoint,
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(_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="GeminiVideoCreate",
)
else:
# 原始 Gemini 格式
request_body = (
original_request_body.copy() if endpoint_body_rules else original_request_body
)
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, internal_request.model)
headers = self._build_upstream_headers(
original_headers,
upstream_key,
endpoint,
auth_info,
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(_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="GeminiVideoCreate",
)
def _extract_task_id(payload: dict[str, Any]) -> str | None:
# 根据响应格式提取 task ID
# Gemini: {"name": "operations/..."}
# OpenAI: {"id": "..."}
if "name" in payload:
value = payload.get("name")
logger.debug(
"[GeminiVeoHandler] Upstream response name={}, keys={}",
value,
list(payload.keys()) if isinstance(payload, dict) else type(payload),
)
if not value:
return None
return normalize_gemini_operation_id(str(value))
if "id" in payload:
# OpenAI 格式
return str(payload["id"])
return None
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,
)
if isinstance(outcome_or_response, JSONResponse):
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(
"[GeminiVeoHandler] 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)
logger.debug(
"[GeminiVeoHandler] Task created: id={}, external_task_id={}",
task.id,
task.external_task_id,
)
except IntegrityError:
self.db.rollback()
raise HTTPException(status_code=409, detail="Task already exists")
# 先构建返回给客户端的响应(使用短 ID 对外暴露)
internal_task = InternalVideoTask(
id=task.short_id,
external_id=external_task_id,
status=VideoStatus.SUBMITTED,
created_at=task.created_at,
original_request=internal_request,
)
base_url = self._get_request_base_url(http_request)
response_body = self._normalizer.video_task_from_internal(internal_task, base_url=base_url)
# 提交成功后补齐 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,
None, # auth_info
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:
# Gemini 使用 operations/{id} 格式,需要按 external_task_id 查找
task = self._get_task_by_external_id(task_id)
# 直接从数据库返回任务状态(后台轮询服务会持续更新状态)
internal_task = self._task_to_internal(task)
base_url = self._get_request_base_url(http_request)
response_body = self._normalizer.video_task_from_internal(internal_task, base_url=base_url)
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:
tasks = (
self.db.query(VideoTask)
.filter(VideoTask.user_id == self.user.id)
.order_by(VideoTask.created_at.desc())
.limit(100)
.all()
)
base_url = self._get_request_base_url(http_request)
items = [
self._normalizer.video_task_from_internal(self._task_to_internal(t), base_url=base_url)
for t in tasks
]
return JSONResponse({"operations": items})
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_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_by_external_id(task_id)
# 根据任务状态返回不同的错误码
if not task.video_url:
if task.status in (
VideoStatus.PENDING.value,
VideoStatus.SUBMITTED.value,
VideoStatus.QUEUED.value,
VideoStatus.PROCESSING.value,
):
# 任务仍在处理中,返回 202 Accepted
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'}",
)
# 其他状态(如 CANCELLED
raise HTTPException(status_code=404, detail="Video not available")
# 检查视频是否已过期
if task.video_expires_at:
now = datetime.now(timezone.utc)
if task.video_expires_at < now:
raise HTTPException(status_code=410, detail="Video URL has expired")
# 获取 provider 的认证信息Gemini 下载视频需要带 API Key
endpoint, key = self._get_endpoint_and_key(task)
download_headers: dict[str, str] = {}
if key.api_key:
try:
upstream_key = crypto_service.decrypt(key.api_key)
# Gemini API 使用 x-goog-api-key 头进行认证
download_headers["x-goog-api-key"] = upstream_key
# 如果是 Vertex AI需要使用 OAuth Bearer token
auth_info = await get_provider_auth(endpoint, key)
if auth_info:
download_headers.pop("x-goog-api-key", None)
download_headers[auth_info.auth_header] = auth_info.auth_value
except Exception as exc:
logger.warning(
"[VideoDownload] Failed to get auth for download task={}: {}",
task.id,
sanitize_error_message(str(exc)),
)
# 继续尝试无认证下载(某些 URL 可能是预签名的)
# 代理下载而非直接重定向,避免暴露上游存储 URL
# 使用 httpx 支持重定向Gemini 视频 URL 会重定向到实际存储位置)
return await self._try_rust_download_stream(
url=task.video_url,
headers=download_headers,
task_id=str(task.id),
endpoint=endpoint,
key=key,
model_name=str(getattr(task, "model", "") or "") or None,
)
async def _try_rust_download_stream(
self,
*,
url: str,
headers: dict[str, str],
task_id: str,
endpoint: ProviderEndpoint,
key: ProviderAPIKey,
model_name: str | None = None,
) -> Response | StreamingResponse:
import httpx
from src.services.proxy_node.resolver import (
build_proxy_url_async,
get_system_proxy_config_async,
resolve_delegate_config_async,
resolve_effective_proxy,
resolve_proxy_info_async,
)
from src.services.request.execution_runtime_plan import (
ExecutionPlan,
ExecutionPlanBody,
ExecutionPlanTimeouts,
ExecutionProxySnapshot,
)
from src.services.request.execution_runtime_client import (
ExecutionRuntimeClient,
ExecutionRuntimeClientError,
)
if config.execution_runtime_backend != "rust":
raise ProviderNotAvailableException(
"Video 下载仅支持 Rust executor",
provider_name="gemini",
upstream_response=f"executor_backend={config.execution_runtime_backend}",
)
try:
effective_proxy = resolve_effective_proxy(
resolve_provider_proxy(endpoint=endpoint, key=key),
getattr(key, "proxy", None),
)
if not effective_proxy or not effective_proxy.get("enabled", True):
effective_proxy = await get_system_proxy_config_async()
delegate_cfg = await resolve_delegate_config_async(effective_proxy)
proxy_url: str | None = None
if effective_proxy and not (delegate_cfg and delegate_cfg.get("tunnel")):
proxy_url = await build_proxy_url_async(effective_proxy)
proxy_info = await resolve_proxy_info_async(effective_proxy)
proxy_snapshot = ExecutionProxySnapshot.from_proxy_info(
proxy_info,
proxy_url=proxy_url,
mode_override="tunnel" if delegate_cfg and delegate_cfg.get("tunnel") else None,
node_id_override=(
str(delegate_cfg.get("node_id") or "").strip() or None
if delegate_cfg and delegate_cfg.get("tunnel")
else None
),
)
plan = ExecutionPlan(
request_id=str(self.request_id or ""),
candidate_id=None,
provider_name="gemini",
provider_id=str(getattr(endpoint, "provider_id", "") or ""),
endpoint_id=str(getattr(endpoint, "id", "") or ""),
key_id=str(getattr(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 ""),
proxy=proxy_snapshot,
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="gemini",
upstream_response=sanitize_error_message(str(exc)),
) from exc
try:
rust_stream = await ExecutionRuntimeClient().execute_stream(plan)
except (ExecutionRuntimeClientError, httpx.HTTPError, ValueError) as exc:
logger.warning(
"[VideoDownload] Rust executor unavailable task={} url={}: {}",
task_id,
url,
sanitize_error_message(str(exc)),
)
raise ProviderNotAvailableException(
"执行器暂时不可用,请稍后重试",
provider_name="gemini",
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:
try:
async for _ in rust_stream.byte_iterator:
pass
finally:
await rust_stream.response_ctx.__aexit__(None, None, None)
raise HTTPException(status_code=rust_stream.status_code, detail="Upstream error")
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", "video/mp4"),
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
async def _resolve_upstream_key(
self, candidate: ProviderCandidate
) -> tuple[str, ProviderEndpoint, ProviderAPIKey, Any | None]:
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")
auth_info = await get_provider_auth(candidate.endpoint, candidate.key)
return upstream_key, candidate.endpoint, candidate.key, auth_info
def _build_upstream_url(self, base_url: str | None, model: str) -> str:
base = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
if base.endswith("/v1beta"):
base = base[: -len("/v1beta")]
return f"{base}/v1beta/models/{model}:predictLongRunning"
def _build_cancel_url(self, base_url: str | None, operation_name: str) -> str:
base = (base_url or self.DEFAULT_BASE_URL).rstrip("/")
if base.endswith("/v1beta"):
base = base[: -len("/v1beta")]
return f"{base}/v1beta/{operation_name}:cancel"
def _build_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]:
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
def _format_error_payload(self, error: dict[str, Any], status_code: int) -> dict[str, Any]:
"""Gemini 风格错误格式"""
return {
"code": error.get("code", status_code),
"message": sanitize_error_message(error.get("message", "Request failed")),
"status": error.get("status", "BAD_GATEWAY"),
}
# ------------------------------------------------------------------
# OpenAI format conversion helpers
# ------------------------------------------------------------------
def _build_openai_upstream_url(self, base_url: str | None) -> str:
"""构建 OpenAI Sora API 的上游 URL"""
base = (base_url or "https://api.openai.com").rstrip("/")
if base.endswith("/v1"):
return f"{base}/videos"
return f"{base}/v1/videos"
def _build_openai_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]:
"""构建 OpenAI 格式的请求头"""
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,
)
def _create_task_record(
self,
*,
external_task_id: str,
candidate: ProviderCandidate,
original_request_body: dict[str, Any],
internal_request: Any,
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)
# 构建请求元数据(使用追踪信息)
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", "x-goog-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,
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:
"""覆盖父类方法Gemini 使用 short_id 作为对外暴露的 ID"""
try:
status = VideoStatus(task.status)
except ValueError:
status = VideoStatus.PENDING
return InternalVideoTask(
id=task.short_id, # Gemini 使用短 ID
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 [],
created_at=task.created_at,
completed_at=task.completed_at,
error_code=task.error_code,
error_message=task.error_message,
extra={"model": task.model},
)
def _get_task_by_external_id(self, external_id: str) -> VideoTask:
"""按 short_id 查找任务(我们对外暴露的 operation 格式是 models/{model}/operations/{short_id}"""
from src.api.handlers.base.video_handler_base import extract_short_id_from_operation
short_id = extract_short_id_from_operation(external_id)
# 通过 short_id 查找任务
task = (
self.db.query(VideoTask)
.filter(
VideoTask.short_id == short_id,
VideoTask.user_id == self.user.id,
)
.first()
)
if not task:
logger.debug("[GeminiVeoHandler] Task not found: short_id={}", short_id)
raise HTTPException(status_code=404, detail="Video task not found")
return task
__all__ = ["GeminiVeoHandler"]