feat: 接入模型动态 Fallback 与调用审计

This commit is contained in:
hh
2026-07-21 10:35:50 +08:00
parent 9429133d04
commit d0c3690d47
45 changed files with 9253 additions and 173 deletions
+89 -18
View File
@@ -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