fix(agent): 媒体准入参数结构化纠错

This commit is contained in:
ddcat666
2026-10-13 20:54:44 +08:00
parent 7466119867
commit fb03cfca1e
9 changed files with 157 additions and 117 deletions

View File

@@ -187,6 +187,14 @@ func TestCloudAgentToolsFollowCanvasCapabilityRegistry(t *testing.T) {
if got := functions["generate_media"]["properties"].(map[string]any)["mode"].(map[string]any)["enum"]; !reflect.DeepEqual(got, cloudAgentGenerationModeNames()) {
t.Fatalf("generation modes differ from implemented adapters: %v", got)
}
sizeDescription, _ := functions["generate_media"]["properties"].(map[string]any)["size"].(map[string]any)["description"].(string)
if !strings.Contains(sizeDescription, "imageSize/image.size.parameter") || !strings.Contains(sizeDescription, "allowCustom=true") {
t.Fatalf("generation size schema does not explain model-specific parameters: %q", sizeDescription)
}
splitProperties := functions["image_layer_split"]["properties"].(map[string]any)
if _, ok := splitProperties["size"]; !ok {
t.Fatal("image_layer_split is missing the shared image size field")
}
if got := CloudAgentCapabilitySetInfo(); got.Hash != canvasCapabilityRegistry.Hash() || got.Version != capability.SetVersion || !reflect.DeepEqual(got.Nodes, canvasCapabilityRegistry.Types()) {
t.Fatalf("capability endpoint info differs from registry: %+v", got)
}

View File

@@ -1,56 +1,6 @@
package app
import "strings"
const cloudAgentMediaModelListHint = "优先使用本回合已有 model_list 结果;只有 mode、referenceNodeIds 或模型选择发生变化时才重新查询;没有匹配项时向用户说明,不重复读取同一目录"
// 模型能力校验(model_capability_validate.go)拒绝的生成参数属于「模型填错了参数」,
// 不是模型、权限或预算问题。此前它们和其它准入失败一样被包成不可重试的
// media_admission_failed:工具结果只有一句「工具执行失败」,整轮直接终止,
// 模型既不知道错在哪个参数,也没有机会改正;用户要求重试时它只能原样再错一次。
//
// 这里把这类错误改报为字段级参数错误:结果里带着具体原因和取值来源,
// 走已有的 fix_arguments 纠正通道(次数仍受 cloudAgentToolAttemptLimit 限制),
// 没有任何任务被提交,也不会产生费用。
var cloudAgentMediaCapabilityArgumentHints = []struct {
marker string
field string
hint string
}{
{"图片尺寸不在当前模型支持范围内", "size", "size 必须取 model_list 返回的该模型 options.image.size.values 中的一个值(通常是像素尺寸,例如 1536x2752),不要写 9:16 这样的比例"},
{"图片尺寸宽高必须是 16 的倍数", "size", "size 必须使用宽x高,并且宽高都必须是 16 的倍数"},
{"图片尺寸最长边不能超过", "size", "size 的最长边不能超过当前模型允许的上限"},
{"图片宽高比不能超过", "size", "size 的宽高比必须落在当前模型允许的范围内"},
{"图片总像素需在", "size", "size 的总像素必须落在当前模型允许的范围内"},
{"当前分辨率不支持所选图片宽高比", "size", "所选 quality 档位下没有这个宽高比;按 model_list 返回的该模型 options.image.size.presets 选一组匹配的比例与分辨率"},
{"图片质量不在当前模型支持范围内", "quality", "quality 必须取 model_list 返回的该模型 options.image.quality.values 中的一个值,或省略"},
{"当前图片模型最多支持", "logicalModelId", cloudAgentMediaModelListHint},
{"参考图片文件超过当前模型大小限制", "logicalModelId", cloudAgentMediaModelListHint},
{"当前图片模型不支持蒙版编辑", "logicalModelId", cloudAgentMediaModelListHint},
{"视频时长不在当前模型支持范围内", "durationSeconds", "durationSeconds 必须落在 model_list 返回的该模型 options.video.duration 给出的范围或固定值内"},
{"画面比例不在当前模型支持范围内", "size", "视频的 size 必须取 model_list 返回的该模型 options.video.ratios 中的一个比例"},
{"输出分辨率不在当前模型支持范围内", "quality", "视频的 quality 必须取 model_list 返回的该模型 options.video.resolutions 中的一个值,大小写保持一致"},
{"参考素材数量超过当前模型限制", "logicalModelId", cloudAgentMediaModelListHint},
{"当前视频模型至少需要", "logicalModelId", cloudAgentMediaModelListHint},
{"参考视频文件超过当前模型大小限制", "logicalModelId", cloudAgentMediaModelListHint},
{"参考音频文件超过当前模型大小限制", "logicalModelId", cloudAgentMediaModelListHint},
{"参考视频时长超过当前模型限制", "logicalModelId", cloudAgentMediaModelListHint},
{"参考音频时长超过当前模型限制", "logicalModelId", cloudAgentMediaModelListHint},
{"火山方舟全模态参考不支持纯音频或文本+音频", "logicalModelId", cloudAgentMediaModelListHint},
{"当前视频模型不支持该生成模式", "logicalModelId", cloudAgentMediaModelListHint},
}
// cloudAgentMediaCapabilityArgumentError 识别能力校验错误并转成可纠正的参数错误;
// 其它错误返回 nil,由调用方按原口径处理。
func cloudAgentMediaCapabilityArgumentError(err error) error {
if err == nil {
return nil
}
message := err.Error()
for _, item := range cloudAgentMediaCapabilityArgumentHints {
if strings.Contains(message, item.marker) {
return cloudAgentFieldError(item.field, "unsupported_value", item.marker+";"+item.hint+"。未提交生成任务,改正后可直接重新调用")
}
}
return nil
}
// Media capability failures are converted to modelCapabilityArgumentError or
// ModelError at the shared task admission boundary. The Agent consumes those
// stable fields in model_capability_argument.go; no provider error text is
// parsed here.

View File

@@ -6,10 +6,8 @@ import (
"testing"
)
// 复现:generate_media 把 size 写成 "9:16" 这类比例值,而模型只接受像素尺寸;
// 修复前结果只有「工具执行失败」,整轮终止,重试时模型原样再错。
func TestCloudAgentMediaCapabilityErrorBecomesCorrectableArgumentError(t *testing.T) {
wrapped := cloudAgentWrapMediaAdmissionError(BadAuthRequest("图片尺寸不在当前模型支持范围内"))
wrapped := cloudAgentWrapMediaAdmissionError(newModelCapabilityArgumentError("size", "unsupported_value", BadAuthRequest("自定义图片尺寸请使用宽x高,例如 3840x1920")))
var argumentErr *cloudAgentArgumentError
if !errors.As(wrapped, &argumentErr) {
@@ -23,27 +21,33 @@ func TestCloudAgentMediaCapabilityErrorBecomesCorrectableArgumentError(t *testin
if class != cloudAgentToolErrorSchemaError || !retryable || action != "fix_arguments" {
t.Fatalf("class=%s retryable=%v action=%s", class, retryable, action)
}
text := cloudAgentSafeToolError(wrapped)
if !strings.Contains(text, "图片尺寸不在当前模型支持范围内") || !strings.Contains(text, "model_list") {
if text := cloudAgentSafeToolError(wrapped); !strings.Contains(text, "自定义图片尺寸请使用宽x高") {
t.Fatalf("model does not see the reason: %q", text)
}
}
func TestCloudAgentMediaCapabilityErrorCoversDurationRatioResolutionAndModelChoice(t *testing.T) {
for message, field := range map[string]string{
"视频时长不在当前模型支持范围内": "durationSeconds",
"画面比例不在当前模型支持范围内": "size",
"输出分辨率不在当前模型支持范围内": "quality",
"图片质量不在当前模型支持范围内": "quality",
"当前视频模型不支持该生成模式": "logicalModelId",
"参考素材数量超过当前模型限制": "logicalModelId",
"参考视频时长超过当前模型限制": "logicalModelId",
"图片尺寸宽高必须是 16 的倍数": "size",
func TestCloudAgentMediaCapabilityErrorsUseStableFields(t *testing.T) {
for _, test := range []struct {
field string
text string
}{
{field: "size", text: "所选模型不支持当前请求"},
{field: "quality", text: "图片质量不在当前模型支持范围内"},
{field: "count", text: "输出数量不支持"},
} {
var fieldErr *cloudAgentFieldArgumentError
if !errors.As(cloudAgentWrapMediaAdmissionError(BadAuthRequest(message)), &fieldErr) || fieldErr.Field != field {
t.Fatalf("%s → %#v, want field %s", message, fieldErr, field)
}
t.Run(test.field, func(t *testing.T) {
wrapped := cloudAgentWrapMediaAdmissionError(newModelCapabilityArgumentError(test.field, "unsupported_value", BadAuthRequest(test.text)))
var fieldErr *cloudAgentFieldArgumentError
if !errors.As(wrapped, &fieldErr) || fieldErr.Field != test.field || fieldErr.Issue != "unsupported_value" {
t.Fatalf("field error = %#v", fieldErr)
}
})
}
match := CapabilityMatch{Issues: []CapabilityIssue{{Kind: "option", Name: "size", Reason: "参数 画面尺寸超出支持范围"}}}
wrapped := cloudAgentWrapMediaAdmissionError(modelCapabilityMismatchError(match))
var fieldErr *cloudAgentFieldArgumentError
if !errors.As(wrapped, &fieldErr) || fieldErr.Field != "size" {
t.Fatalf("route mismatch field = %#v", fieldErr)
}
}
@@ -58,7 +62,6 @@ func TestCloudAgentMediaAdmissionFailureMessageKeepsReason(t *testing.T) {
}
}
// 模型、权限、预算等其它准入失败保持原口径:不可自动重试,交给用户。
func TestCloudAgentMediaAdmissionErrorStaysTerminalForOtherFailures(t *testing.T) {
wrapped := cloudAgentWrapMediaAdmissionError(BadAuthRequest("已超过本轮视频时长预算"))
var admissionErr *cloudAgentMediaAdmissionError

View File

@@ -26,7 +26,8 @@ func cloudAgentCanonicalFor(system string, history []providerTextMessage, prompt
const (
cloudAgentPromptCacheSchemaVersion = "cloud-agent-prompt-cache/v2"
cloudAgentToolSchemaVersion = "cloud-agent-tools/v4"
cloudAgentToolSchemaVersion = "cloud-agent-tools/v5"
cloudAgentMediaSizeDescription = "按本回合 model_list 返回的 imageSize/image.size.parameter 传值:parameter=size 时使用支持值,只有 allowCustom=true 才能使用宽x高;parameter=aspect_ratio 时使用 W:H;不要把冒号或比例值传给像素尺寸模型"
)
// cloudAgentPromptCacheIdentity deliberately excludes the canvas payload and its
@@ -209,7 +210,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
if req.PermissionMode != "read_only" && len(req.ContextScope) > 0 {
add("image_layer_split", "将图片按用户指定对象拆分为独立透明图层。参数与 generate_media 的图片生成参数一致,但 mode 固定为 image;所有权限模式都会先创建草稿并进入界面独立审批,用户批准后才提交生成任务。", map[string]any{
"prompt": str("需要拆分的对象与透明背景要求"), "logicalModelId": str("selection.logicalModelId"), "channelId": str("selection.channelId"), "channelModelKey": str("selection.channelModelKey"),
"quality": str("模型支持的质量档位"), "snapshotHash": str("最近画布读取返回的 mediaSnapshotHash,可省略"), "nodeId": str("新的结果节点ID"), "title": str("结果节点名称"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("源图片节点ID")},
"size": str(cloudAgentMediaSizeDescription), "quality": str("模型支持的质量档位"), "snapshotHash": str("最近画布读取返回的 mediaSnapshotHash,可省略"), "nodeId": str("新的结果节点ID"), "title": str("结果节点名称"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("源图片节点ID")},
}, "prompt", "nodeId", "title", "referenceNodeIds")
cloudAgentRequireExplicitMediaModelSelection(tools[len(tools)-1])
add("model_list", "读取当前生效的生成模型目录、能力与价格档。生成前传 mode 和本次实际 referenceNodeIds,服务端按真实素材类型、数量和生成操作筛选匹配模型;空列表表示无匹配项,不得退回不匹配模型。相同条件直接复用,变化后再查。复制 selection 到 generate_media,不猜ID或混用模型选择;再按返回的能力配置核对时长、画幅、音频和价格。", map[string]any{"mode": map[string]any{"type": "string", "enum": cloudAgentGenerationModeNames()}, "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("本次实际使用的画布媒体参考节点ID;文生媒体传空数组")}})
@@ -287,7 +288,7 @@ func compileCloudAgentTools(req CloudAgentRequest, includeProfileTool bool) []ma
add("generate_media", "提交媒体生成:先准备草稿和引用;所有权限模式均经模型/能力/价格/预算/资源校验和界面独立审批,用户批准后才提交。编辑节点或连线用 canvas_apply_ops;先读画布与模型。已有任务或产物不可覆盖,状态用 generation/task_get。sourceNodeId 为文本,referenceNodeIds 为媒体;角色卡作为参考同时采用设定与三视图,仅取设定则用 sourceNodeId,不要重复指定。临时引用只接受标注工具ID,不接受URL。参数取本回合 model_list;失败先改参数,勿重提或重读。失败须告知用户,重试需明确要求并重新审批。", map[string]any{
"mode": map[string]any{"type": "string", "enum": cloudAgentGenerationModeNames()}, "prompt": str("完整生成提示词;引用素材时在对应描述中使用 @图片1、@视频1、@音频1,各类型按 referenceNodeIds 中出现顺序独立编号,文本来源不占媒体编号。服务端会为遗漏的已选素材补齐引用标签,不推断素材用途"),
"logicalModelId": str("selection.logicalModelId;与channelId/channelModelKey互斥"), "channelId": str("selection.channelId"), "channelModelKey": str("selection.channelModelKey"),
"durationSeconds": map[string]any{"type": "integer", "minimum": 0}, "size": str("模型支持的画幅,例如9:16"), "quality": str("目录支持的分辨率或质量"), "videoGenerateAudio": map[string]any{"type": "boolean", "description": "是否生成音频,仅视频可用"},
"durationSeconds": map[string]any{"type": "integer", "minimum": 0}, "size": str(cloudAgentMediaSizeDescription), "quality": str("按本回合 model_list 返回的 image.quality 或 video.resolutions 取值;视频输出分辨率保持目录返回的大小写"), "videoGenerateAudio": map[string]any{"type": "boolean", "description": "是否生成音频,仅视频可用"},
"videoStartFrameNodeId": str("视频首帧图片ID,须在 referenceNodeIds 中"),
"videoEndFrameNodeId": str("视频尾帧图片ID,须在 referenceNodeIds 中"),
"snapshotHash": str("可省略:省略时用当前画布内容快照"), "nodeId": str("可续用的未提交媒体草稿ID;无草稿时才使用新唯一ID"), "title": str("媒体节点名称"), "sourceNodeId": str("仅文本/镜头提示词节点ID;不要填媒体节点"), "referenceNodeIds": map[string]any{"type": "array", "maxItems": 16, "items": str("画布媒体参考节点ID,按引用顺序")}, "referenceTransientIds": map[string]any{"type": "array", "maxItems": 4, "items": str("由 image_annotation_render 返回的临时参考图ID")},

View File

@@ -0,0 +1,67 @@
package app
import "errors"
// modelCapabilityArgumentError preserves the machine-readable field that caused
// a capability rejection while keeping the normal 400 AppError projection for
// non-Agent task callers.
type modelCapabilityArgumentError struct {
*AppError
Field string
Issue string
}
func (e *modelCapabilityArgumentError) Unwrap() error {
if e == nil {
return nil
}
return e.AppError
}
func newModelCapabilityArgumentError(field, issue string, cause error) error {
if cause == nil {
cause = BadAuthRequest("模型能力参数无效")
}
return &modelCapabilityArgumentError{AppError: BadAuthRequest(cause.Error()), Field: field, Issue: issue}
}
func modelCapabilityMismatchError(match CapabilityMatch) error {
details := map[string]any{"capabilityIssues": match.Issues}
for _, issue := range match.Issues {
if issue.Kind != "option" {
continue
}
field := issue.Name
if field == "vquality" {
field = "quality"
}
details["field"] = field
details["issue"] = "unsupported_value"
details["message"] = issue.Reason
break
}
return NewModelError(ErrCodeModelCapabilityNotSupported, "所选模型不支持当前请求").WithDetails(details)
}
func cloudAgentMediaCapabilityArgumentError(err error) error {
if err == nil {
return nil
}
var argumentErr *modelCapabilityArgumentError
if errors.As(err, &argumentErr) && argumentErr != nil && argumentErr.Field != "" {
return cloudAgentFieldError(argumentErr.Field, argumentErr.Issue, argumentErr.Error())
}
var modelErr *ModelError
if errors.As(err, &modelErr) && modelErr != nil {
field, _ := modelErr.Details["field"].(string)
issue, _ := modelErr.Details["issue"].(string)
if field != "" && issue != "" {
message, _ := modelErr.Details["message"].(string)
if message == "" {
message = modelErr.Error()
}
return cloudAgentFieldError(field, issue, message+";请按本回合 model_list 返回的能力参数修正后重试")
}
}
return nil
}

View File

@@ -329,44 +329,44 @@ func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInp
return err
}
if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios {
return BadAuthRequest("参考素材数量超过当前模型限制")
return newModelCapabilityArgumentError("referenceNodeIds", "item_count", BadAuthRequest("参考素材数量超过当前模型限制"))
}
if model.IsVolcengineArkVideoProtocol(model.ChannelInterfaceType(input.Config.InterfaceType)) && len(input.ReferenceAudios) > 0 && len(input.ReferenceImages) == 0 && len(input.ReferenceVideos) == 0 {
return BadAuthRequest("火山方舟全模态参考不支持纯音频或文本+音频,请同时添加参考图片或参考视频")
return newModelCapabilityArgumentError("referenceNodeIds", "invalid_combination", BadAuthRequest("火山方舟全模态参考不支持纯音频或文本+音频,请同时添加参考图片或参考视频"))
}
if len(input.ReferenceImages) < profile.References.MinImages {
return BadAuthRequest(fmt.Sprintf("当前视频模型至少需要 %d 张参考图", profile.References.MinImages))
return newModelCapabilityArgumentError("referenceNodeIds", "item_count", BadAuthRequest(fmt.Sprintf("当前视频模型至少需要 %d 张参考图", profile.References.MinImages)))
}
for _, media := range input.ReferenceImages {
if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes {
return BadAuthRequest("参考图片文件超过当前模型大小限制")
return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考图片文件超过当前模型大小限制"))
}
}
for _, media := range input.ReferenceVideos {
if profile.References.MaxVideoBytes > 0 && media.Bytes > profile.References.MaxVideoBytes {
return BadAuthRequest("参考视频文件超过当前模型大小限制")
return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考视频文件超过当前模型大小限制"))
}
if profile.References.MaxVideoDuration > 0 && media.DurationMs > int64(profile.References.MaxVideoDuration)*1000 {
return BadAuthRequest("参考视频时长超过当前模型限制")
return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考视频时长超过当前模型限制"))
}
}
for _, media := range input.ReferenceAudios {
if profile.References.MaxAudioBytes > 0 && media.Bytes > profile.References.MaxAudioBytes {
return BadAuthRequest("参考音频文件超过当前模型大小限制")
return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考音频文件超过当前模型大小限制"))
}
if profile.References.MaxAudioDuration > 0 && media.DurationMs > int64(profile.References.MaxAudioDuration)*1000 {
return BadAuthRequest("参考音频时长超过当前模型限制")
return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考音频时长超过当前模型限制"))
}
}
seconds, err := strconv.Atoi(strings.TrimSpace(input.Config.VideoSeconds))
if err != nil || !videoDurationAllowed(profile.Duration, seconds) {
return BadAuthRequest("视频时长不在当前模型支持范围内")
return newModelCapabilityArgumentError("durationSeconds", "unsupported_value", BadAuthRequest("视频时长不在当前模型支持范围内"))
}
if input.Config.Size != "" && !videoRatioAllowed(profile.Ratios, input.Config.Size) {
return BadAuthRequest("画面比例不在当前模型支持范围内")
return newModelCapabilityArgumentError("size", "unsupported_value", BadAuthRequest("画面比例不在当前模型支持范围内"))
}
if len(profile.Resolutions) > 0 && !isAutomaticVideoResolution(input.Config.VQuality) && videoResolutionNameRequest(profile, input.Config.VQuality) == "" {
return BadAuthRequest("输出分辨率不在当前模型支持范围内")
return newModelCapabilityArgumentError("quality", "unsupported_value", BadAuthRequest("输出分辨率不在当前模型支持范围内"))
}
operation := metadataString(input.Metadata, "videoEditOperation")
if operation == "" {
@@ -377,7 +377,7 @@ func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInp
}
}
if !containsCapabilityString(profile.Operations, operation) {
return BadAuthRequest("当前视频模型不支持该生成模式")
return newModelCapabilityArgumentError("logicalModelId", "unsupported_value", BadAuthRequest("当前视频模型不支持该生成模式"))
}
return nil
}
@@ -400,47 +400,46 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp
}
}
if len(input.ReferenceImages) > profile.References.MaxImages {
return BadAuthRequest(fmt.Sprintf("当前图片模型最多支持 %d 张参考图", profile.References.MaxImages))
return newModelCapabilityArgumentError("referenceNodeIds", "item_count", BadAuthRequest(fmt.Sprintf("当前图片模型最多支持 %d 张参考图", profile.References.MaxImages)))
}
for _, media := range input.ReferenceImages {
if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes {
return BadAuthRequest("参考图片文件超过当前模型大小限制")
return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考图片文件超过当前模型大小限制"))
}
}
if input.Mask != nil && !profile.References.MaskSupported {
return BadAuthRequest("当前图片模型不支持蒙版编辑")
return newModelCapabilityArgumentError("logicalModelId", "unsupported_value", BadAuthRequest("当前图片模型不支持蒙版编辑"))
}
quality := strings.TrimSpace(input.Config.Quality)
resolution := strings.TrimSpace(input.Config.ImageResolution)
if profile.Resolution.Supported && resolution != "" && !strings.EqualFold(resolution, "auto") && !strings.EqualFold(resolution, "any") && !containsCapabilityString(profile.Resolution.Values, resolution) {
return BadAuthRequest("图片分辨率不在当前模型支持范围内")
return newModelCapabilityArgumentError("imageResolution", "unsupported_value", BadAuthRequest("图片分辨率不在当前模型支持范围内"))
}
selectionTier := quality
if profile.Resolution.Supported {
selectionTier = resolution
}
// 比例型线路会在任务输入边界把画布比例换算成 preset 像素值(OpenAI Images 只收
// WIDTHxHEIGHT)。换算结果必须继续通过同一套尺寸与分辨率校验:先把已声明的 preset
// 像素值还原成对应比例,否则执行前的 provider 预检会把刚换算好的值判成不支持的尺寸。
// WIDTHxHEIGHT)。换算结果必须继续通过同一套尺寸与分辨率校验。
resolvedRatio := imageSizeRatioForDeclaredPreset(profile, input.Config.Size, qualityForImageValidation(selectionTier, profile.Resolution.Default))
if profile.Size.Parameter != "none" && !profile.Size.AllowCustom && strings.TrimSpace(input.Config.Size) != "" &&
!containsCapabilityString(profile.Size.Values, input.Config.Size) && resolvedRatio == "" {
return BadAuthRequest("图片尺寸不在当前模型支持范围内")
return newModelCapabilityArgumentError("size", "unsupported_value", BadAuthRequest("图片尺寸不在当前模型支持范围内"))
}
if profile.Parameters == nil && profile.Size.Parameter == "size" && profile.Size.AllowCustom && strings.HasPrefix(modelName, "gpt-image-2") && !containsCapabilityString(profile.Size.Values, input.Config.Size) {
if err := validateGPTImage2CustomSize(input.Config.Size); err != nil {
return BadAuthRequest(err.Error())
return newModelCapabilityArgumentError("size", "unsupported_value", err)
}
}
if profile.Quality.Supported && quality != "" && !strings.EqualFold(quality, "auto") && !strings.EqualFold(quality, "any") && !containsCapabilityString(profile.Quality.Values, quality) {
return BadAuthRequest("图片质量不在当前模型支持范围内")
return newModelCapabilityArgumentError("quality", "unsupported_value", BadAuthRequest("图片质量不在当前模型支持范围内"))
}
if err := validateImagePresetSelection(profile, qualityForImageValidation(selectionTier, firstNonEmpty(profile.Resolution.Default, profile.Quality.Default)), firstNonEmpty(resolvedRatio, input.Config.Size, profile.Size.Default)); err != nil {
return err
}
count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count))
if err == nil && count > profile.MaxOutputs {
return BadAuthRequest(fmt.Sprintf("当前图片模型单次最多生成 %d 张", profile.MaxOutputs))
return newModelCapabilityArgumentError("count", "unsupported_value", BadAuthRequest(fmt.Sprintf("当前图片模型单次最多生成 %d 张", profile.MaxOutputs)))
}
return nil
}
@@ -488,7 +487,7 @@ func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio
return nil
}
}
return BadAuthRequest("当前分辨率不支持所选图片宽高比")
return newModelCapabilityArgumentError("size", "unsupported_value", BadAuthRequest("当前分辨率不支持所选图片宽高比"))
}
// imageSizeRatioForDeclaredPreset 把已声明的 preset 像素值还原成它对应的比例。比例型线路

View File

@@ -207,7 +207,7 @@ func (s *Service) ResolveLogicalModel(logicalModelID string, intent ModelRequest
}
}
if match := MatchCapability(cached.ProductSpec, intent); !match.Matched {
return nil, BadAuthRequest("所选模型不支持当前请求:" + strings.Join(match.Reasons, ";"))
return nil, modelCapabilityMismatchError(match)
}
eligible := s.eligibleLogicalRoutes(cached.Routes, intent, nil, cached.Model.PricePolicy == "channel")
if len(eligible) == 0 {

View File

@@ -151,9 +151,16 @@ func normalizeModelRequestOption(name string, value any) any {
}
}
type CapabilityIssue struct {
Kind string `json:"kind"`
Name string `json:"name"`
Reason string `json:"reason"`
}
type CapabilityMatch struct {
Matched bool `json:"matched"`
Reasons []string `json:"reasons,omitempty"`
Matched bool `json:"matched"`
Reasons []string `json:"reasons,omitempty"`
Issues []CapabilityIssue `json:"issues,omitempty"`
}
type cachedLogicalModel struct {
@@ -292,6 +299,11 @@ func decodeLogicalDefaults(raw string, spec CapabilitySpec) (map[string]any, err
func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityMatch {
reasons := make([]string, 0)
issues := make([]CapabilityIssue, 0)
addIssue := func(kind, name, reason string) {
reasons = append(reasons, reason)
issues = append(issues, CapabilityIssue{Kind: kind, Name: name, Reason: reason})
}
if spec.Capability == "image" && spec.ImageParameters != nil {
values := make(map[string]any, len(intent.Options))
for name, value := range intent.Options {
@@ -313,51 +325,51 @@ func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityM
}
}
if _, err := resolveImageParameterValues(parameters, values); err != nil {
reasons = append(reasons, err.Error())
addIssue("image_parameter", "imageParameters", err.Error())
}
}
if normalizeCapability(intent.Capability) != normalizeCapability(spec.Capability) {
reasons = append(reasons, "能力类型不匹配")
addIssue("capability", "capability", "能力类型不匹配")
}
if operation := normalizeCapabilityValue(intent.Operation); operation != "" && len(spec.Operations) > 0 && !containsNormalized(spec.Operations, operation) {
reasons = append(reasons, "不支持操作 "+intent.Operation)
addIssue("operation", "operation", "不支持操作 "+intent.Operation)
}
for inputType, count := range intent.Inputs {
if count < 0 {
reasons = append(reasons, "输入数量不能小于 0")
addIssue("input", inputType, "输入数量不能小于 0")
continue
}
constraint, declared := spec.Inputs[inputType]
if !declared {
if count > 0 {
reasons = append(reasons, "不支持 "+capabilityInputLabel(inputType)+"输入")
addIssue("input", inputType, "不支持 "+capabilityInputLabel(inputType)+"输入")
}
continue
}
if count < constraint.Min || count > constraint.Max {
reasons = append(reasons, fmt.Sprintf("%s数量需在 %d-%d 之间", capabilityInputLabel(inputType), constraint.Min, constraint.Max))
addIssue("input", inputType, fmt.Sprintf("%s数量需在 %d-%d 之间", capabilityInputLabel(inputType), constraint.Min, constraint.Max))
}
}
for inputType, constraint := range spec.Inputs {
if intent.Inputs[inputType] < constraint.Min {
reasons = append(reasons, fmt.Sprintf("至少需要 %d 个%s", constraint.Min, capabilityInputLabel(inputType)))
addIssue("input", inputType, fmt.Sprintf("至少需要 %d 个%s", constraint.Min, capabilityInputLabel(inputType)))
}
}
for name, value := range intent.Options {
canonicalName := canonicalModelOptionName(spec.Capability, name)
if inactiveCapabilityOptionValue(canonicalName, value) {
canonical := canonicalModelOptionName(spec.Capability, name)
if inactiveCapabilityOptionValue(canonical, value) {
continue
}
constraint, declared := spec.Options[canonicalName]
constraint, declared := spec.Options[canonical]
if !declared {
reasons = append(reasons, "不支持参数 "+capabilityOptionLabel(name))
addIssue("option", canonical, "不支持参数 "+capabilityOptionLabel(name))
continue
}
if !matchOptionConstraint(name, constraint, value) {
reasons = append(reasons, "参数 "+capabilityOptionLabel(name)+"超出支持范围")
addIssue("option", canonical, "参数 "+capabilityOptionLabel(name)+"超出支持范围")
}
}
return CapabilityMatch{Matched: len(reasons) == 0, Reasons: reasons}
return CapabilityMatch{Matched: len(reasons) == 0, Reasons: reasons, Issues: issues}
}
// Image size intent and provider encoding share explicit preset mappings.

View File

@@ -578,7 +578,7 @@ func (s *Service) resolveSystemChannelModelSelection(input map[string]any, taskT
return input, InvalidModelSelection("指定的模型能力配置无效,请联系管理员")
}
if match := MatchCapability(*capabilitySpec, intent); !match.Matched {
return input, ModelCapabilityNotSupported("所选模型不支持当前请求:" + strings.Join(match.Reasons, ";"))
return input, modelCapabilityMismatchError(match)
}
}