feat: 接入模型动态 Fallback 与调用审计
This commit is contained in:
@@ -15,6 +15,7 @@ import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
|
||||
from django.conf import settings
|
||||
@@ -272,18 +273,33 @@ def _reap_stale_free_video_tasks(*, team) -> None:
|
||||
· SUBMITTED/POLLING 超 2 小时:轮询链早已断且无人认领(正常出片 5-10 分钟)→ 标失败退费;
|
||||
· POSTPROCESSING 超 30 分钟:转存/结算中途崩溃 → 标失败退费(火山可能已出片,平台承担该笔成本)。"""
|
||||
now = timezone.now()
|
||||
from .routing_policy import load_model_routing_policy
|
||||
|
||||
video_policy = load_model_routing_policy().video
|
||||
buckets = [
|
||||
([AITask.Status.RESERVED], now - timedelta(minutes=10), "任务未在预期时间内提交(自动回收)"),
|
||||
([AITask.Status.SUBMITTED, AITask.Status.POLLING], now - timedelta(hours=2), "生成超时(自动回收)"),
|
||||
([AITask.Status.POSTPROCESSING], now - timedelta(minutes=30), "视频结果处理超时(自动回收)"),
|
||||
(
|
||||
[AITask.Status.RESERVED],
|
||||
{"updated_at__lt": now - timedelta(seconds=video_policy.submit_total_timeout)},
|
||||
"任务未在配置的提交总时限内完成(自动回收)",
|
||||
),
|
||||
(
|
||||
[AITask.Status.SUBMITTED, AITask.Status.POLLING],
|
||||
{"submitted_at__lt": now - timedelta(seconds=video_policy.generation_timeout)},
|
||||
"生成超过配置的成片等待总时限(自动回收)",
|
||||
),
|
||||
(
|
||||
[AITask.Status.POSTPROCESSING],
|
||||
{"updated_at__lt": now - timedelta(minutes=30)},
|
||||
"视频结果处理超时(自动回收)",
|
||||
),
|
||||
]
|
||||
for statuses, cutoff, reason in buckets:
|
||||
for statuses, stale_filter, reason in buckets:
|
||||
stale = AITask.objects.filter(
|
||||
team=team,
|
||||
project__isnull=True,
|
||||
task_type=AITask.Type.FREE_VIDEO,
|
||||
status__in=statuses,
|
||||
updated_at__lt=cutoff,
|
||||
**stale_filter,
|
||||
)
|
||||
for task in stale:
|
||||
try:
|
||||
@@ -404,6 +420,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
"price_multiplier": quote.meta.get("price_multiplier", "1"),
|
||||
"points_per_yuan_snapshot": quote.meta.get("rate", ""),
|
||||
"references": built["snapshots"],
|
||||
"model_routing_v1": True,
|
||||
}
|
||||
|
||||
# 建任务 + 预留同一事务:余额不足/限额拦截时回滚任务行,不留半套
|
||||
@@ -418,7 +435,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
idempotency_key=f"free_video:{team.id}:{uuid.uuid4()}",
|
||||
request_payload=request_payload,
|
||||
estimated_cost=quote.points,
|
||||
base_cost=quote.base_cost_yuan,
|
||||
base_cost=Decimal("0"),
|
||||
)
|
||||
try:
|
||||
reserve_credit(team=team, user=user, task=task, amount=reserve_amount)
|
||||
@@ -431,26 +448,44 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
|
||||
# 火山调用在事务外(不持锁调外网)
|
||||
try:
|
||||
from .services import build_provider
|
||||
from .services import execute_routed_video_submit
|
||||
|
||||
provider = build_provider(model_config)
|
||||
response = provider.create_video_task(
|
||||
model=model_config.name,
|
||||
endpoint=model_config.endpoint,
|
||||
routed = execute_routed_video_submit(
|
||||
task=task,
|
||||
primary_model=model_config,
|
||||
prompt=built["api_prompt"],
|
||||
ratio=aspect_ratio,
|
||||
duration=duration,
|
||||
resolution=resolution,
|
||||
generate_audio=generate_audio,
|
||||
reference_images=[],
|
||||
content_items=built["content_items"],
|
||||
pricing_references=built["snapshots"],
|
||||
generate_audio=generate_audio,
|
||||
seed=seed if seed != -1 else None,
|
||||
search_mode=search_mode,
|
||||
request_summary={"feature": "free_video", "mode": mode},
|
||||
)
|
||||
task.provider_task_id = str(response.get("id") or response.get("task_id") or "")
|
||||
response, provider_task_id = routed.value
|
||||
# execute_model_call 以原子 F 表达式累计实际尝试的平台成本;刷新内存对象,
|
||||
# 保证本方法返回值与数据库中的 AITask.base_cost 完全一致。
|
||||
task.refresh_from_db(fields=["base_cost"])
|
||||
task.provider_task_id = provider_task_id
|
||||
task.response_payload = response
|
||||
payload = dict(task.request_payload or {})
|
||||
payload["actual_model_config_id"] = str(routed.actual_model.id)
|
||||
task.request_payload = payload
|
||||
task.status = AITask.Status.SUBMITTED
|
||||
task.submitted_at = timezone.now()
|
||||
task.save(update_fields=["provider_task_id", "response_payload", "status", "submitted_at", "updated_at"])
|
||||
task.save(
|
||||
update_fields=[
|
||||
"provider_task_id",
|
||||
"response_payload",
|
||||
"request_payload",
|
||||
"status",
|
||||
"submitted_at",
|
||||
"updated_at",
|
||||
]
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — 创建失败:标失败退费,返回失败卡(不向上抛)
|
||||
code, raw_message = parse_provider_error(exc)
|
||||
public_error = classify_generation_error(
|
||||
@@ -577,11 +612,47 @@ def finalize_free_video(*, task: AITask) -> AITask:
|
||||
if not task.provider_task_id:
|
||||
return task
|
||||
|
||||
from .services import build_provider
|
||||
from .routing_policy import load_model_routing_policy
|
||||
from .services import get_video_provider
|
||||
|
||||
provider = build_provider(task.model_config)
|
||||
video_policy = load_model_routing_policy().video
|
||||
if task.submitted_at and (
|
||||
timezone.now() - task.submitted_at
|
||||
).total_seconds() >= video_policy.generation_timeout:
|
||||
timeout_message = "视频生成超过配置的成片等待总时限"
|
||||
public_error = classify_generation_error(
|
||||
TimeoutError(timeout_message),
|
||||
operation="video_generate",
|
||||
reference_id=str(task.id),
|
||||
)
|
||||
with transaction.atomic():
|
||||
locked = AITask.objects.select_for_update().get(id=task.id)
|
||||
if locked.status not in (AITask.Status.SUBMITTED, AITask.Status.POLLING):
|
||||
return locked
|
||||
locked.status = AITask.Status.FAILED
|
||||
locked.error_code = "GenerationTimeout"
|
||||
locked.error_message = timeout_message
|
||||
locked.completed_at = timezone.now()
|
||||
locked.save(
|
||||
update_fields=["status", "error_code", "error_message", "completed_at", "updated_at"]
|
||||
)
|
||||
release_credit(reservation=locked.credit_reservation, reason=timeout_message)
|
||||
_notify_failure(locked, raw=timeout_message, hint=public_error.fallback_message)
|
||||
return locked
|
||||
|
||||
submit_attempt = (
|
||||
task.model_attempts.filter(status="succeeded", operation="video_generate")
|
||||
.select_related("model_config__provider")
|
||||
.order_by("-sequence")
|
||||
.first()
|
||||
)
|
||||
actual_model = submit_attempt.model_config if submit_attempt and submit_attempt.model_config else task.model_config
|
||||
|
||||
provider = get_video_provider(actual_model)
|
||||
response = provider.poll_video_task(
|
||||
endpoint=task.model_config.endpoint, provider_task_id=task.provider_task_id
|
||||
endpoint=actual_model.endpoint,
|
||||
provider_task_id=task.provider_task_id,
|
||||
timeout=video_policy.poll_request_timeout,
|
||||
)
|
||||
remote_status = str(response.get("status") or "")
|
||||
|
||||
@@ -647,7 +718,7 @@ def finalize_free_video(*, task: AITask) -> AITask:
|
||||
from decimal import Decimal
|
||||
|
||||
settle = quote_video_actual(
|
||||
locked.model_config, tokens=total_tokens, with_video_ref=with_video_ref, resolution=resolution,
|
||||
actual_model, tokens=total_tokens, with_video_ref=with_video_ref, resolution=resolution,
|
||||
multiplier=Decimal(str(payload.get("price_multiplier") or "1")),
|
||||
)
|
||||
actual, base_cost = settle.points, settle.base_cost_yuan
|
||||
|
||||
Reference in New Issue
Block a user