mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
feat: 视频计费增强与影子计费系统
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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__ = [
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
# =========================
|
||||
|
||||
@@ -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
|
||||
# =========================
|
||||
|
||||
Reference in New Issue
Block a user