Files
Aether/_deprecated_py_src/services/task/video/billing.py
fawney19 1d9c77522a refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
2026-04-03 16:26:16 +08:00

325 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from typing import Any
from sqlalchemy.orm import Session
from src.config.settings import config
from src.core.api_format.conversion.internal_video import VideoStatus
from src.core.logger import logger
from src.models.database import ApiKey, Provider, Usage, User
from src.services.usage.service import UsageService
class VideoTaskBillingService:
"""视频任务计费/结算服务。"""
def __init__(self, db: Session) -> None:
self.db = db
async def _create_fallback_usage_for_video_task(self, task: Any, request_id: str) -> bool:
"""
Fallback: create a Usage row if it's missing (should be rare).
This keeps behavior compatible with the old Phase2 finalize logic.
"""
user_obj = self.db.query(User).filter(User.id == task.user_id).first()
api_key_obj = (
self.db.query(ApiKey).filter(ApiKey.id == task.api_key_id).first()
if getattr(task, "api_key_id", None)
else None
)
provider_obj = (
self.db.query(Provider).filter(Provider.id == task.provider_id).first()
if getattr(task, "provider_id", None)
else None
)
provider_name = provider_obj.name if provider_obj else "unknown"
response_time_ms: int | None = None
if getattr(task, "submitted_at", None) and getattr(task, "completed_at", None):
delta = task.completed_at - task.submitted_at
response_time_ms = int(delta.total_seconds() * 1000)
request_headers: dict[str, Any] | None = None
if isinstance(getattr(task, "request_metadata", None), dict):
task_meta = task.request_metadata
for header_key in ("request_headers", "headers", "original_headers"):
raw_headers = task_meta.get(header_key)
if isinstance(raw_headers, dict):
request_headers = dict(raw_headers)
break
try:
await UsageService.record_usage_with_custom_cost(
db=self.db,
user=user_obj,
api_key=api_key_obj,
provider=provider_name,
model=task.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=task.client_api_format,
endpoint_api_format=task.provider_api_format,
has_format_conversion=bool(getattr(task, "format_converted", False)),
is_stream=False,
response_time_ms=response_time_ms,
first_byte_time_ms=None,
status_code=200 if task.status == "completed" else 500,
error_message=(
None
if task.status == "completed"
else (task.error_message or task.error_code or "video_task_failed")
),
metadata={
"fallback_created": True,
"video_task_id": task.id,
},
request_headers=request_headers,
request_body=getattr(task, "original_request_body", None),
provider_request_headers=None,
response_headers=None,
client_response_headers=None,
response_body=None,
request_id=request_id,
provider_id=getattr(task, "provider_id", None),
provider_endpoint_id=getattr(task, "endpoint_id", None),
provider_api_key_id=getattr(task, "key_id", None),
status="completed" if task.status == "completed" else "failed",
target_model=None,
finalized_at=getattr(task, "completed_at", None),
)
return True
except Exception as exc:
logger.exception(
"Failed to create fallback usage for video task={}: {}",
task.id,
str(exc),
)
return False
async def finalize_video_task(self, task: Any) -> bool:
"""
Update billing/usage for a completed/failed video task.
Async video billing flow:
- Submit success: Usage is already settled with cost=0
- Poll completion: update actual cost (success -> bill, failure -> keep 0)
Returns True when updated, False when skipped (already finalized).
"""
from datetime import datetime, timezone
from src.services.billing.dimension_collector_service import DimensionCollectorService
from src.services.billing.formula_engine import BillingIncompleteError, FormulaEngine
from src.services.billing.rule_service import BillingRuleService
request_id = getattr(task, "request_id", None) or getattr(task, "id", None)
if not request_id:
return False
# Advisory check无锁仅用于快速跳过实际状态转换由 update_settled_billing 的
# with_for_update() 保证原子性。
existing = self.db.query(Usage).filter(Usage.request_id == request_id).first()
if not existing:
logger.warning(
"Usage not found for video task, creating fallback: task_id={} request_id={}",
getattr(task, "id", None),
request_id,
)
return await self._create_fallback_usage_for_video_task(task, request_id)
if getattr(existing, "billing_status", None) != "pending":
logger.debug(
"Skip video task billing finalize because Usage is already terminal: task_id={} request_id={} billing_status={}",
getattr(task, "id", None),
request_id,
getattr(existing, "billing_status", None),
)
return False
response_time_ms: int | None = None
if getattr(task, "submitted_at", None) and getattr(task, "completed_at", None):
delta = task.completed_at - task.submitted_at
response_time_ms = int(delta.total_seconds() * 1000)
base_dimensions: dict[str, Any] = {
"duration_seconds": getattr(task, "duration_seconds", None),
"resolution": getattr(task, "resolution", None),
"aspect_ratio": getattr(task, "aspect_ratio", None),
"size": getattr(task, "size", None) or "",
"retry_count": getattr(task, "retry_count", 0),
}
collector_metadata: dict[str, Any] = {
"task": {
"id": getattr(task, "id", None),
"external_task_id": getattr(task, "external_task_id", None),
"model": getattr(task, "model", None),
"duration_seconds": getattr(task, "duration_seconds", None),
"resolution": getattr(task, "resolution", None),
"aspect_ratio": getattr(task, "aspect_ratio", None),
"size": getattr(task, "size", None),
"retry_count": getattr(task, "retry_count", 0),
"video_size_bytes": getattr(task, "video_size_bytes", None),
},
"result": {
"video_url": getattr(task, "video_url", None),
"video_urls": getattr(task, "video_urls", None) or [],
},
}
poll_raw = None
if isinstance(getattr(task, "request_metadata", None), dict):
poll_raw = task.request_metadata.get("poll_raw_response")
dims = DimensionCollectorService(self.db).collect_dimensions(
api_format=getattr(task, "provider_api_format", None),
task_type="video",
request=getattr(task, "original_request_body", None) or {},
response=poll_raw if isinstance(poll_raw, dict) else None,
metadata=collector_metadata,
base_dimensions=base_dimensions,
)
# Prefer frozen rule snapshot from submit stage.
rule_snapshot = None
if isinstance(getattr(task, "request_metadata", None), dict):
rule_snapshot = task.request_metadata.get("billing_rule_snapshot")
expression = None
variables: dict[str, Any] | None = None
dimension_mappings: dict[str, dict[str, Any]] | None = None
rule_id = None
rule_name = None
rule_scope = None
if isinstance(rule_snapshot, dict) and rule_snapshot.get("status") == "ok":
rule_id = rule_snapshot.get("rule_id")
rule_name = rule_snapshot.get("rule_name")
rule_scope = rule_snapshot.get("scope")
expression = rule_snapshot.get("expression")
variables = rule_snapshot.get("variables") or {}
dimension_mappings = rule_snapshot.get("dimension_mappings") or {}
else:
lookup = BillingRuleService.find_rule(
self.db,
provider_id=getattr(task, "provider_id", None),
model_name=getattr(task, "model", None),
task_type="video",
)
if lookup:
rule = lookup.rule
rule_id = getattr(rule, "id", None)
rule_name = getattr(rule, "name", None)
rule_scope = getattr(lookup, "scope", None)
expression = getattr(rule, "expression", None)
variables = getattr(rule, "variables", None) or {}
dimension_mappings = getattr(rule, "dimension_mappings", None) or {}
billing_snapshot: dict[str, Any] = {
"schema_version": "1.0",
"rule_id": str(rule_id) if rule_id else None,
"rule_name": str(rule_name) if rule_name else None,
"scope": str(rule_scope) if rule_scope else None,
"expression": str(expression) if expression else None,
"dimensions_used": dims,
"missing_required": [],
"cost": 0.0,
"status": "no_rule",
"calculated_at": datetime.now(timezone.utc).isoformat(),
}
cost = 0.0
is_success = str(getattr(task, "status", "")) in {
VideoStatus.COMPLETED.value,
"completed",
}
if is_success and expression:
engine = FormulaEngine()
try:
result = engine.evaluate(
expression=str(expression),
variables=variables,
dimensions=dims,
dimension_mappings=dimension_mappings,
strict_mode=config.billing_strict_mode,
)
billing_snapshot["status"] = result.status
billing_snapshot["missing_required"] = result.missing_required
if result.status == "complete":
cost = float(result.cost)
billing_snapshot["cost"] = cost
except BillingIncompleteError as exc:
# strict_mode=true: mark task failed and hide artifacts (avoid free pass)
task.status = VideoStatus.FAILED.value
task.error_code = "billing_incomplete"
task.error_message = f"Missing required dimensions: {exc.missing_required}"
task.video_url = None
task.video_urls = None
billing_snapshot["status"] = "incomplete"
billing_snapshot["missing_required"] = exc.missing_required
billing_snapshot["cost"] = 0.0
cost = 0.0
except Exception as exc:
billing_snapshot["status"] = "incomplete"
billing_snapshot["error"] = str(exc)
billing_snapshot["cost"] = 0.0
cost = 0.0
# Write back to task.request_metadata for audit/recalc.
task_meta = dict(task.request_metadata) if getattr(task, "request_metadata", None) else {}
task_meta["billing_snapshot"] = billing_snapshot
task.request_metadata = task_meta
updated = UsageService.update_settled_billing(
self.db,
request_id=request_id,
total_cost_usd=cost,
request_cost_usd=cost,
status="completed" if str(getattr(task, "status", "")) == "completed" else "failed",
status_code=200 if str(getattr(task, "status", "")) == "completed" else 500,
error_message=(
None
if str(getattr(task, "status", "")) == "completed"
else (
getattr(task, "error_message", None)
or getattr(task, "error_code", None)
or "video_task_failed"
)
),
response_time_ms=response_time_ms,
billing_snapshot=billing_snapshot,
extra_metadata={
"dimensions": dims,
"raw_response_ref": {
"video_task_id": getattr(task, "id", None),
"field": "video_tasks.request_metadata.poll_raw_response",
},
},
finalized_at=getattr(task, "completed_at", None),
)
if updated:
logger.debug(
"Updated video task billing: task_id={} request_id={} cost={:.6f}",
getattr(task, "id", None),
request_id,
cost,
)
else:
logger.warning(
"Failed to update video task billing (may already be updated): "
"task_id={} request_id={}",
getattr(task, "id", None),
request_id,
)
return bool(updated)