From f8a04df46d7bb53e414db52bd8bb676cdd7c419e Mon Sep 17 00:00:00 2001 From: tbphp Date: Sun, 13 Sep 2026 11:42:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(deepseek):=20=E8=A1=A5=E9=BD=90=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E5=8D=8F=E8=AE=AE=E5=AD=97=E6=AE=B5=E5=85=BC=E5=AE=B9?= =?UTF-8?q?=20(#638)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(deepseek): 补齐原生协议字段兼容 * fix(deepseek): 兼容强制工具调用与默认思考 * fix(deepseek): 跳过空思考字段别名 --- internal/execution/bifrost/deepseek_native.go | 123 ++++++++ .../execution/bifrost/deepseek_native_test.go | 278 ++++++++++++++++++ internal/execution/bifrost/executor.go | 3 + .../execution/bifrost/native_fidelity_test.go | 7 + 4 files changed, 411 insertions(+) create mode 100644 internal/execution/bifrost/deepseek_native.go create mode 100644 internal/execution/bifrost/deepseek_native_test.go diff --git a/internal/execution/bifrost/deepseek_native.go b/internal/execution/bifrost/deepseek_native.go new file mode 100644 index 00000000..ce357736 --- /dev/null +++ b/internal/execution/bifrost/deepseek_native.go @@ -0,0 +1,123 @@ +package bifrost + +import ( + "strconv" + + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + + "gpt-load/internal/protocol" +) + +// 仅修正 DeepSeek 原生协议的字段差异,不重建或重排消息历史。 +func normalizeDeepSeekNativeRequest(body []byte, clientProtocol protocol.Protocol) ([]byte, error) { + var err error + body, err = normalizeDeepSeekDefaultThinking(body, clientProtocol) + if err != nil { + return nil, err + } + field := "messages" + switch clientProtocol { + case protocol.OpenAICompletions: + case protocol.OpenAIResponses: + field = "input" + default: + return body, nil + } + history := gjson.GetBytes(body, field) + if !history.IsArray() { + return body, nil + } + for index, message := range history.Array() { + if !message.IsObject() { + continue + } + if clientProtocol == protocol.OpenAIResponses { + itemType := message.Get("type") + if itemType.Exists() && itemType.String() != "message" { + continue + } + } + path := field + "." + strconv.Itoa(index) + var err error + if message.Get("role").String() == "developer" && deepSeekTextOnlyContent(message.Get("content"), clientProtocol) { + // Completions 不接受 developer;Responses 将它视为 user,需保留指令语义。 + body, err = sjson.SetBytes(body, path+".role", "system") + if err != nil { + return nil, err + } + } + if clientProtocol == protocol.OpenAICompletions && message.Get("role").String() == "assistant" && !message.Get("reasoning_content").Exists() { + // 只复制已有的完整文本,不用摘要、密文或空占位补造思考内容。 + reasoning := message.Get("reasoning") + if reasoning.Type == gjson.String && reasoning.Str != "" { + body, err = sjson.SetRawBytes(body, path+".reasoning_content", []byte(reasoning.Raw)) + if err != nil { + return nil, err + } + } + } + } + return body, nil +} + +// 强制工具调用优先于上游默认思考;显式思考配置保持原样,由上游校验冲突。 +func normalizeDeepSeekDefaultThinking(body []byte, clientProtocol protocol.Protocol) ([]byte, error) { + choice := gjson.GetBytes(body, "tool_choice") + forced := false + switch clientProtocol { + case protocol.OpenAICompletions: + forced = choice.String() == "required" || + (choice.Get("type").String() == "function" && choice.Get("function.name").String() != "") + case protocol.OpenAIResponses: + forced = choice.String() == "required" || + (choice.Get("type").String() == "function" && choice.Get("name").String() != "") + case protocol.Anthropic: + forced = choice.Get("type").String() == "any" || + (choice.Get("type").String() == "tool" && choice.Get("name").String() != "") + default: + return body, nil + } + if !forced { + return body, nil + } + if gjson.GetBytes(body, "thinking").Exists() || gjson.GetBytes(body, "reasoning_effort").Exists() { + return body, nil + } + if clientProtocol == protocol.OpenAIResponses { + reasoning := gjson.GetBytes(body, "reasoning") + if reasoning.Exists() && (!reasoning.IsObject() || reasoning.Get("effort").Exists()) { + return body, nil + } + return sjson.SetBytes(body, "reasoning.effort", "none") + } + if clientProtocol == protocol.Anthropic { + config := gjson.GetBytes(body, "output_config") + if config.Exists() && (!config.IsObject() || config.Get("effort").Exists()) { + return body, nil + } + } + return sjson.SetBytes(body, "thinking.type", "disabled") +} + +func deepSeekTextOnlyContent(content gjson.Result, clientProtocol protocol.Protocol) bool { + if content.Type == gjson.String { + return true + } + if !content.IsArray() || len(content.Array()) == 0 { + return false + } + for _, block := range content.Array() { + kind := block.Get("type").String() + if clientProtocol == protocol.OpenAICompletions && kind != "text" { + return false + } + if clientProtocol == protocol.OpenAIResponses && kind != "input_text" && kind != "output_text" { + return false + } + if block.Get("text").Type != gjson.String { + return false + } + } + return true +} diff --git a/internal/execution/bifrost/deepseek_native_test.go b/internal/execution/bifrost/deepseek_native_test.go new file mode 100644 index 00000000..2c194f9f --- /dev/null +++ b/internal/execution/bifrost/deepseek_native_test.go @@ -0,0 +1,278 @@ +package bifrost + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/tidwall/sjson" + + "gpt-load/internal/channel" + "gpt-load/internal/execution" + "gpt-load/internal/protocol" + "gpt-load/internal/usage" +) + +func TestDeepSeekNativeCompatibilityPreservesHistory(t *testing.T) { + for _, test := range []struct { + protocol protocol.Protocol + implicitThinking bool + }{ + {protocol.OpenAICompletions, false}, {protocol.OpenAIResponses, false}, {protocol.Anthropic, false}, + {protocol.OpenAICompletions, true}, {protocol.OpenAIResponses, true}, {protocol.Anthropic, true}, + } { + clientProtocol, implicitThinking := test.protocol, test.implicitThinking + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/stream=%t/implicitThinking=%t", clientProtocol, stream, implicitThinking), func(t *testing.T) { + body, response, events := nativeFidelityFixture(clientProtocol) + if implicitThinking { + for _, field := range []string{"thinking", "reasoning_effort", "reasoning", "output_config"} { + var err error + body, err = sjson.Delete(body, field) + if err != nil { + t.Fatal(err) + } + } + choice := `"required"` + if clientProtocol == protocol.Anthropic { + choice = `{"type":"any"}` + } + var err error + body, err = sjson.SetRaw(body, "tool_choice", choice) + if err != nil { + t.Fatal(err) + } + } + field := "messages" + if clientProtocol == protocol.OpenAIResponses { + field = "input" + const reasoningItem = `{"type":"reasoning","id":"reasoning_history","content":[{"type":"reasoning_text","text":"full reasoning history"}],"summary":[]}` + body = strings.Replace(body, `{"type":"function_call"`, reasoningItem+`,{"type":"function_call"`, 1) + response = strings.Replace(response, `"output":[`, `"output":[`+reasoningItem+`,`, 1) + events = "event: response.reasoning_text.delta\ndata: {\"type\":\"response.reasoning_text.delta\",\"item_id\":\"reasoning_history\",\"output_index\":0,\"content_index\":0,\"delta\":\"full reasoning history\"}\n\n" + events + events = strings.Replace(events, `"output":[]`, `"output":[`+reasoningItem+`]`, 1) + } + if clientProtocol != protocol.Anthropic { + body = strings.ReplaceAll(body, `"role":"system"`, `"role":"developer"`) + } + if clientProtocol == protocol.OpenAICompletions { + body = strings.Replace(body, `"reasoning_content":"reasoning history"`, `"reasoning":"reasoning history"`, 1) + } + body = strings.TrimSuffix(body, "}") + `,"prompt_cache_key":"stable-cache-key","metadata":{"user_id":"stable-user"}}` + original := []byte(body) + want := nativeFidelityObject(t, original) + want["model"] = "upstream-model" + delete(want, "provider") + if implicitThinking { + if clientProtocol == protocol.OpenAIResponses { + want["reasoning"] = map[string]any{"effort": "none"} + } else { + want["thinking"] = map[string]any{"type": "disabled"} + } + } + if stream { + want["stream"] = true + } + history := want[field].([]any) + switch clientProtocol { + case protocol.OpenAICompletions: + history[0].(map[string]any)["role"] = "system" + history[4].(map[string]any)["role"] = "system" + history[2].(map[string]any)["reasoning_content"] = "reasoning history" + case protocol.OpenAIResponses: + history[4].(map[string]any)["role"] = "system" + } + wireBodies := make(chan []byte, 2) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + wire, err := io.ReadAll(request.Body) + if err != nil { + t.Error(err) + return + } + wireBodies <- wire + if request.Header.Get("X-Session-Id") != "stable-session" { + t.Error("session header changed") + } + writer.Header().Set("Content-Type", "application/json") + output := response + if stream { + writer.Header().Set("Content-Type", "text/event-stream") + output = events + } + _, _ = io.WriteString(writer, output) + })) + defer server.Close() + var spec execution.AttemptSpec + switch clientProtocol { + case protocol.OpenAICompletions: + spec = openAIChatAttempt(t, channel.DeepSeek, server.URL, "native-key") + case protocol.OpenAIResponses: + spec = openAIResponsesAttempt(t, channel.DeepSeek, server.URL) + case protocol.Anthropic: + spec = deepSeekAnthropicAttempt(t, server.URL) + } + spec.ClientModel = spec.UpstreamModel + spec.Header = http.Header{"X-Session-Id": {"stable-session"}} + spec.ContinuityKey = "stable-continuity" + spec.Body = bytes.Clone(original) + runtime := newRuntimeForTest(t, testRuntimeOptions{allowPrivateNetwork: true}) + execute := func() map[string]any { + t.Helper() + before := spec.Clone() + if stream { + var received []execution.StreamEvent + result := runtime.ExecuteStream(t.Context(), spec, func(event execution.StreamEvent) error { + received = append(received, event.Clone()) + return nil + }) + assertRawStream(t, result, received, events, usage.Tokens{UncachedInput: 100, CacheRead: 900, Output: 3}) + } else { + result := runtime.Execute(t.Context(), spec) + if result.Error != nil || string(result.Body) != response { + t.Fatalf("native response changed: error=%+v body=%s", result.Error, result.Body) + } + } + if !reflect.DeepEqual(spec, before) { + t.Fatal("execution changed the original attempt, affinity identifiers or credential") + } + select { + case wire := <-wireBodies: + return nativeFidelityObject(t, wire) + default: + t.Fatal("request was not dispatched") + return nil + } + } + first := execute() + if !reflect.DeepEqual(first, want) { + t.Fatalf("unexpected compatibility changes:\ngot: %#v\nwant: %#v", first, want) + } + var err error + spec.Body, err = sjson.SetRawBytes(original, field+".-1", []byte(`{"role":"user","content":"continue"}`)) + if err != nil { + t.Fatal(err) + } + second := execute() + secondHistory := second[field].([]any) + if len(secondHistory) != len(history)+1 || !reflect.DeepEqual(first[field], secondHistory[:len(history)]) { + t.Fatal("appending a turn changed the existing message prefix or tool order") + } + second[field] = secondHistory[:len(history)] + if !reflect.DeepEqual(first, second) { + t.Fatal("appending a turn changed instructions, tools, thinking or affinity fields") + } + }) + } + } +} + +func TestDeepSeekForcedToolsRespectExplicitThinking(t *testing.T) { + for _, test := range []struct { + name string + protocol protocol.Protocol + choice string + control string + added string + }{ + {"chat required", protocol.OpenAICompletions, `"required"`, ``, `,"thinking":{"type":"disabled"}`}, + {"chat named", protocol.OpenAICompletions, `{"type":"function","function":{"name":"lookup"}}`, ``, `,"thinking":{"type":"disabled"}`}, + {"chat auto", protocol.OpenAICompletions, `"auto"`, ``, ``}, + {"chat none", protocol.OpenAICompletions, `"none"`, ``, ``}, + {"chat enabled", protocol.OpenAICompletions, `"required"`, `,"thinking":{"type":"enabled"}`, ``}, + {"chat disabled", protocol.OpenAICompletions, `"required"`, `,"thinking":{"type":"disabled"}`, ``}, + {"chat effort", protocol.OpenAICompletions, `"required"`, `,"reasoning_effort":"high"`, ``}, + {"chat invalid thinking", protocol.OpenAICompletions, `"required"`, `,"thinking":null`, ``}, + {"responses required", protocol.OpenAIResponses, `"required"`, ``, `,"reasoning":{"effort":"none"}`}, + {"responses named", protocol.OpenAIResponses, `{"type":"function","name":"lookup"}`, ``, `,"reasoning":{"effort":"none"}`}, + {"responses effort", protocol.OpenAIResponses, `"required"`, `,"reasoning":{"effort":"high"}`, ``}, + {"responses disabled", protocol.OpenAIResponses, `"required"`, `,"reasoning":{"effort":"none"}`, ``}, + {"responses auto", protocol.OpenAIResponses, `"auto"`, ``, ``}, + {"responses invalid reasoning", protocol.OpenAIResponses, `"required"`, `,"reasoning":"high"`, ``}, + {"anthropic any", protocol.Anthropic, `{"type":"any"}`, ``, `,"thinking":{"type":"disabled"}`}, + {"anthropic named", protocol.Anthropic, `{"type":"tool","name":"lookup","disable_parallel_tool_use":true}`, ``, `,"thinking":{"type":"disabled"}`}, + {"anthropic auto", protocol.Anthropic, `{"type":"auto"}`, ``, ``}, + {"anthropic adaptive", protocol.Anthropic, `{"type":"any"}`, `,"thinking":{"type":"adaptive"}`, ``}, + {"anthropic effort", protocol.Anthropic, `{"type":"any"}`, `,"output_config":{"effort":"high"}`, ``}, + {"unsupported protocol", protocol.Gemini, `"required"`, ``, ``}, + } { + t.Run(test.name, func(t *testing.T) { + body := `{"tool_choice":` + test.choice + test.control + `}` + got, err := normalizeDeepSeekNativeRequest([]byte(body), test.protocol) + want := strings.TrimSuffix(body, "}") + test.added + "}" + if err != nil || string(got) != want { + t.Fatalf("tool/thinking compatibility: error=%v got=%s want=%s", err, got, want) + } + again, err := normalizeDeepSeekNativeRequest(got, test.protocol) + if err != nil || !bytes.Equal(got, again) { + t.Fatal("normalization changed an already prepared request") + } + }) + } +} + +func TestDeepSeekDefaultThinkingPreservesOtherOptions(t *testing.T) { + for _, test := range []struct { + protocol protocol.Protocol + body string + want string + }{ + {protocol.OpenAIResponses, + `{"tool_choice":"required","reasoning":{"summary":"auto"}}`, + `{"tool_choice":"required","reasoning":{"summary":"auto","effort":"none"}}`}, + {protocol.Anthropic, + `{"tool_choice":{"type":"any"},"output_config":{"format":{"type":"json_schema","schema":{"type":"object"}}}}`, + `{"tool_choice":{"type":"any"},"output_config":{"format":{"type":"json_schema","schema":{"type":"object"}}},"thinking":{"type":"disabled"}}`}, + } { + got, err := normalizeDeepSeekNativeRequest([]byte(test.body), test.protocol) + if err != nil || string(got) != test.want { + t.Fatalf("changed unrelated options: error=%v body=%s", err, got) + } + } +} + +func TestDeepSeekCompatibilityLeavesUnsupportedContentAndCanonicalReasoning(t *testing.T) { + for _, test := range []struct { + protocol protocol.Protocol + body string + }{ + {protocol.OpenAIResponses, `{"input":[{"role":"developer","content":[{"type":"input_image","image_url":"https://example.com/image.png"}]}]}`}, + {protocol.OpenAIResponses, `{"input":[{"type":"reasoning","summary":[{"type":"summary_text","text":"a summary"}],"encrypted_content":"opaque"}]}`}, + {protocol.OpenAICompletions, `{"messages":[{"role":"assistant","reasoning_content":"canonical","reasoning":"alias"}]}`}, + {protocol.OpenAICompletions, `{"messages":[{"role":"assistant","reasoning_content":"","reasoning":"alias"}]}`}, + {protocol.OpenAICompletions, `{"messages":[{"role":"assistant","reasoning_content":null,"reasoning":"alias"}]}`}, + {protocol.OpenAICompletions, `{"messages":[{"role":"assistant","reasoning":""}]}`}, + {protocol.OpenAICompletions, `{"messages":[{"role":"assistant","reasoning":{"summary":"not original text"}}]}`}, + {protocol.Anthropic, `{"system":"global","messages":[{"role":"user","content":"start"},{"role":"system","content":"instruction"}]}`}, + } { + body := []byte(test.body) + got, err := normalizeDeepSeekNativeRequest(body, test.protocol) + if err != nil || !bytes.Equal(body, got) { + t.Fatalf("unexpected rewrite for %s: error=%v body=%s", test.protocol, err, got) + } + if !json.Valid(got) { + t.Fatal("invalid JSON returned") + } + } +} + +func TestDeepSeekCompatibilityTextBlocks(t *testing.T) { + for _, test := range []struct { + protocol protocol.Protocol + body string + }{ + {protocol.OpenAICompletions, `{"messages":[{"role":"developer","content":[{"type":"text","text":"instruction","cache_control":{"type":"ephemeral"}}]}]}`}, + {protocol.OpenAIResponses, `{"input":[{"type":"message","id":"msg_1","role":"developer","content":[{"type":"input_text","text":"instruction"}]}]}`}, + } { + got, err := normalizeDeepSeekNativeRequest([]byte(test.body), test.protocol) + want := strings.Replace(test.body, `"role":"developer"`, `"role":"system"`, 1) + if err != nil || string(got) != want { + t.Fatalf("text instruction or its metadata changed: error=%v body=%s", err, got) + } + } +} diff --git a/internal/execution/bifrost/executor.go b/internal/execution/bifrost/executor.go index 46f5b963..78532c14 100644 --- a/internal/execution/bifrost/executor.go +++ b/internal/execution/bifrost/executor.go @@ -632,6 +632,9 @@ func (r *Runtime) prepare(spec execution.AttemptSpec, stream bool) (preparedAtte } if convertedImages || mode == channel.RouteNative && (nativeMessagePassthrough || providerSupportsPassthrough(providerKind)) { body, sanitizedHeaders, err := sanitizeNativePassthroughRequest(spec, stream) + if err == nil && mode == channel.RouteNative && providerKind == channel.ProviderDeepSeek { + body, err = normalizeDeepSeekNativeRequest(body, spec.ClientProtocol) + } if err != nil { failure := notSentUnaryFailure(execution.ErrorKindInvalidRequest, "invalid native request body") if spec.ClientProtocol == protocol.OpenAIImages { diff --git a/internal/execution/bifrost/native_fidelity_test.go b/internal/execution/bifrost/native_fidelity_test.go index 578e53e2..aa51a846 100644 --- a/internal/execution/bifrost/native_fidelity_test.go +++ b/internal/execution/bifrost/native_fidelity_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "reflect" + "strings" "sync/atomic" "testing" @@ -42,6 +43,12 @@ func TestNativeMessagesPreserveConversationAndExtensions(t *testing.T) { for _, stream := range []bool{false, true} { t.Run(fmt.Sprintf("%s/%s/stream=%t", test.channel, test.protocol, stream), func(t *testing.T) { body, responseBody, responseStream := nativeFidelityFixture(test.protocol) + if test.channel != channel.DeepSeek { + body = strings.ReplaceAll(body, `"role":"system"`, `"role":"developer"`) + body = strings.ReplaceAll(body, `"reasoning_content":"reasoning history"`, `"reasoning":"reasoning history"`) + body = strings.Replace(body, `,"thinking":{"type":"enabled"},"reasoning_effort":"high"`, "", 1) + body = strings.Replace(body, `,"reasoning":{"effort":"high"}`, `,"tool_choice":"required"`, 1) + } var original map[string]json.RawMessage if err := json.Unmarshal([]byte(body), &original); err != nil { t.Fatal(err)