2026-01-30 22:41:42 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Gemini Video Handler - Veo 视频生成实现
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
import time
|
2026-01-30 22:41:42 +08:00
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
|
|
from typing import Any, AsyncIterator
|
|
|
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import HTTPException, Request
|
|
|
|
|
|
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
|
|
|
|
from sqlalchemy.exc import IntegrityError
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
|
|
|
2026-03-15 20:27:53 +08:00
|
|
|
|
from src.api.handlers.base.request_builder import (
|
|
|
|
|
|
apply_body_rules,
|
|
|
|
|
|
evaluate_condition,
|
|
|
|
|
|
get_provider_auth,
|
|
|
|
|
|
)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
from src.api.handlers.base.video_handler_base import (
|
|
|
|
|
|
VideoHandlerBase,
|
|
|
|
|
|
normalize_gemini_operation_id,
|
|
|
|
|
|
sanitize_error_message,
|
|
|
|
|
|
)
|
|
|
|
|
|
from src.config.settings import config
|
2026-02-01 17:28:27 +08:00
|
|
|
|
from src.core.api_format import (
|
|
|
|
|
|
ApiFamily,
|
|
|
|
|
|
EndpointKind,
|
|
|
|
|
|
build_upstream_headers_for_endpoint,
|
|
|
|
|
|
get_extra_headers_from_endpoint,
|
|
|
|
|
|
make_signature_key,
|
|
|
|
|
|
)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
from src.core.api_format.conversion.internal_video import (
|
|
|
|
|
|
InternalVideoRequest,
|
|
|
|
|
|
InternalVideoTask,
|
|
|
|
|
|
VideoStatus,
|
|
|
|
|
|
)
|
|
|
|
|
|
from src.core.api_format.conversion.normalizers.gemini import GeminiNormalizer
|
2026-02-02 03:16:52 +08:00
|
|
|
|
from src.core.api_format.conversion.registry import format_conversion_registry
|
2026-01-30 22:41:42 +08:00
|
|
|
|
from src.core.api_format.headers import HOP_BY_HOP_HEADERS
|
|
|
|
|
|
from src.core.crypto import crypto_service
|
2026-03-31 19:19:04 +08:00
|
|
|
|
from src.core.exceptions import ProviderNotAvailableException
|
2026-01-30 22:41:42 +08:00
|
|
|
|
from src.core.logger import logger
|
|
|
|
|
|
from src.models.database import ApiKey, ProviderAPIKey, ProviderEndpoint, User, VideoTask
|
2026-01-31 19:11:25 +08:00
|
|
|
|
from src.services.billing.rule_service import BillingRuleLookupResult, BillingRuleService
|
2026-03-20 16:50:59 +08:00
|
|
|
|
from src.services.provider.provider_context import resolve_provider_proxy
|
2026-02-16 11:00:48 +08:00
|
|
|
|
from src.services.scheduling.aware_scheduler import ProviderCandidate
|
2026-02-02 03:16:52 +08:00
|
|
|
|
from src.services.usage.service import UsageService
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class GeminiVeoHandler(VideoHandlerBase):
|
2026-02-01 17:28:27 +08:00
|
|
|
|
FORMAT_ID = "gemini:video"
|
|
|
|
|
|
API_FAMILY = ApiFamily.GEMINI
|
|
|
|
|
|
ENDPOINT_KIND = EndpointKind.VIDEO
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
2026-02-03 18:48:39 +08:00
|
|
|
|
@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}"
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
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))
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 异步任务:提前创建 pending usage,便于前端看到“处理中”
|
|
|
|
|
|
try:
|
|
|
|
|
|
UsageService.create_pending_usage(
|
|
|
|
|
|
db=self.db,
|
|
|
|
|
|
request_id=self.request_id,
|
|
|
|
|
|
user=self.user,
|
|
|
|
|
|
api_key=self.api_key,
|
|
|
|
|
|
model=internal_request.model,
|
|
|
|
|
|
is_stream=False,
|
|
|
|
|
|
request_type="video",
|
|
|
|
|
|
api_format=self.FORMAT_ID,
|
|
|
|
|
|
request_headers=original_headers,
|
|
|
|
|
|
request_body=original_request_body,
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"Failed to create pending usage for video request_id={}: {}",
|
2026-02-02 03:16:52 +08:00
|
|
|
|
self.request_id,
|
|
|
|
|
|
sanitize_error_message(str(exc)),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 用于跟踪是否发生了格式转换
|
|
|
|
|
|
format_conversion_info: dict[str, Any] = {
|
|
|
|
|
|
"converted": False,
|
|
|
|
|
|
"provider_format": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 17:28:27 +08:00
|
|
|
|
async def _submit(candidate: ProviderCandidate) -> Any:
|
|
|
|
|
|
upstream_key, endpoint, _key, auth_info = await self._resolve_upstream_key(candidate)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
|
|
|
|
|
|
# 检测目标格式
|
|
|
|
|
|
provider_format = make_signature_key(
|
|
|
|
|
|
str(getattr(endpoint, "api_family", "")).strip().lower(),
|
|
|
|
|
|
str(getattr(endpoint, "endpoint_kind", "")).strip().lower(),
|
2026-02-01 17:28:27 +08:00
|
|
|
|
)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
needs_conversion = provider_format.upper() != self.FORMAT_ID.upper()
|
|
|
|
|
|
format_conversion_info["provider_format"] = provider_format
|
|
|
|
|
|
format_conversion_info["converted"] = needs_conversion
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
# 应用端点的请求体规则
|
|
|
|
|
|
endpoint_body_rules = getattr(endpoint, "body_rules", None)
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
if needs_conversion and provider_format.upper().startswith("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"])
|
|
|
|
|
|
|
2026-02-06 16:37:06 +08:00
|
|
|
|
if endpoint_body_rules:
|
2026-03-15 20:27:53 +08:00
|
|
|
|
converted_body = apply_body_rules(
|
|
|
|
|
|
converted_body,
|
|
|
|
|
|
endpoint_body_rules,
|
|
|
|
|
|
original_body=original_request_body,
|
|
|
|
|
|
)
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 构建 OpenAI 风格的 URL
|
|
|
|
|
|
upstream_url = self._build_openai_upstream_url(endpoint.base_url)
|
|
|
|
|
|
|
|
|
|
|
|
# 构建 OpenAI 风格的请求头
|
|
|
|
|
|
headers = self._build_openai_upstream_headers(
|
2026-03-15 20:27:53 +08:00
|
|
|
|
original_headers,
|
|
|
|
|
|
upstream_key,
|
|
|
|
|
|
endpoint,
|
|
|
|
|
|
body=converted_body,
|
|
|
|
|
|
original_body=original_request_body,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
return await self._try_rust_sync_http_response(
|
2026-03-21 12:57:09 +08:00
|
|
|
|
method="POST",
|
|
|
|
|
|
url=upstream_url,
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
body=converted_body,
|
|
|
|
|
|
provider_name=str(candidate.provider.name),
|
|
|
|
|
|
provider_id=str(candidate.provider.id),
|
|
|
|
|
|
endpoint_id=str(endpoint.id),
|
|
|
|
|
|
key_id=str(_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",
|
|
|
|
|
|
)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
else:
|
|
|
|
|
|
# 原始 Gemini 格式
|
2026-02-06 16:37:06 +08:00
|
|
|
|
request_body = (
|
|
|
|
|
|
original_request_body.copy() if endpoint_body_rules else original_request_body
|
|
|
|
|
|
)
|
|
|
|
|
|
if endpoint_body_rules:
|
2026-03-15 20:27:53 +08:00
|
|
|
|
request_body = apply_body_rules(
|
|
|
|
|
|
request_body,
|
|
|
|
|
|
endpoint_body_rules,
|
|
|
|
|
|
original_body=original_request_body,
|
|
|
|
|
|
)
|
2026-02-06 16:37:06 +08:00
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
upstream_url = self._build_upstream_url(endpoint.base_url, internal_request.model)
|
|
|
|
|
|
headers = self._build_upstream_headers(
|
2026-03-15 20:27:53 +08:00
|
|
|
|
original_headers,
|
|
|
|
|
|
upstream_key,
|
|
|
|
|
|
endpoint,
|
|
|
|
|
|
auth_info,
|
|
|
|
|
|
body=request_body,
|
|
|
|
|
|
original_body=original_request_body,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
)
|
2026-03-31 19:19:04 +08:00
|
|
|
|
return await self._try_rust_sync_http_response(
|
2026-03-21 12:57:09 +08:00
|
|
|
|
method="POST",
|
|
|
|
|
|
url=upstream_url,
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
body=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",
|
|
|
|
|
|
)
|
2026-02-01 17:28:27 +08:00
|
|
|
|
|
|
|
|
|
|
def _extract_task_id(payload: dict[str, Any]) -> str | None:
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 根据响应格式提取 task ID
|
|
|
|
|
|
# Gemini: {"name": "operations/..."}
|
|
|
|
|
|
# OpenAI: {"id": "..."}
|
|
|
|
|
|
if "name" in payload:
|
|
|
|
|
|
value = payload.get("name")
|
|
|
|
|
|
logger.debug(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"[GeminiVeoHandler] Upstream response name={}, keys={}",
|
2026-02-02 03:16:52 +08:00
|
|
|
|
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
|
2026-02-01 17:28:27 +08:00
|
|
|
|
|
|
|
|
|
|
outcome_or_response = await self._submit_with_failover(
|
|
|
|
|
|
api_format=self.FORMAT_ID,
|
|
|
|
|
|
model_name=internal_request.model,
|
|
|
|
|
|
task_type="video",
|
|
|
|
|
|
submit_func=_submit,
|
|
|
|
|
|
extract_external_task_id=_extract_task_id,
|
2026-03-01 23:32:48 +08:00
|
|
|
|
supported_auth_types={"api_key", "service_account", "vertex_ai"},
|
2026-02-02 03:16:52 +08:00
|
|
|
|
allow_format_conversion=True,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
max_candidates=10,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
)
|
2026-02-01 17:28:27 +08:00
|
|
|
|
if isinstance(outcome_or_response, JSONResponse):
|
|
|
|
|
|
return outcome_or_response
|
|
|
|
|
|
outcome = outcome_or_response
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
# 冻结 billing_rule 配置(用于异步任务的成本一致性)
|
|
|
|
|
|
# 复用 _select_candidate 中已查询的结果;billing_require_rule=false 时需补查
|
2026-02-01 17:28:27 +08:00
|
|
|
|
rule_lookup = outcome.rule_lookup
|
2026-01-31 19:11:25 +08:00
|
|
|
|
if rule_lookup is None:
|
|
|
|
|
|
rule_lookup = BillingRuleService.find_rule(
|
|
|
|
|
|
self.db,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
provider_id=outcome.candidate.provider.id,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
model_name=internal_request.model,
|
|
|
|
|
|
task_type="video",
|
2026-01-30 22:41:42 +08:00
|
|
|
|
)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
billing_rule_snapshot = self._build_billing_rule_snapshot(rule_lookup)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
2026-02-01 17:28:27 +08:00
|
|
|
|
external_task_id = outcome.external_task_id
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 如果发生了格式转换,记录转换后的请求体
|
|
|
|
|
|
converted_request_body = original_request_body
|
|
|
|
|
|
if format_conversion_info["converted"]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
converted_request_body = format_conversion_registry.convert_video_request(
|
|
|
|
|
|
original_request_body,
|
|
|
|
|
|
self.FORMAT_ID,
|
|
|
|
|
|
format_conversion_info["provider_format"],
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"[GeminiVeoHandler] Failed to record converted request: {}",
|
2026-02-02 03:16:52 +08:00
|
|
|
|
sanitize_error_message(str(e)),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
task = self._create_task_record(
|
|
|
|
|
|
external_task_id=external_task_id,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
candidate=outcome.candidate,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
original_request_body=original_request_body,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
converted_request_body=converted_request_body,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
internal_request=internal_request,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
candidate_keys=outcome.candidate_keys,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
original_headers=original_headers,
|
|
|
|
|
|
billing_rule_snapshot=billing_rule_snapshot,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
format_converted=format_conversion_info["converted"],
|
2026-01-30 22:41:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.db.add(task)
|
|
|
|
|
|
self.db.flush() # 先 flush 检测冲突
|
|
|
|
|
|
self.db.commit()
|
|
|
|
|
|
self.db.refresh(task)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
logger.debug(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"[GeminiVeoHandler] Task created: id={}, external_task_id={}",
|
2026-02-02 03:16:52 +08:00
|
|
|
|
task.id,
|
|
|
|
|
|
task.external_task_id,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
except IntegrityError:
|
|
|
|
|
|
self.db.rollback()
|
|
|
|
|
|
raise HTTPException(status_code=409, detail="Task already exists")
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 先构建返回给客户端的响应(使用短 ID 对外暴露)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
internal_task = InternalVideoTask(
|
2026-02-02 03:16:52 +08:00
|
|
|
|
id=task.short_id,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
external_id=external_task_id,
|
|
|
|
|
|
status=VideoStatus.SUBMITTED,
|
|
|
|
|
|
created_at=task.created_at,
|
|
|
|
|
|
original_request=internal_request,
|
|
|
|
|
|
)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
base_url = self._get_request_base_url(http_request)
|
|
|
|
|
|
response_body = self._normalizer.video_task_from_internal(internal_task, base_url=base_url)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
|
2026-03-08 00:05:48 +08:00
|
|
|
|
# 提交成功后补齐 Usage 的 provider 上下文,真正结算留到轮询完成时
|
2026-02-02 03:16:52 +08:00
|
|
|
|
response_time_ms = int((time.time() - self.start_time) * 1000)
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 构建发送给上游的请求头(脱敏)
|
|
|
|
|
|
upstream_request_headers = self._build_upstream_headers(
|
|
|
|
|
|
original_headers,
|
|
|
|
|
|
"", # key 不重要,只是用于记录
|
|
|
|
|
|
outcome.candidate.endpoint,
|
|
|
|
|
|
None, # auth_info
|
2026-03-15 20:27:53 +08:00
|
|
|
|
body=converted_request_body,
|
|
|
|
|
|
original_body=original_request_body,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
UsageService.finalize_submitted(
|
|
|
|
|
|
self.db,
|
|
|
|
|
|
request_id=self.request_id,
|
|
|
|
|
|
provider_name=outcome.candidate.provider.name,
|
|
|
|
|
|
provider_id=outcome.candidate.provider.id,
|
|
|
|
|
|
provider_endpoint_id=outcome.candidate.endpoint.id,
|
|
|
|
|
|
provider_api_key_id=outcome.candidate.key.id,
|
|
|
|
|
|
response_time_ms=response_time_ms,
|
|
|
|
|
|
status_code=outcome.upstream_status_code or 200,
|
|
|
|
|
|
endpoint_api_format=make_signature_key(
|
|
|
|
|
|
str(getattr(outcome.candidate.endpoint, "api_family", "")).strip().lower(),
|
|
|
|
|
|
str(getattr(outcome.candidate.endpoint, "endpoint_kind", "")).strip().lower(),
|
|
|
|
|
|
),
|
|
|
|
|
|
provider_request_headers=upstream_request_headers,
|
|
|
|
|
|
response_headers=outcome.upstream_headers,
|
|
|
|
|
|
response_body=response_body, # 使用我们转换后的响应(包含我们的 ID)
|
|
|
|
|
|
)
|
|
|
|
|
|
self.db.commit()
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"Failed to finalize submitted usage for video request_id={}: {}",
|
2026-02-02 03:16:52 +08:00
|
|
|
|
self.request_id,
|
|
|
|
|
|
sanitize_error_message(str(exc)),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
return JSONResponse(response_body)
|
|
|
|
|
|
|
|
|
|
|
|
async def handle_get_task(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
task_id: str,
|
|
|
|
|
|
http_request: Request,
|
|
|
|
|
|
original_headers: dict[str, str],
|
|
|
|
|
|
query_params: dict[str, str] | None = None,
|
|
|
|
|
|
path_params: dict[str, Any] | None = None,
|
|
|
|
|
|
) -> JSONResponse:
|
|
|
|
|
|
# Gemini 使用 operations/{id} 格式,需要按 external_task_id 查找
|
|
|
|
|
|
task = self._get_task_by_external_id(task_id)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
# 直接从数据库返回任务状态(后台轮询服务会持续更新状态)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
internal_task = self._task_to_internal(task)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
base_url = self._get_request_base_url(http_request)
|
|
|
|
|
|
response_body = self._normalizer.video_task_from_internal(internal_task, base_url=base_url)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
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()
|
|
|
|
|
|
)
|
2026-02-03 18:48:39 +08:00
|
|
|
|
base_url = self._get_request_base_url(http_request)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
items = [
|
2026-02-03 18:48:39 +08:00
|
|
|
|
self._normalizer.video_task_from_internal(self._task_to_internal(t), base_url=base_url)
|
|
|
|
|
|
for t in tasks
|
2026-01-30 22:41:42 +08:00
|
|
|
|
]
|
|
|
|
|
|
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:
|
2026-02-02 21:16:28 +08:00
|
|
|
|
from src.services.task.service import TaskService
|
2026-02-02 03:16:52 +08:00
|
|
|
|
|
2026-02-02 21:16:28 +08:00
|
|
|
|
_ = (http_request, query_params, path_params) # reserved for future extensions
|
|
|
|
|
|
err_resp = await TaskService(self.db).cancel(
|
|
|
|
|
|
task_id,
|
|
|
|
|
|
user_id=str(self.user.id),
|
|
|
|
|
|
original_headers=original_headers,
|
|
|
|
|
|
)
|
|
|
|
|
|
if err_resp is not None:
|
|
|
|
|
|
return self._build_error_response(err_resp)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
return JSONResponse({})
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 获取 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(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"[VideoDownload] Failed to get auth for download task={}: {}",
|
2026-02-02 03:16:52 +08:00
|
|
|
|
task.id,
|
|
|
|
|
|
sanitize_error_message(str(exc)),
|
|
|
|
|
|
)
|
|
|
|
|
|
# 继续尝试无认证下载(某些 URL 可能是预签名的)
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
# 代理下载而非直接重定向,避免暴露上游存储 URL
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 使用 httpx 支持重定向(Gemini 视频 URL 会重定向到实际存储位置)
|
2026-03-31 19:19:04 +08:00
|
|
|
|
return await self._try_rust_download_stream(
|
2026-03-21 12:57:09 +08:00
|
|
|
|
url=task.video_url,
|
|
|
|
|
|
headers=download_headers,
|
|
|
|
|
|
task_id=str(task.id),
|
|
|
|
|
|
endpoint=endpoint,
|
|
|
|
|
|
key=key,
|
|
|
|
|
|
model_name=str(getattr(task, "model", "") or "") or None,
|
|
|
|
|
|
)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
2026-03-21 12:57:09 +08:00
|
|
|
|
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,
|
2026-03-31 19:19:04 +08:00
|
|
|
|
) -> Response | StreamingResponse:
|
2026-03-21 12:57:09 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-04-03 14:59:58 +08:00
|
|
|
|
from src.services.request.execution_runtime_plan import (
|
2026-03-21 12:57:09 +08:00
|
|
|
|
ExecutionPlan,
|
|
|
|
|
|
ExecutionPlanBody,
|
|
|
|
|
|
ExecutionPlanTimeouts,
|
|
|
|
|
|
ExecutionProxySnapshot,
|
|
|
|
|
|
)
|
2026-04-03 14:59:58 +08:00
|
|
|
|
from src.services.request.execution_runtime_client import (
|
|
|
|
|
|
ExecutionRuntimeClient,
|
|
|
|
|
|
ExecutionRuntimeClientError,
|
2026-03-21 12:57:09 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-03 14:59:58 +08:00
|
|
|
|
if config.execution_runtime_backend != "rust":
|
2026-03-31 19:19:04 +08:00
|
|
|
|
raise ProviderNotAvailableException(
|
|
|
|
|
|
"Video 下载仅支持 Rust executor",
|
|
|
|
|
|
provider_name="gemini",
|
2026-04-03 14:59:58 +08:00
|
|
|
|
upstream_response=f"executor_backend={config.execution_runtime_backend}",
|
2026-03-31 19:19:04 +08:00
|
|
|
|
)
|
2026-03-21 12:57:09 +08:00
|
|
|
|
|
|
|
|
|
|
try:
|
2026-03-31 19:19:04 +08:00
|
|
|
|
effective_proxy = resolve_effective_proxy(
|
|
|
|
|
|
resolve_provider_proxy(endpoint=endpoint, key=key),
|
|
|
|
|
|
getattr(key, "proxy", None),
|
2026-03-21 12:57:09 +08:00
|
|
|
|
)
|
2026-03-31 19:19:04 +08:00
|
|
|
|
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:
|
2026-04-03 14:59:58 +08:00
|
|
|
|
rust_stream = await ExecutionRuntimeClient().execute_stream(plan)
|
|
|
|
|
|
except (ExecutionRuntimeClientError, httpx.HTTPError, ValueError) as exc:
|
2026-03-21 12:57:09 +08:00
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[VideoDownload] Rust executor unavailable task={} url={}: {}",
|
|
|
|
|
|
task_id,
|
|
|
|
|
|
url,
|
|
|
|
|
|
sanitize_error_message(str(exc)),
|
|
|
|
|
|
)
|
2026-03-31 19:19:04 +08:00
|
|
|
|
raise ProviderNotAvailableException(
|
|
|
|
|
|
"执行器暂时不可用,请稍后重试",
|
|
|
|
|
|
provider_name="gemini",
|
|
|
|
|
|
upstream_response=sanitize_error_message(str(exc)),
|
|
|
|
|
|
) from exc
|
2026-03-21 12:57:09 +08:00
|
|
|
|
|
|
|
|
|
|
safe_headers = {
|
|
|
|
|
|
k: v for k, v in rust_stream.headers.items() if k.lower() not in HOP_BY_HOP_HEADERS
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if rust_stream.status_code >= 400:
|
|
|
|
|
|
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"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 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(
|
2026-02-02 21:16:28 +08:00
|
|
|
|
"Failed to decrypt provider key id={}: {}",
|
2026-01-30 22:41:42 +08:00
|
|
|
|
candidate.key.id,
|
|
|
|
|
|
sanitize_error_message(str(exc)),
|
|
|
|
|
|
)
|
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to decrypt provider key")
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-03-15 20:27:53 +08:00
|
|
|
|
*,
|
|
|
|
|
|
body: dict[str, Any] | None = None,
|
|
|
|
|
|
original_body: dict[str, Any] | None = None,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
) -> dict[str, str]:
|
|
|
|
|
|
extra_headers = get_extra_headers_from_endpoint(endpoint)
|
2026-02-01 17:28:27 +08:00
|
|
|
|
endpoint_sig = make_signature_key(
|
|
|
|
|
|
str(getattr(endpoint, "api_family", "")).strip().lower(),
|
|
|
|
|
|
str(getattr(endpoint, "endpoint_kind", "")).strip().lower(),
|
|
|
|
|
|
)
|
|
|
|
|
|
headers = build_upstream_headers_for_endpoint(
|
2026-01-30 22:41:42 +08:00
|
|
|
|
original_headers,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
endpoint_sig,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
upstream_key,
|
|
|
|
|
|
endpoint_headers=extra_headers,
|
2026-02-06 16:37:06 +08:00
|
|
|
|
header_rules=getattr(endpoint, "header_rules", None),
|
2026-03-15 20:27:53 +08:00
|
|
|
|
body=body,
|
|
|
|
|
|
original_body=original_body,
|
|
|
|
|
|
condition_evaluator=evaluate_condition,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
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"),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
# 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,
|
2026-03-15 20:27:53 +08:00
|
|
|
|
*,
|
|
|
|
|
|
body: dict[str, Any] | None = None,
|
|
|
|
|
|
original_body: dict[str, Any] | None = None,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
) -> dict[str, str]:
|
|
|
|
|
|
"""构建 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,
|
2026-02-06 16:37:06 +08:00
|
|
|
|
header_rules=getattr(endpoint, "header_rules", None),
|
2026-03-15 20:27:53 +08:00
|
|
|
|
body=body,
|
|
|
|
|
|
original_body=original_body,
|
|
|
|
|
|
condition_evaluator=evaluate_condition,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
def _create_task_record(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
external_task_id: str,
|
|
|
|
|
|
candidate: ProviderCandidate,
|
|
|
|
|
|
original_request_body: dict[str, Any],
|
|
|
|
|
|
internal_request: Any,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
candidate_keys: list[dict[str, Any]] | None = None,
|
|
|
|
|
|
original_headers: dict[str, str] | None = None,
|
|
|
|
|
|
billing_rule_snapshot: dict[str, Any] | None = None,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
converted_request_body: dict[str, Any] | None = None,
|
|
|
|
|
|
format_converted: bool = False,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
) -> VideoTask:
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
|
|
|
|
|
# 构建请求元数据(使用追踪信息)
|
|
|
|
|
|
request_metadata = {
|
|
|
|
|
|
"candidate_keys": candidate_keys or [],
|
|
|
|
|
|
"selected_key_id": candidate.key.id,
|
|
|
|
|
|
"selected_endpoint_id": candidate.endpoint.id,
|
|
|
|
|
|
"client_ip": self.client_ip,
|
|
|
|
|
|
"user_agent": self.user_agent,
|
|
|
|
|
|
"request_id": self.request_id,
|
|
|
|
|
|
"billing_rule_snapshot": billing_rule_snapshot,
|
|
|
|
|
|
}
|
|
|
|
|
|
# 记录请求头(脱敏处理)
|
|
|
|
|
|
if original_headers:
|
|
|
|
|
|
safe_headers = {
|
|
|
|
|
|
k: v
|
|
|
|
|
|
for k, v in original_headers.items()
|
|
|
|
|
|
if k.lower() not in {"authorization", "x-api-key", "x-goog-api-key", "cookie"}
|
|
|
|
|
|
}
|
|
|
|
|
|
request_metadata["request_headers"] = safe_headers
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
provider_api_format = make_signature_key(
|
|
|
|
|
|
str(getattr(candidate.endpoint, "api_family", "")).strip().lower(),
|
|
|
|
|
|
str(getattr(candidate.endpoint, "endpoint_kind", "")).strip().lower(),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
return VideoTask(
|
|
|
|
|
|
id=str(uuid4()),
|
2026-02-02 03:16:52 +08:00
|
|
|
|
request_id=self.request_id,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
external_task_id=external_task_id,
|
|
|
|
|
|
user_id=self.user.id,
|
|
|
|
|
|
api_key_id=self.api_key.id,
|
2026-03-08 14:31:15 +08:00
|
|
|
|
username=self.user.username,
|
|
|
|
|
|
api_key_name=self.api_key.name,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
provider_id=candidate.provider.id,
|
|
|
|
|
|
endpoint_id=candidate.endpoint.id,
|
|
|
|
|
|
key_id=candidate.key.id,
|
2026-02-01 17:28:27 +08:00
|
|
|
|
client_api_format=self.FORMAT_ID,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
provider_api_format=provider_api_format,
|
|
|
|
|
|
format_converted=format_converted,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
model=internal_request.model,
|
|
|
|
|
|
prompt=internal_request.prompt,
|
|
|
|
|
|
original_request_body=original_request_body,
|
2026-02-02 03:16:52 +08:00
|
|
|
|
converted_request_body=converted_request_body or original_request_body,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
duration_seconds=internal_request.duration_seconds,
|
|
|
|
|
|
resolution=internal_request.resolution,
|
|
|
|
|
|
aspect_ratio=internal_request.aspect_ratio,
|
|
|
|
|
|
status=VideoStatus.SUBMITTED.value,
|
|
|
|
|
|
progress_percent=0,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
poll_interval_seconds=config.video_poll_interval_seconds,
|
|
|
|
|
|
next_poll_at=now + timedelta(seconds=config.video_poll_interval_seconds),
|
2026-01-30 22:41:42 +08:00
|
|
|
|
poll_count=0,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
max_poll_count=config.video_max_poll_count,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
submitted_at=now,
|
2026-01-31 19:11:25 +08:00
|
|
|
|
request_metadata=request_metadata,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
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},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-30 22:41:42 +08:00
|
|
|
|
def _get_task_by_external_id(self, external_id: str) -> VideoTask:
|
2026-02-02 03:16:52 +08:00
|
|
|
|
"""按 short_id 查找任务(我们对外暴露的 operation 格式是 models/{model}/operations/{short_id})"""
|
|
|
|
|
|
from src.api.handlers.base.video_handler_base import extract_short_id_from_operation
|
2026-01-31 19:11:25 +08:00
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
short_id = extract_short_id_from_operation(external_id)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 通过 short_id 查找任务
|
2026-01-30 22:41:42 +08:00
|
|
|
|
task = (
|
|
|
|
|
|
self.db.query(VideoTask)
|
|
|
|
|
|
.filter(
|
2026-02-02 03:16:52 +08:00
|
|
|
|
VideoTask.short_id == short_id,
|
2026-01-30 22:41:42 +08:00
|
|
|
|
VideoTask.user_id == self.user.id,
|
|
|
|
|
|
)
|
|
|
|
|
|
.first()
|
|
|
|
|
|
)
|
|
|
|
|
|
if not task:
|
2026-02-02 21:16:28 +08:00
|
|
|
|
logger.debug("[GeminiVeoHandler] Task not found: short_id={}", short_id)
|
2026-01-30 22:41:42 +08:00
|
|
|
|
raise HTTPException(status_code=404, detail="Video task not found")
|
|
|
|
|
|
return task
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
__all__ = ["GeminiVeoHandler"]
|