fix(deepseek): 补齐原生协议字段兼容 (#638)
* fix(deepseek): 补齐原生协议字段兼容 * fix(deepseek): 兼容强制工具调用与默认思考 * fix(deepseek): 跳过空思考字段别名
This commit is contained in:
1 parent
d01656a1d8
commit
f8a04df46d
4 files changed
+411
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user