From 46cdb1a2880a93adcb7f19f0b5757c1dfccd962d Mon Sep 17 00:00:00 2001 From: wangbo Date: Thu, 23 Jul 2026 11:07:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E7=BC=BA=E5=A4=B1=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E8=83=BD=E5=8A=9B=E9=85=8D=E7=BD=AE=E6=97=B6=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/internal/runner/output_token_limit.go | 87 ++++++++++++------- .../runner/output_token_limit_test.go | 78 ++++++++++++++--- 2 files changed, 123 insertions(+), 42 deletions(-) diff --git a/apps/api/internal/runner/output_token_limit.go b/apps/api/internal/runner/output_token_limit.go index 329e546..3825fab 100644 --- a/apps/api/internal/runner/output_token_limit.go +++ b/apps/api/internal/runner/output_token_limit.go @@ -10,7 +10,6 @@ import ( const ( volcesOutputTokenThreshold = 10240 - volcesOutputCapabilityErrorCode = "model_capability_configuration_error" volcesOutputInvalidParameterCode = "invalid_parameter" ) @@ -25,20 +24,25 @@ func (outputTokenLimitProcessor) ShouldProcess(_ map[string]any, modelType strin func (outputTokenLimitProcessor) Process(params map[string]any, modelType string, context *paramProcessContext) bool { modelMax, sourceType, capabilityValue, ok := candidateMaxOutputTokens(context.candidate, modelType) path := capabilityPath(sourceType, "max_output_tokens") + if parameter, value, explicit := explicitOutputTokenParameter(context.kind, params); explicit { + if _, valid := positiveInteger(value); !valid { + return context.reject( + "OutputTokenLimitProcessor", + parameter, + value, + fmt.Sprintf("%s must be a positive integer", parameter), + path, + capabilityValue, + ) + } + } if !ok { - return context.reject( - "OutputTokenLimitProcessor", - outputTokenParameter(context.kind), - nil, - "火山引擎文本候选未配置正整数 max_output_tokens,已禁止执行该候选。", - path, - capabilityValue, - ) + return true } if context.kind == "chat.completions" { if value, explicit := nonNullParameter(params, "max_tokens"); explicit { - if parsed, valid := positiveInteger(value); !valid || parsed > modelMax { + if parsed, _ := positiveInteger(value); parsed > modelMax { return context.reject("OutputTokenLimitProcessor", "max_tokens", value, fmt.Sprintf("max_tokens must be a positive integer no greater than the selected Volcengine model limit (%d)", modelMax), path, capabilityValue) } return true @@ -57,7 +61,7 @@ func (outputTokenLimitProcessor) Process(params map[string]any, modelType string } if value, explicit := nonNullParameter(params, "max_output_tokens"); explicit { - if parsed, valid := positiveInteger(value); !valid || parsed > modelMax { + if parsed, _ := positiveInteger(value); parsed > modelMax { return context.reject("OutputTokenLimitProcessor", "max_output_tokens", value, fmt.Sprintf("max_output_tokens must be a positive integer no greater than the selected Volcengine model limit (%d)", modelMax), path, capabilityValue) } return true @@ -76,8 +80,35 @@ func filterRuntimeCandidatesByOutputTokens(kind string, requestedModel string, m if !isOpenAITextGenerationKind(kind) || !isTextOutputModelType(modelType) || len(candidates) == 0 { return candidates, nil, nil } + hasVolcesCandidate := false + for _, candidate := range candidates { + if isVolcesCandidate(candidate) { + hasVolcesCandidate = true + break + } + } + if !hasVolcesCandidate { + return candidates, nil, nil + } + explicitParameter, explicitValue, hasExplicitParameter := explicitOutputTokenParameter(kind, body) + explicitTokens := 0 + if hasExplicitParameter { + var valid bool + explicitTokens, valid = positiveInteger(explicitValue) + if !valid { + summary := map[string]any{ + "filter": "volces_output_token_limit", "kind": kind, "requestedModel": requestedModel, "modelType": modelType, + "candidateCount": len(candidates), "supportedCandidateCount": 0, "filteredCandidateCount": len(candidates), + "code": volcesOutputInvalidParameterCode, "reason": "invalid_output_token_parameter", + "parameter": explicitParameter, "requestedValue": explicitValue, + } + message := fmt.Sprintf("%s must be a positive integer", explicitParameter) + return nil, summary, &store.ModelCandidateUnavailableError{Code: volcesOutputInvalidParameterCode, Message: message, Details: summary} + } + } filtered := make([]store.RuntimeModelCandidate, 0, len(candidates)) rejected := make([]map[string]any, 0) + missingCapability := make([]map[string]any, 0) invalidExplicit := false for _, candidate := range candidates { if !isVolcesCandidate(candidate) { @@ -92,15 +123,15 @@ func filterRuntimeCandidatesByOutputTokens(kind string, requestedModel string, m } if !configured { detail["reason"] = "max_output_tokens_missing" - rejected = append(rejected, detail) + missingCapability = append(missingCapability, detail) + filtered = append(filtered, candidate) continue } - if parameter, value, explicit := explicitOutputTokenParameter(kind, body); explicit { - parsed, valid := positiveInteger(value) - if !valid || (parameter != "max_completion_tokens" && parsed > modelMax) { + if hasExplicitParameter { + if explicitParameter != "max_completion_tokens" && explicitTokens > modelMax { detail["reason"] = "requested_output_tokens_exceed_capability" - detail["parameter"] = parameter - detail["requestedValue"] = value + detail["parameter"] = explicitParameter + detail["requestedValue"] = explicitValue invalidExplicit = true rejected = append(rejected, detail) continue @@ -108,26 +139,25 @@ func filterRuntimeCandidatesByOutputTokens(kind string, requestedModel string, m } filtered = append(filtered, candidate) } - if len(rejected) == 0 { + if len(rejected) == 0 && len(missingCapability) == 0 { return filtered, nil, nil } summary := map[string]any{ "filter": "volces_output_token_limit", "kind": kind, "requestedModel": requestedModel, "modelType": modelType, "candidateCount": len(candidates), "supportedCandidateCount": len(filtered), "filteredCandidateCount": len(rejected), + "missingCapabilityCandidateCount": len(missingCapability), "passthroughMissingCapability": len(missingCapability) > 0, "threshold": volcesOutputTokenThreshold, "formula": "third=floor(modelMaxOutputTokens/3); default=third>=10240?third:modelMaxOutputTokens", - "rejectedCandidates": rejected, + "rejectedCandidates": rejected, "missingCapabilityCandidates": missingCapability, } if len(filtered) > 0 { return filtered, summary, nil } - code := volcesOutputCapabilityErrorCode - message := "所有火山引擎文本候选都缺少有效的 max_output_tokens 能力配置" if invalidExplicit { - code = volcesOutputInvalidParameterCode - message = "请求的输出 token 上限超过所有可用火山引擎候选的模型能力" + message := "请求的输出 token 上限超过所有可用火山引擎候选的模型能力" + summary["code"] = volcesOutputInvalidParameterCode + return nil, summary, &store.ModelCandidateUnavailableError{Code: volcesOutputInvalidParameterCode, Message: message, Details: summary} } - summary["code"] = code - return nil, summary, &store.ModelCandidateUnavailableError{Code: code, Message: message, Details: summary} + return filtered, summary, nil } func defaultVolcesOutputTokens(modelMax int) int { @@ -195,13 +225,6 @@ func explicitOutputTokenParameter(kind string, body map[string]any) (string, any return "max_completion_tokens", value, ok } -func outputTokenParameter(kind string) string { - if kind == "responses" { - return "max_output_tokens" - } - return "max_tokens" -} - func isOpenAITextGenerationKind(kind string) bool { return kind == "chat.completions" || kind == "responses" } diff --git a/apps/api/internal/runner/output_token_limit_test.go b/apps/api/internal/runner/output_token_limit_test.go index 37262d4..b8fcd49 100644 --- a/apps/api/internal/runner/output_token_limit_test.go +++ b/apps/api/internal/runner/output_token_limit_test.go @@ -72,32 +72,90 @@ func TestVolcesOutputProcessorPreservesExplicitLimits(t *testing.T) { } } -func TestVolcesOutputCandidateFilterSupportsFailover(t *testing.T) { +func TestVolcesOutputCandidateFilterPreservesMissingCapabilityOrder(t *testing.T) { missing := volcTextCandidate(nil) missing.PlatformID = "missing" valid := volcTextCandidate(32768) valid.PlatformID = "valid" filtered, summary, err := filterRuntimeCandidatesByOutputTokens("chat.completions", "demo", "text_generate", map[string]any{"messages": []any{}}, []store.RuntimeModelCandidate{missing, valid}) - if err != nil || len(filtered) != 1 || filtered[0].PlatformID != "valid" { - t.Fatalf("expected missing capability candidate to be skipped: filtered=%+v summary=%+v err=%v", filtered, summary, err) + if err != nil || len(filtered) != 2 || filtered[0].PlatformID != "missing" || filtered[1].PlatformID != "valid" { + t.Fatalf("expected missing capability candidate order to be preserved: filtered=%+v summary=%+v err=%v", filtered, summary, err) } - result := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}}, filtered[0]) + if summary["missingCapabilityCandidateCount"] != 1 || summary["passthroughMissingCapability"] != true { + t.Fatalf("expected missing capability audit summary, got %+v", summary) + } + missingResult := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}}, filtered[0]) + if missingResult.Err != nil || len(missingResult.Log.Changes) != 0 { + t.Fatalf("missing capability candidate must pass through unchanged: %+v", missingResult) + } + if _, ok := missingResult.Body["max_tokens"]; ok { + t.Fatalf("missing capability candidate must not inject max_tokens: %+v", missingResult.Body) + } + result := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}}, filtered[1]) if result.Body["max_tokens"] != 10922 { - t.Fatalf("failover candidate must recalculate its own default, got %+v", result.Body) + t.Fatalf("configured candidate must calculate its own default, got %+v", result.Body) } } -func TestVolcesOutputCandidateFilterRejectsMissingAndExceededCapabilities(t *testing.T) { - _, _, err := filterRuntimeCandidatesByOutputTokens("responses", "demo", "text_generate", map[string]any{"input": "hello"}, []store.RuntimeModelCandidate{volcTextCandidate(nil)}) - if store.ModelCandidateErrorCode(err) != volcesOutputCapabilityErrorCode { - t.Fatalf("expected capability configuration error, got %v", err) +func TestVolcesOutputCandidateFilterPassesThroughMissingCapability(t *testing.T) { + filtered, summary, err := filterRuntimeCandidatesByOutputTokens("responses", "demo", "text_generate", map[string]any{"input": "hello"}, []store.RuntimeModelCandidate{volcTextCandidate(nil)}) + if err != nil || len(filtered) != 1 { + t.Fatalf("expected missing capability candidate to remain available: filtered=%+v summary=%+v err=%v", filtered, summary, err) } - _, _, err = filterRuntimeCandidatesByOutputTokens("chat.completions", "demo", "text_generate", map[string]any{"messages": []any{}, "max_tokens": 32769}, []store.RuntimeModelCandidate{volcTextCandidate(32768)}) + result := preprocessRequestWithLog("responses", map[string]any{"input": "hello"}, filtered[0]) + if result.Err != nil { + t.Fatalf("missing capability preprocessing must pass through: %v", result.Err) + } + if _, ok := result.Body["max_output_tokens"]; ok { + t.Fatalf("missing capability preprocessing must not inject max_output_tokens: %+v", result.Body) + } +} + +func TestVolcesOutputCandidateFilterRejectsExceededKnownCapability(t *testing.T) { + _, _, err := filterRuntimeCandidatesByOutputTokens("chat.completions", "demo", "text_generate", map[string]any{"messages": []any{}, "max_tokens": 32769}, []store.RuntimeModelCandidate{volcTextCandidate(32768)}) if store.ModelCandidateErrorCode(err) != volcesOutputInvalidParameterCode { t.Fatalf("expected invalid_parameter for explicit limit, got %v", err) } } +func TestVolcesOutputCandidateFilterAllowsUnknownCapabilityForExplicitLimit(t *testing.T) { + missing := volcTextCandidate(nil) + missing.PlatformID = "missing" + configured := volcTextCandidate(32768) + configured.PlatformID = "configured" + filtered, summary, err := filterRuntimeCandidatesByOutputTokens( + "chat.completions", + "demo", + "text_generate", + map[string]any{"messages": []any{}, "max_tokens": 65536}, + []store.RuntimeModelCandidate{missing, configured}, + ) + if err != nil || len(filtered) != 1 || filtered[0].PlatformID != "missing" { + t.Fatalf("expected unknown capability candidate to remain available: filtered=%+v summary=%+v err=%v", filtered, summary, err) + } +} + +func TestVolcesOutputProcessorValidatesExplicitLimitWithoutCapability(t *testing.T) { + valid := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}, "max_tokens": 2048}, volcTextCandidate(nil)) + if valid.Err != nil || valid.Body["max_tokens"] != 2048 { + t.Fatalf("valid explicit limit must pass through: %+v", valid) + } + invalid := preprocessRequestWithLog("chat.completions", map[string]any{"messages": []any{}, "max_tokens": 0}, volcTextCandidate(nil)) + if invalid.Err == nil { + t.Fatalf("invalid explicit limit must still be rejected: %+v", invalid) + } + _, _, err := filterRuntimeCandidatesByOutputTokens( + "chat.completions", + "demo", + "text_generate", + map[string]any{"messages": []any{}, "max_tokens": 0}, + []store.RuntimeModelCandidate{volcTextCandidate(nil)}, + ) + if store.ModelCandidateErrorCode(err) != volcesOutputInvalidParameterCode { + t.Fatalf("candidate filter must reject an invalid explicit limit, got %v", err) + } +} + func TestNonVolcesOutputProcessorDoesNotInject(t *testing.T) { candidate := volcTextCandidate(131072) candidate.Provider = "openai"