diff --git a/internal/dialect/anthropic.go b/internal/dialect/anthropic.go index 2df8c6f4..69b4588f 100644 --- a/internal/dialect/anthropic.go +++ b/internal/dialect/anthropic.go @@ -1,6 +1,8 @@ package dialect import ( + "bytes" + "encoding/json" "fmt" "gpt-load/internal/execution" @@ -19,6 +21,22 @@ func (*Anthropic) Protocol() protocol.Protocol { return protocol.Anthropic } +// AnthropicRequestsZeroOutput 判断请求是否显式要求零输出,不把缺失、null 或字符串视为数值零。 +func AnthropicRequestsZeroOutput(body []byte) bool { + var request struct { + MaxTokens json.RawMessage `json:"max_tokens"` + } + if err := json.Unmarshal(body, &request); err != nil { + return false + } + mantissa := request.MaxTokens + if index := bytes.IndexAny(mantissa, "eE"); index >= 0 { + mantissa = mantissa[:index] + } + // 按有效数字判断,避免极小的非零数值经浮点下溢被误判为零。 + return bytes.IndexByte(mantissa, '0') >= 0 && len(bytes.Trim(mantissa, "-0.")) == 0 +} + func (d *Anthropic) InspectRequest(req *ParsedRequest) (RequestMetadata, error) { if req == nil { return RequestMetadata{}, fmt.Errorf("parsed request is required") diff --git a/internal/execution/bifrost/executor.go b/internal/execution/bifrost/executor.go index ed723205..dc385e68 100644 --- a/internal/execution/bifrost/executor.go +++ b/internal/execution/bifrost/executor.go @@ -19,6 +19,7 @@ import ( "github.com/maximhq/bifrost/core/schemas" "gpt-load/internal/channel" + "gpt-load/internal/dialect" "gpt-load/internal/execution" "gpt-load/internal/protocol" "gpt-load/internal/reasoning" @@ -456,6 +457,16 @@ func (r *Runtime) prepare(spec execution.AttemptSpec, stream bool) (preparedAtte return preparedAttempt{}, &failure } safeQuery := safeAttemptQuery(spec) + if mode == channel.RouteConverted { + // 源协议的调用标记和响应格式不能继承到其他协议的目标 URL。 + switch spec.ClientProtocol { + case protocol.Anthropic: + safeQuery = removeRawQueryValue(safeQuery, "beta") + case protocol.Gemini: + safeQuery = removeRawQueryValue(safeQuery, "alt") + safeQuery = removeRawQueryValue(safeQuery, "$alt") + } + } if mode == channel.RouteNative && spec.ClientProtocol == protocol.Gemini && (providerKind == channel.ProviderGemini || providerKind == channel.ProviderGoogleVertex || providerKind == channel.ProviderMultiProtocolGateway) { @@ -659,6 +670,16 @@ func (r *Runtime) prepare(spec execution.AttemptSpec, stream bool) (preparedAtte secrets: secrets, }, nil } + if spec.ClientProtocol == protocol.Anthropic && spec.Operation == execution.OperationChatCompletion && + dialect.AnthropicRequestsZeroOutput(spec.Body) { + failure := notSentConversionFailure( + execution.ErrorCodeCriticalSemanticLoss, + "typed conversion cannot preserve Anthropic zero-output semantics", + ) + failure.Error.OriginHint = execution.ErrorOriginInternal + failure.Error.ScopeHint = execution.ErrorScopeGroup + return preparedAttempt{}, &failure + } if spec.Operation == execution.OperationListModels { typedURL, upstreamProtocol, targetErr := convertedListModelsTarget(providerKind, customTargetBaseURL, safeQuery) if targetErr != nil { diff --git a/internal/execution/bifrost/query_test.go b/internal/execution/bifrost/query_test.go new file mode 100644 index 00000000..d0fc876a --- /dev/null +++ b/internal/execution/bifrost/query_test.go @@ -0,0 +1,185 @@ +package bifrost + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + "gpt-load/internal/channel" + "gpt-load/internal/execution" + "gpt-load/internal/protocol" +) + +func TestRuntimeNormalizesConvertedProtocolQueries(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + clientProtocol protocol.Protocol + channelID channel.ID + rawQuery string + query url.Values + wantQuery string + }{ + { + name: "Anthropic to Gemini", clientProtocol: protocol.Anthropic, channelID: channel.Gemini, + rawQuery: "beta=true&%62eta=false&trace=%2f&&cursor=next&api_key=removed", + wantQuery: "trace=%2f&&cursor=next", + }, + { + name: "Gemini to Anthropic", clientProtocol: protocol.Gemini, channelID: channel.Anthropic, + rawQuery: "alt=json&$alt=proto&%24alt=sse&trace=%2F&trace=%2f&key=removed", + wantQuery: "trace=%2F&trace=%2f", + }, + { + name: "Gemini query values to OpenAI", clientProtocol: protocol.Gemini, channelID: channel.OpenAI, + query: url.Values{"alt": {"json"}, "$alt": {"sse"}, "trace": {"kept"}, "key": {"removed"}}, + wantQuery: "trace=kept", + }, + { + name: "native Anthropic preserves beta", clientProtocol: protocol.Anthropic, channelID: channel.Anthropic, + rawQuery: "beta=true&trace=%2f&key=removed", wantQuery: "beta=true&trace=%2f", + }, + { + name: "native Gemini retains existing format handling", clientProtocol: protocol.Gemini, channelID: channel.Gemini, + rawQuery: "alt=json&trace=%2f&key=removed", wantQuery: "trace=%2f", + }, + } { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/stream=%t", test.name, stream), func(t *testing.T) { + wantQuery := test.wantQuery + if test.channelID == channel.Gemini && stream { + wantQuery += "&alt=sse" + } + wantPath := "/v1/messages" + responseBody := anthropicResponsesConvertedFixture + if stream { + responseBody = anthropicResponsesStreamFixture + } + switch test.channelID { + case channel.Gemini: + wantPath = "/v1beta/models/upstream-model:generateContent" + responseBody = geminiResponsesConvertedFixture + if stream { + wantPath = "/v1beta/models/upstream-model:streamGenerateContent" + responseBody = geminiResponsesStreamFixture + } + case channel.OpenAI: + wantPath = "/v1/responses" + responseBody = openAIResponsesConvertedFixture + if stream { + responseBody = openAIResponsesStreamFixture + } + } + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != wantPath || request.URL.RawQuery != wantQuery { + t.Errorf("target = %s?%s, want %s?%s", request.URL.Path, request.URL.RawQuery, wantPath, wantQuery) + } + if request.Header.Get("Anthropic-Beta") != "test-feature" { + t.Error("query normalization changed the request header") + } + writer.Header().Set("Content-Type", "application/json") + if stream { + writer.Header().Set("Content-Type", "text/event-stream") + } + _, _ = io.WriteString(writer, responseBody) + })) + defer server.Close() + + runtime := newProtocolTestRuntime(t, testRuntimeOptions{ + allowPrivateNetwork: true, openAIBaseURL: server.URL, + anthropicBaseURL: server.URL, geminiBaseURL: server.URL + "/v1beta", + }) + path := "/v1/messages" + body := []byte(`{"model":"client-model","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`) + if test.clientProtocol == protocol.Gemini { + path = "/v1beta/models/client-model:generateContent" + if stream { + path = "/v1beta/models/client-model:streamGenerateContent" + } + body = []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`) + } + spec := convertedSpec(test.channelID, test.clientProtocol, execution.OperationChatCompletion, path, body) + spec.RawQuery = test.rawQuery + spec.Query = test.query + spec.Header.Set("Anthropic-Beta", "test-feature") + original := spec.Clone() + if stream { + result := runtime.ExecuteStream(context.Background(), spec, func(execution.StreamEvent) error { return nil }) + if result.Error != nil || result.StatusCode != http.StatusOK { + t.Fatalf("stream result = %+v", result) + } + } else { + result := runtime.Execute(context.Background(), spec) + if result.Error != nil || result.StatusCode != http.StatusOK { + t.Fatalf("result = %+v", result) + } + } + if !reflect.DeepEqual(spec, original) { + t.Error("execution mutated the original attempt") + } + }) + } + } +} + +func TestConvertedCountTokensDropsAnthropicBetaQuery(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1beta/models/upstream-model:countTokens" || request.URL.RawQuery != "trace=%2f" { + t.Errorf("count tokens target = %s?%s", request.URL.Path, request.URL.RawQuery) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `{"totalTokens":7}`) + })) + defer server.Close() + runtime := newProtocolTestRuntime(t, testRuntimeOptions{allowPrivateNetwork: true, geminiBaseURL: server.URL + "/v1beta"}) + spec := convertedSpec(channel.Gemini, protocol.Anthropic, execution.OperationCountTokens, + "/v1/messages/count_tokens", []byte(`{"model":"client-model","messages":[{"role":"user","content":"hello"}]}`)) + spec.RawQuery = "beta=true&trace=%2f" + result := runtime.Execute(context.Background(), spec) + if result.Error != nil || result.StatusCode != http.StatusOK { + t.Fatalf("result = %+v", result) + } +} + +func TestConvertedProtocolQueriesDoNotBlockProviderPreparation(t *testing.T) { + t.Parallel() + + for _, target := range []struct { + channelID channel.ID + config json.RawMessage + }{ + {channel.Groq, json.RawMessage(`{}`)}, + {channel.AzureOpenAI, json.RawMessage(`{"endpoint":"https://resource.openai.azure.com"}`)}, + {channel.AWSBedrock, json.RawMessage(`{"region":"us-east-1"}`)}, + } { + for _, clientProtocol := range []protocol.Protocol{protocol.Anthropic, protocol.Gemini} { + t.Run(fmt.Sprintf("%s/%s", target.channelID, clientProtocol), func(t *testing.T) { + runtime := newProtocolTestRuntime(t, testRuntimeOptions{}) + path := "/v1/messages" + body := []byte(`{"model":"client-model","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`) + query := "beta=true" + if clientProtocol == protocol.Gemini { + path = "/v1beta/models/client-model:streamGenerateContent" + body = []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`) + query = "alt=sse&%24alt=json" + } + spec := convertedSpec(target.channelID, clientProtocol, execution.OperationChatCompletion, path, body) + spec.TargetConfig = target.config + spec = freezeTestAttempt(spec) + spec.RawQuery = query + if _, failure := runtime.prepare(spec, true); failure != nil { + t.Fatalf("prepare() error = %+v", failure.Error) + } + }) + } + } +} diff --git a/internal/execution/bifrost/zero_output_test.go b/internal/execution/bifrost/zero_output_test.go new file mode 100644 index 00000000..4a6a1625 --- /dev/null +++ b/internal/execution/bifrost/zero_output_test.go @@ -0,0 +1,120 @@ +package bifrost + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "gpt-load/internal/channel" + "gpt-load/internal/execution" + "gpt-load/internal/protocol" +) + +func TestRuntimeRejectsAnthropicZeroOutputBeforeTypedConversion(t *testing.T) { + t.Parallel() + + for _, channelID := range []channel.ID{channel.Gemini, channel.OpenAI, channel.OpenAICompatible, channel.DeepSeek} { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/stream=%t", channelID, stream), func(t *testing.T) { + var calls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + calls.Add(1) + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(writer, `{"error":{"message":"unexpected generation request"}}`) + })) + defer server.Close() + runtime := newProtocolTestRuntime(t, testRuntimeOptions{ + allowPrivateNetwork: true, geminiBaseURL: server.URL + "/v1beta", openAIBaseURL: server.URL, + }) + runtime.baseURLs[channel.OpenAICompatible] = server.URL + runtime.baseURLs[channel.DeepSeek] = server.URL + spec := convertedSpec(channelID, protocol.Anthropic, execution.OperationChatCompletion, + "/v1/messages", []byte(`{"model":"client-model","max_tokens":0,"messages":[{"role":"user","content":"hello"}]}`)) + var evidence *execution.ErrorEvidence + if stream { + events := 0 + result := runtime.ExecuteStream(t.Context(), spec, func(execution.StreamEvent) error { + events++ + return nil + }) + if result.DispatchState != execution.DispatchNotSent || result.ResponseStarted || events != 0 { + t.Errorf("stream result = %+v, events = %d", result, events) + } + evidence = result.Error + } else { + result := runtime.Execute(t.Context(), spec) + if result.DispatchState != execution.DispatchNotSent || result.ResponseStarted { + t.Errorf("result = %+v", result) + } + evidence = result.Error + } + if evidence == nil || evidence.Kind != execution.ErrorKindConversionUnsupported || + evidence.Code != execution.ErrorCodeCriticalSemanticLoss || + evidence.ScopeHint != execution.ErrorScopeGroup || evidence.OriginHint != execution.ErrorOriginInternal { + t.Errorf("error = %+v", evidence) + } + if calls.Load() != 0 { + t.Errorf("upstream calls = %d, want 0", calls.Load()) + } + }) + } + } +} + +func TestNativeAnthropicZeroOutputPreservesLimitAndResponse(t *testing.T) { + t.Parallel() + + const responseBody = `{"id":"msg_1","type":"message","role":"assistant","model":"upstream-model","content":[],"stop_reason":"max_tokens","stop_sequence":null,"usage":{"input_tokens":4,"output_tokens":0}}` + for _, channelID := range []channel.ID{channel.Anthropic, channel.NewAPI} { + t.Run(string(channelID), func(t *testing.T) { + var calls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + calls.Add(1) + var body map[string]json.RawMessage + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Error(err) + } + if string(body["max_tokens"]) != "0" || request.URL.Path != "/v1/messages" { + t.Errorf("native path/limit = %s/%s", request.URL.Path, body["max_tokens"]) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, responseBody) + })) + defer server.Close() + runtime := newProtocolTestRuntime(t, testRuntimeOptions{allowPrivateNetwork: true, anthropicBaseURL: server.URL}) + runtime.baseURLs[channel.NewAPI] = server.URL + spec := convertedSpec(channelID, protocol.Anthropic, execution.OperationChatCompletion, + "/v1/messages", []byte(`{"model":"client-model","max_tokens":0,"messages":[{"role":"user","content":"hello"}]}`)) + spec.ClientModel = spec.UpstreamModel + result := runtime.Execute(t.Context(), spec) + if result.Error != nil || result.StatusCode != http.StatusOK || calls.Load() != 1 || string(result.Body) != responseBody { + t.Fatalf("result = %+v, calls = %d, body = %s", result, calls.Load(), result.Body) + } + }) + } +} + +func TestCountTokensDoesNotApplyZeroOutputGuard(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/v1beta/models/upstream-model:countTokens" { + t.Errorf("path = %s", request.URL.Path) + } + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `{"totalTokens":7}`) + })) + defer server.Close() + runtime := newProtocolTestRuntime(t, testRuntimeOptions{allowPrivateNetwork: true, geminiBaseURL: server.URL + "/v1beta"}) + spec := convertedSpec(channel.Gemini, protocol.Anthropic, execution.OperationCountTokens, + "/v1/messages/count_tokens", []byte(`{"model":"client-model","max_tokens":0,"messages":[{"role":"user","content":"hello"}]}`)) + result := runtime.Execute(t.Context(), spec) + if result.Error != nil || result.StatusCode != http.StatusOK { + t.Fatalf("count tokens result = %+v", result) + } +} diff --git a/internal/gateway/zero_output_test.go b/internal/gateway/zero_output_test.go new file mode 100644 index 00000000..aa250404 --- /dev/null +++ b/internal/gateway/zero_output_test.go @@ -0,0 +1,86 @@ +package gateway + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "gpt-load/internal/channel" + "gpt-load/internal/dialect" + "gpt-load/internal/platform/config" + "gpt-load/internal/protocol" + "gpt-load/internal/state" + "gpt-load/internal/testutil/fakeupstream" +) + +func TestGatewayZeroOutputProtectionUsesGroupEffectiveParameters(t *testing.T) { + for _, test := range []struct { + name string + original int + override any + wantReject bool + }{ + {name: "zero limit is protected", original: 0, wantReject: true}, + {name: "group requests zero output", original: 16, override: 0, wantReject: true}, + {name: "explicit group override permits generation", original: 0, override: 16}, + {name: "ordinary generation", original: 16}, + } { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/stream=%t", test.name, stream), func(t *testing.T) { + fixture := "gemini/success.json" + if stream { + fixture = "gemini/stream.sse" + } + upstream := fakeupstream.New(fakeupstream.Step{Status: http.StatusOK, Fixture: fixture}) + defer upstream.Close() + params, err := json.Marshal(map[string]string{"base_url": upstream.URL + "/v1beta"}) + if err != nil { + t.Fatal(err) + } + var settings config.Settings + if test.override != nil { + settings = config.Settings{state.SettingParameterOverrides: []any{ + map[string]any{"set": map[string]any{"max_tokens": test.override}}, + }} + } + engine, registry := newDialectGatewayEngine(t, protocol.Anthropic, "public", + dialect.NewSet(dialect.NewAnthropic()), dialectGatewayGroup{ + id: 1, name: "gemini", channelID: channel.Gemini, params: params, + apiKeys: []string{"upstream-key"}, settings: settings, + }) + before := registry.Snapshot() + body := fmt.Sprintf(`{"model":"public","max_tokens":%d,"stream":%t,"messages":[{"role":"user","content":"hello"}]}`, test.original, stream) + request := httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body)) + request.Header.Set("Authorization", "Bearer gl-client") + response := httptest.NewRecorder() + engine.ServeHTTP(response, request) + if test.wantReject { + if response.Code != http.StatusUnprocessableEntity || + !strings.Contains(response.Body.String(), `"code":"protocol_conversion_unsupported"`) || + len(upstream.Requests()) != 0 || strings.Contains(response.Body.String(), "event: message_start") { + t.Fatalf("response = %d %s, upstream calls = %d", response.Code, response.Body.String(), len(upstream.Requests())) + } + if !reflect.DeepEqual(before, registry.Snapshot()) { + t.Fatal("local conversion rejection changed credential health") + } + return + } + if response.Code != http.StatusOK || len(upstream.Requests()) != 1 { + t.Fatalf("generation response = %d %s, upstream calls = %d", response.Code, response.Body.String(), len(upstream.Requests())) + } + var received struct { + GenerationConfig struct { + MaxOutputTokens int `json:"maxOutputTokens"` + } `json:"generationConfig"` + } + if err := json.Unmarshal(upstream.Requests()[0].Body, &received); err != nil || received.GenerationConfig.MaxOutputTokens != 16 { + t.Fatalf("effective output limit = %d, decode error = %v", received.GenerationConfig.MaxOutputTokens, err) + } + }) + } + } +} diff --git a/internal/provideradapter/registry.go b/internal/provideradapter/registry.go index d3ef1424..14b3f681 100644 --- a/internal/provideradapter/registry.go +++ b/internal/provideradapter/registry.go @@ -8,6 +8,7 @@ import ( "reflect" "gpt-load/internal/channel" + "gpt-load/internal/dialect" "gpt-load/internal/execution" "gpt-load/internal/outboundproxy" "gpt-load/internal/protocol" @@ -159,6 +160,14 @@ func (registry *Registry) resolve(spec execution.AttemptSpec) (execution.Executo "attempt route is not declared by channel", ) } + if spec.RouteMode == execution.RouteConverted && spec.ClientProtocol == protocol.Anthropic && + spec.Operation == execution.OperationChatCompletion && dialect.AnthropicRequestsZeroOutput(spec.Body) { + return nil, localAdapterEvidence( + execution.ErrorKindConversionUnsupported, + execution.ErrorCodeCriticalSemanticLoss, + "protocol conversion cannot preserve Anthropic zero-output semantics", + ) + } if spec.ResponsesStoreDowngraded && target.ResponsesStoreHandling( protocol.OpenAIResponses, execution.OperationResponsesCreate, diff --git a/internal/provideradapter/zero_output_test.go b/internal/provideradapter/zero_output_test.go new file mode 100644 index 00000000..c0f04b66 --- /dev/null +++ b/internal/provideradapter/zero_output_test.go @@ -0,0 +1,130 @@ +package provideradapter + +import ( + "encoding/json" + "fmt" + "testing" + + "gpt-load/internal/channel" + "gpt-load/internal/execution" + "gpt-load/internal/protocol" +) + +func TestRegistryRejectsConvertedAnthropicZeroOutputBeforeDispatch(t *testing.T) { + t.Parallel() + + for _, target := range []struct { + channelID channel.ID + config json.RawMessage + }{ + {channel.Gemini, nil}, + {channel.OpenAI, nil}, + {channel.OpenAICompatible, json.RawMessage(`{"base_url":"https://upstream.example/v1"}`)}, + {channel.Codex, json.RawMessage(`{"base_url":"https://chatgpt.com/backend-api/codex"}`)}, + } { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/stream=%t", target.channelID, stream), func(t *testing.T) { + adapter := &recordingAdapter{} + registry, err := NewRegistry(channel.NewRegistry(), completeBindings(adapter, adapter)) + if err != nil { + t.Fatal(err) + } + spec := execution.AttemptSpec{ + ChannelID: string(target.channelID), TargetConfig: target.config, + ClientProtocol: protocol.Anthropic, Operation: execution.OperationChatCompletion, + RouteMode: execution.RouteConverted, UpstreamModel: "upstream-model", + Body: []byte(`{"model":"client-model","max_tokens":0,"messages":[{"role":"user","content":"hello"}]}`), + } + var evidence *execution.ErrorEvidence + if stream { + events := 0 + result := registry.ExecuteStream(t.Context(), spec, func(execution.StreamEvent) error { + events++ + return nil + }) + if result.DispatchState != execution.DispatchNotSent || result.ResponseStarted || events != 0 { + t.Errorf("stream result = %+v, events = %d", result, events) + } + evidence = result.Error + } else { + result := registry.Execute(t.Context(), spec) + if result.DispatchState != execution.DispatchNotSent || result.ResponseStarted { + t.Errorf("result = %+v", result) + } + evidence = result.Error + } + if evidence == nil || evidence.Kind != execution.ErrorKindConversionUnsupported || + evidence.Code != execution.ErrorCodeCriticalSemanticLoss || + evidence.OriginHint != execution.ErrorOriginInternal || evidence.ScopeHint != execution.ErrorScopeGroup { + t.Errorf("conversion evidence = %+v", evidence) + } + if adapter.unaryCalls != 0 || adapter.streamCalls != 0 { + t.Errorf("zero-output request reached adapter: %+v", adapter) + } + }) + } + } +} + +func TestRegistryZeroOutputGuardUsesEffectiveRequestAndOperation(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + body string + protocol protocol.Protocol + operation execution.Operation + native bool + wantReject bool + }{ + {name: "zero", body: `{"max_tokens":0}`, wantReject: true}, + {name: "negative zero", body: `{"max_tokens":-0}`, wantReject: true}, + {name: "decimal zero", body: `{"max_tokens":0.0}`, wantReject: true}, + {name: "exponent zero", body: `{"max_tokens":0e12}`, wantReject: true}, + {name: "small exponent zero", body: `{"max_tokens":0e-10000}`, wantReject: true}, + {name: "effective positive limit", body: `{"max_tokens":16}`}, + {name: "missing limit", body: `{}`}, + {name: "null limit", body: `{"max_tokens":null}`}, + {name: "string is not numeric zero", body: `{"max_tokens":"0"}`}, + {name: "nonzero must not underflow to zero", body: `{"max_tokens":1e-10000}`}, + {name: "nested limit", body: `{"metadata":{"max_tokens":0}}`}, + {name: "last value zero", body: `{"max_tokens":16,"max_tokens":0}`, wantReject: true}, + {name: "last value positive", body: `{"max_tokens":0,"max_tokens":16}`}, + {name: "malformed body keeps existing validation", body: `{"max_tokens":0`}, + {name: "native Anthropic", body: `{"max_tokens":0}`, native: true}, + {name: "count tokens", body: `{"max_tokens":0}`, operation: execution.OperationCountTokens}, + {name: "OpenAI zero retains existing behavior", body: `{"max_tokens":0}`, protocol: protocol.OpenAICompletions}, + } { + t.Run(test.name, func(t *testing.T) { + adapter := &recordingAdapter{} + registry, err := NewRegistry(channel.NewRegistry(), completeBindings(adapter, adapter)) + if err != nil { + t.Fatal(err) + } + clientProtocol := test.protocol + if clientProtocol == "" { + clientProtocol = protocol.Anthropic + } + operation := test.operation + if operation == "" { + operation = execution.OperationChatCompletion + } + spec := execution.AttemptSpec{ + ChannelID: string(channel.Gemini), ClientProtocol: clientProtocol, Operation: operation, + RouteMode: execution.RouteConverted, UpstreamModel: "upstream-model", Body: []byte(test.body), + } + if test.native { + spec.ChannelID = string(channel.Anthropic) + spec.RouteMode = execution.RouteNative + } + result := registry.Execute(t.Context(), spec) + if test.wantReject { + if result.Error == nil || result.Error.Code != execution.ErrorCodeCriticalSemanticLoss || adapter.unaryCalls != 0 { + t.Fatalf("result = %+v, adapter calls = %d", result, adapter.unaryCalls) + } + } else if result.Error != nil || adapter.unaryCalls != 1 { + t.Fatalf("unrelated request changed: result = %+v, adapter calls = %d", result, adapter.unaryCalls) + } + }) + } +}