feat: 接入模型动态 Fallback 与调用审计
This commit is contained in:
@@ -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")
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user