feat: 视频计费增强与影子计费系统

This commit is contained in:
fawney19
2026-02-03 18:48:39 +08:00
parent ed68aebfb0
commit 00442c41fa
84 changed files with 6507 additions and 1678 deletions

View File

@@ -81,7 +81,7 @@ def is_format_compatible(
# 2. 格式不同 -> 需要检查格式转换开关
# 如果总开关为 False直接拒绝禁用任何跨格式转换
if not effective_conversion_enabled:
return False, False, "格式转换已禁用(FORMAT_CONVERSION_ENABLED=false"
return False, False, "格式转换已禁用(enable_format_conversion=false"
# 3. 如果全局或提供商开关为 ON跳过端点配置检查
if not skip_endpoint_check:

View File

@@ -74,6 +74,7 @@ class InternalVideoPollResult:
error_code: str | None = None
error_message: str | None = None
raw_response: dict[str, Any] | None = None
video_duration_seconds: float | None = None # 实际视频时长
__all__ = [

View File

@@ -102,8 +102,15 @@ class FormatNormalizer(ABC):
"""将视频任务响应转换为内部表示"""
raise NotImplementedError(f"{self.__class__.__name__} does not support video conversion")
def video_task_from_internal(self, internal: InternalVideoTask) -> dict[str, Any]:
"""将内部视频任务转换为格式特定响应"""
def video_task_from_internal(
self, internal: InternalVideoTask, *, base_url: str | None = None
) -> dict[str, Any]:
"""将内部视频任务转换为格式特定响应
Args:
internal: 内部视频任务表示
base_url: 可选的基础 URL用于构建完整的下载链接
"""
raise NotImplementedError(f"{self.__class__.__name__} does not support video conversion")
def video_poll_to_internal(self, response: dict[str, Any]) -> InternalVideoPollResult:

View File

@@ -872,7 +872,9 @@ class GeminiNormalizer(FormatNormalizer):
extra={"metadata": metadata},
)
def video_task_from_internal(self, internal: InternalVideoTask) -> dict[str, Any]:
def video_task_from_internal(
self, internal: InternalVideoTask, *, base_url: str | None = None
) -> dict[str, Any]:
# 从 external_id 中提取 model 名称,用于构建 operation name
# external_id 格式: models/{model}/operations/{gemini_id}
model_name = "unknown"
@@ -889,7 +891,9 @@ class GeminiNormalizer(FormatNormalizer):
# 使用我们的内部 task_id 构建下载 URL不暴露真实的 Gemini file_id
# 使用 aev_ 前缀标识这是视频任务的下载链接
# 格式:/v1beta/files/aev_{task_id}:download?alt=media
proxy_download_url = f"/v1beta/files/aev_{internal.id}:download?alt=media"
download_path = f"/v1beta/files/aev_{internal.id}:download?alt=media"
# 如果提供了 base_url返回完整 URL否则返回相对路径
proxy_download_url = f"{base_url}{download_path}" if base_url else download_path
return {
"name": operation_name,
"done": True,
@@ -938,12 +942,15 @@ class GeminiNormalizer(FormatNormalizer):
if isinstance(s, dict) and s.get("video", {}).get("uri")
]
video_url = video_urls[0] if video_urls else None
# 提取实际视频时长
video_duration = self._extract_gemini_video_duration(response, samples)
return InternalVideoPollResult(
status=VideoStatus.COMPLETED,
progress_percent=100,
video_url=video_url,
video_urls=video_urls,
raw_response=response,
video_duration_seconds=video_duration,
)
return InternalVideoPollResult(
@@ -952,6 +959,41 @@ class GeminiNormalizer(FormatNormalizer):
raw_response=response,
)
def _extract_gemini_video_duration(
self, response: dict[str, Any], samples: list[dict[str, Any]]
) -> float | None:
"""从 Gemini 响应中提取实际视频时长"""
# 尝试从 samples 中获取时长
for sample in samples:
if not isinstance(sample, dict):
continue
video = sample.get("video", {})
if isinstance(video, dict):
# 尝试多种字段名
for field in ["durationSeconds", "duration_seconds", "duration"]:
val = video.get(field)
if val is not None:
try:
# duration 可能是 "5s" 格式
if isinstance(val, str) and val.endswith("s"):
return float(val[:-1])
return float(val)
except (ValueError, TypeError):
continue
# 尝试从 response.metadata 获取
metadata = response.get("metadata", {})
if isinstance(metadata, dict):
for field in ["durationSeconds", "duration_seconds", "duration"]:
val = metadata.get(field)
if val is not None:
try:
if isinstance(val, str) and val.endswith("s"):
return float(val[:-1])
return float(val)
except (ValueError, TypeError):
continue
return None
# =========================
# Helpers
# =========================

View File

@@ -396,7 +396,9 @@ class OpenAINormalizer(FormatNormalizer):
# OpenAI streaming may send a final "usage-only" chunk when
# stream_options.include_usage=true, where `choices` is empty but `usage` exists.
usage_info = self._openai_usage_to_internal(chunk.get("usage"))
if usage_info is not None and (usage_info.total_tokens or usage_info.input_tokens or usage_info.output_tokens):
if usage_info is not None and (
usage_info.total_tokens or usage_info.input_tokens or usage_info.output_tokens
):
# For cross-format targets (e.g. Gemini), emitting usage as a late MessageStopEvent
# allows the target normalizer to surface usage metadata even if the stop chunk
# didn't carry it.
@@ -730,7 +732,9 @@ class OpenAINormalizer(FormatNormalizer):
},
)
def video_task_from_internal(self, internal: InternalVideoTask) -> dict[str, Any]:
def video_task_from_internal(
self, internal: InternalVideoTask, *, base_url: str | None = None
) -> dict[str, Any]:
status_map = {
VideoStatus.PENDING: "queued",
VideoStatus.SUBMITTED: "queued",
@@ -797,6 +801,8 @@ class OpenAINormalizer(FormatNormalizer):
error_message="Upstream response missing video url",
raw_response=response,
)
# 提取实际视频时长(尝试多种字段名)
video_duration = self._extract_video_duration(response)
return InternalVideoPollResult(
status=VideoStatus.COMPLETED,
progress_percent=100,
@@ -805,6 +811,7 @@ class OpenAINormalizer(FormatNormalizer):
datetime.fromtimestamp(expires_at, tz=timezone.utc) if expires_at else None
),
raw_response=response,
video_duration_seconds=video_duration,
)
if status == "failed":
error = response.get("error") or {}
@@ -821,6 +828,36 @@ class OpenAINormalizer(FormatNormalizer):
raw_response=response,
)
def _extract_video_duration(self, response: dict[str, Any]) -> float | None:
"""从响应中提取实际视频时长"""
# 尝试多种可能的字段名
duration_fields = [
"duration_seconds",
"duration",
"video_duration",
"video_duration_seconds",
"length",
"length_seconds",
]
for field in duration_fields:
val = response.get(field)
if val is not None:
try:
return float(val)
except (ValueError, TypeError):
continue
# 尝试从嵌套的 metadata 中获取
metadata = response.get("metadata") or response.get("video_metadata") or {}
if isinstance(metadata, dict):
for field in duration_fields:
val = metadata.get(field)
if val is not None:
try:
return float(val)
except (ValueError, TypeError):
continue
return None
# =========================
# Helpers
# =========================