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
@@ -0,0 +1,154 @@
"""为存量供应商和模型补齐动态 Fallback 路由与最小能力配置。
运行时解析器不识别供应商名或模型名;这里仅把当前产品已经确认的火山/YunQi优先级
写入数据库 metadata。以后新增供应商或模型只维护 metadata,不需要修改路由代码。
"""
from django.db import migrations
DIRECT_PROVIDER_NAMES = {"volcengine", "volcano", "ark", "volcano_ark", "doubao"}
IMAGE_RATIOS = ["1:1", "3:4", "4:5", "9:16", "16:9"]
VIDEO_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"]
VOICE_MAP = {
"BV700_streaming": "BV700_streaming",
"BV034_streaming": "BV034_streaming",
"BV001_streaming": "BV001_streaming",
"BV056_streaming": "BV056_streaming",
"BV102_streaming": "BV102_streaming",
"BV002_streaming": "BV002_streaming",
}
def _provider_priority(name):
if name in DIRECT_PROVIDER_NAMES:
return 10
if name == "yunqi" or name.startswith("yunqi_"):
return 20
return 100
def _mode_limit(modes, prefix, default=0):
for mode in modes:
if mode.startswith(prefix):
_, _, raw = mode.partition(":")
try:
return int(raw)
except (TypeError, ValueError):
return default
return default
def _capabilities(model):
metadata = dict(model.metadata or {})
capability = model.capability
if capability == "text":
return {
"operations": ["chat"],
"features": ["streaming", "structured_output"],
}
if capability == "image":
modes = {str(item) for item in metadata.get("modes") or []}
supports_reference = bool(metadata.get("supports_reference")) or bool(
modes & {"singleImage", "multiReference"}
)
reference_modes = ["none"]
if supports_reference or "singleImage" in modes:
reference_modes.append("single")
if supports_reference or "multiReference" in modes:
reference_modes.append("multiple")
operations = ["image_generate"]
if supports_reference:
operations.append("image_edit")
return {
"operations": operations,
"features": [],
"reference_modes": reference_modes,
"max_reference_images": 9 if supports_reference else 0,
"aspect_ratios": IMAGE_RATIOS,
}
if capability == "video":
modes = {str(item) for item in metadata.get("modes") or []}
features = ["text_to_video"]
for mode, feature in (
("startFrameOptional", "start_frame"),
("lastFrameOptional", "last_frame"),
("imageReference:9", "image_reference"),
("videoReference:3", "video_reference"),
("audioReference:3", "audio_reference"),
):
if mode in modes:
features.append(feature)
if metadata.get("audio") in {"optional", "required", True}:
features.append("generate_audio")
return {
"operations": ["video_generate"],
"features": features,
"max_reference_images": _mode_limit(modes, "imageReference", 0),
"max_reference_videos": _mode_limit(modes, "videoReference", 0),
"max_reference_audios": _mode_limit(modes, "audioReference", 0),
"aspect_ratios": VIDEO_RATIOS,
"resolutions": list(metadata.get("resolutions") or []),
"durations": list(metadata.get("durations") or []),
}
if capability == "audio":
return {
"operations": ["tts"],
"features": [],
"languages": ["zh-CN"],
"voice_map": VOICE_MAP,
"max_chars": 10000,
"speed_range": [0.5, 2.0],
"output_formats": ["mp3"],
}
return {}
def apply(apps, schema_editor):
ModelProvider = apps.get_model("ai", "ModelProvider")
ModelConfig = apps.get_model("ai", "ModelConfig")
for provider in ModelProvider.objects.all().iterator():
metadata = dict(provider.metadata or {})
routing = dict(metadata.get("routing") or {})
routing.setdefault("fallback_priority", _provider_priority(provider.name))
metadata["routing"] = routing
provider.metadata = metadata
provider.save(update_fields=["metadata"])
for model in ModelConfig.objects.select_related("provider").all().iterator():
capabilities = _capabilities(model)
metadata = dict(model.metadata or {})
routing = dict(metadata.get("routing") or {})
is_supported = bool(capabilities)
routing.setdefault("fallback_candidate", is_supported)
routing.setdefault(
"fallback_on_failure",
is_supported and model.provider.name not in DIRECT_PROVIDER_NAMES,
)
metadata["routing"] = routing
if capabilities:
metadata.setdefault("capabilities", capabilities)
model.metadata = metadata
model.save(update_fields=["metadata"])
def revert(apps, schema_editor):
ModelProvider = apps.get_model("ai", "ModelProvider")
ModelConfig = apps.get_model("ai", "ModelConfig")
for provider in ModelProvider.objects.all().iterator():
metadata = dict(provider.metadata or {})
metadata.pop("routing", None)
provider.metadata = metadata
provider.save(update_fields=["metadata"])
for model in ModelConfig.objects.all().iterator():
metadata = dict(model.metadata or {})
metadata.pop("routing", None)
metadata.pop("capabilities", None)
model.metadata = metadata
model.save(update_fields=["metadata"])
class Migration(migrations.Migration):
dependencies = [("ai", "0027_aitask_model_triview_type")]
operations = [migrations.RunPython(apply, revert)]
@@ -0,0 +1,91 @@
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
dependencies = [("ai", "0028_seed_model_routing_metadata")]
operations = [
migrations.CreateModel(
name="AIModelAttempt",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sequence", models.PositiveIntegerField()),
("provider_name", models.CharField(max_length=128)),
("provider_display_name", models.CharField(blank=True, max_length=128)),
("model_name", models.CharField(max_length=128)),
("model_display_name", models.CharField(blank=True, max_length=128)),
("public_model_name", models.CharField(blank=True, max_length=128)),
("capability", models.CharField(max_length=32)),
("operation", models.CharField(max_length=64)),
(
"status",
models.CharField(
choices=[("started", "Started"), ("succeeded", "Succeeded"), ("failed", "Failed")],
default="started",
max_length=24,
),
),
("is_retry", models.BooleanField(default=False)),
("is_fallback", models.BooleanField(default=False)),
("provider_task_id", models.CharField(blank=True, max_length=255)),
("started_at", models.DateTimeField()),
("finished_at", models.DateTimeField(blank=True, null=True)),
("duration_ms", models.PositiveBigIntegerField(blank=True, null=True)),
("error_type", models.CharField(blank=True, max_length=64)),
("provider_error_code", models.CharField(blank=True, max_length=128)),
("raw_error", models.TextField(blank=True)),
("safe_error_summary", models.TextField(blank=True)),
("usage", models.JSONField(blank=True, default=dict)),
("platform_cost", models.DecimalField(decimal_places=4, default=0, max_digits=12)),
("request_summary", models.JSONField(blank=True, default=dict)),
("response_summary", models.JSONField(blank=True, default=dict)),
(
"model_config",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="ai.modelconfig",
),
),
(
"previous_attempt",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="next_attempts",
to="ai.aimodelattempt",
),
),
(
"provider",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="ai.modelprovider",
),
),
(
"task",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="model_attempts",
to="ai.aitask",
),
),
],
options={
"ordering": ["sequence"],
"indexes": [models.Index(fields=["task", "status"], name="ai_attempt_task_status_idx")],
"constraints": [
models.UniqueConstraint(fields=("task", "sequence"), name="ai_attempt_task_sequence_unique")
],
},
),
]