fix(gateway): 修复协议查询参数与零输出边界 (#603)
This commit is contained in:
1 parent
f0bd09b31a
commit
1898d8ee64
7 files changed
+569
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user