Repository navigation
Feature/support deepseek reasoning content #3523
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 2 commits
9e58aed
362a7fe
b0dc79c
7a052a7
f22d170
bd08c7a
eab83da
9a7c0d7
823804b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| package helps | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| "github.com/tidwall/gjson" | ||
| "github.com/tidwall/sjson" | ||
| ) | ||
|
|
||
| // IsDeepSeekReasoningModel reports whether a model requires DeepSeek reasoning_content round-tripping. | ||
| func IsDeepSeekReasoningModel(modelName string) bool { | ||
| modelName = strings.ToLower(strings.TrimSpace(modelName)) | ||
| if strings.HasSuffix(modelName, ")") { | ||
| if idx := strings.LastIndex(modelName, "("); idx > 0 { | ||
| modelName = strings.TrimSpace(modelName[:idx]) | ||
| } | ||
| } | ||
| switch modelName { | ||
| case "deepseek-v4-pro", "deepseek-v4-flash": | ||
| return true | ||
| default: | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| // PreserveDeepSeekReasoningContent restores Responses reasoning output items onto | ||
| // OpenAI Chat Completions assistant messages for DeepSeek V4 thinking models. | ||
| func PreserveDeepSeekReasoningContent(modelName string, translatedChatCompletions, sourceRawJSON []byte) []byte { | ||
| if !IsDeepSeekReasoningModel(modelName) || len(translatedChatCompletions) == 0 || len(sourceRawJSON) == 0 { | ||
| return translatedChatCompletions | ||
| } | ||
| if !gjson.ValidBytes(translatedChatCompletions) || !gjson.ValidBytes(sourceRawJSON) { | ||
| return translatedChatCompletions | ||
| } | ||
| messages := gjson.GetBytes(translatedChatCompletions, "messages") | ||
| if !messages.Exists() || !messages.IsArray() { | ||
| return translatedChatCompletions | ||
| } | ||
|
|
||
| targets := deepSeekReasoningTargetsByAssistantOrdinal(sourceRawJSON) | ||
| if len(targets) == 0 { | ||
| return translatedChatCompletions | ||
| } | ||
|
|
||
| out := bytes.Clone(translatedChatCompletions) | ||
| assistantOrdinal := 0 | ||
| for messageIndex, message := range messages.Array() { | ||
| if strings.TrimSpace(message.Get("role").String()) != "assistant" { | ||
| continue | ||
| } | ||
| if reasoningContent := targets[assistantOrdinal]; strings.TrimSpace(reasoningContent) != "" { | ||
| existing := message.Get("reasoning_content") | ||
| if !existing.Exists() || strings.TrimSpace(existing.String()) == "" { | ||
| updated, errSet := sjson.SetBytes(out, fmt.Sprintf("messages.%d.reasoning_content", messageIndex), reasoningContent) | ||
| if errSet == nil { | ||
| out = updated | ||
| } | ||
| } | ||
| } | ||
| assistantOrdinal++ | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| func deepSeekReasoningTargetsByAssistantOrdinal(sourceRawJSON []byte) map[int]string { | ||
| input := gjson.GetBytes(sourceRawJSON, "input") | ||
| if !input.Exists() || !input.IsArray() { | ||
| return nil | ||
| } | ||
|
|
||
| targets := make(map[int]string) | ||
| assistantOrdinal := 0 | ||
| pendingReasoning := "" | ||
| inFunctionCallGroup := false | ||
|
|
||
| consumePendingReasoning := func() { | ||
| if strings.TrimSpace(pendingReasoning) != "" { | ||
| targets[assistantOrdinal] = pendingReasoning | ||
| pendingReasoning = "" | ||
| } | ||
| } | ||
|
|
||
| for _, item := range input.Array() { | ||
| itemType := strings.TrimSpace(item.Get("type").String()) | ||
| if itemType == "" && strings.TrimSpace(item.Get("role").String()) != "" { | ||
| itemType = "message" | ||
| } | ||
| if itemType != "function_call" { | ||
| inFunctionCallGroup = false | ||
| } | ||
|
|
||
| switch itemType { | ||
| case "reasoning": | ||
| if text := responseReasoningItemText(item); strings.TrimSpace(text) != "" { | ||
| pendingReasoning = appendReasoningContent(pendingReasoning, text) | ||
| } | ||
| case "function_call": | ||
| if !inFunctionCallGroup { | ||
| consumePendingReasoning() | ||
| assistantOrdinal++ | ||
| inFunctionCallGroup = true | ||
| } | ||
| case "message", "": | ||
| if strings.TrimSpace(item.Get("role").String()) == "assistant" { | ||
| consumePendingReasoning() | ||
| assistantOrdinal++ | ||
|
Comment on lines
+105
to
+107
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an input turn contains Useful? React with 👍 / 👎. |
||
| continue | ||
| } | ||
| if pendingReasoning != "" { | ||
| pendingReasoning = "" | ||
| } | ||
| case "function_call_output", "custom_tool_call_output": | ||
| if pendingReasoning != "" { | ||
| pendingReasoning = "" | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if len(targets) == 0 { | ||
| return nil | ||
| } | ||
| return targets | ||
| } | ||
|
|
||
| func responseReasoningItemText(item gjson.Result) string { | ||
| parts := collectReasoningTextParts(item.Get("summary")) | ||
| if len(parts) == 0 { | ||
| parts = collectReasoningTextParts(item.Get("content")) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The restoration logic reads Useful? React with 👍 / 👎. |
||
| } | ||
| if len(parts) == 0 { | ||
| for _, path := range []string{"text", "reasoning_content"} { | ||
| if text := item.Get(path).String(); strings.TrimSpace(text) != "" { | ||
| parts = append(parts, text) | ||
| } | ||
| } | ||
| } | ||
| return strings.Join(parts, "\n\n") | ||
| } | ||
|
|
||
| func collectReasoningTextParts(value gjson.Result) []string { | ||
| if !value.Exists() || !value.IsArray() { | ||
| return nil | ||
| } | ||
| parts := make([]string, 0) | ||
| for _, part := range value.Array() { | ||
| if text := part.Get("text").String(); strings.TrimSpace(text) != "" { | ||
| parts = append(parts, text) | ||
| } | ||
| } | ||
| return parts | ||
| } | ||
|
|
||
| func appendReasoningContent(existing, next string) string { | ||
| if strings.TrimSpace(existing) == "" { | ||
| return next | ||
| } | ||
| if strings.TrimSpace(next) == "" { | ||
| return existing | ||
| } | ||
| return existing + "\n\n" + next | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| package helps | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| responsesconverter "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/openai/openai/responses" | ||
| "github.com/tidwall/gjson" | ||
| "github.com/tidwall/sjson" | ||
| ) | ||
|
|
||
| func TestIsDeepSeekReasoningModel(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| tests := []struct { | ||
| model string | ||
| want bool | ||
| }{ | ||
| {model: "deepseek-v4-pro", want: true}, | ||
| {model: "deepseek-v4-flash", want: true}, | ||
| {model: "deepseek-v4-pro(8192)", want: true}, | ||
| {model: "deepseek-v3.1", want: false}, | ||
| {model: "gpt-5.4", want: false}, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.model, func(t *testing.T) { | ||
| t.Parallel() | ||
| if got := IsDeepSeekReasoningModel(tt.model); got != tt.want { | ||
| t.Fatalf("IsDeepSeekReasoningModel(%q) = %v, want %v", tt.model, got, tt.want) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestPreserveDeepSeekReasoningContent_AttachesReasoningToToolCallAssistant(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| raw := []byte(`{ | ||
| "input": [ | ||
| {"type":"message","role":"user","content":[{"type":"input_text","text":"run a tool"}]}, | ||
| {"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"I need to inspect the file first."}]}, | ||
| {"type":"function_call","call_id":"call_read","name":"read","arguments":"{\"filePath\":\"README.md\"}"}, | ||
| {"type":"function_call_output","call_id":"call_read","output":"ok"} | ||
| ] | ||
| }`) | ||
|
|
||
| translated := responsesconverter.ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-pro", raw, true) | ||
| out := PreserveDeepSeekReasoningContent("deepseek-v4-pro", translated, raw) | ||
|
|
||
| if got := gjson.GetBytes(out, "messages.1.role").String(); got != "assistant" { | ||
| t.Fatalf("messages.1.role = %q, want assistant; payload=%s", got, out) | ||
| } | ||
| if got := gjson.GetBytes(out, "messages.1.reasoning_content").String(); got != "I need to inspect the file first." { | ||
| t.Fatalf("messages.1.reasoning_content = %q", got) | ||
| } | ||
| if got := gjson.GetBytes(out, "messages.1.tool_calls.0.id").String(); got != "call_read" { | ||
| t.Fatalf("messages.1.tool_calls.0.id = %q, want call_read", got) | ||
| } | ||
| } | ||
|
|
||
| func TestPreserveDeepSeekReasoningContent_UsesAssistantOrdinal(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| raw := []byte(`{ | ||
| "input": [ | ||
| {"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]}, | ||
| {"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]}, | ||
| {"id":"rs_1","type":"reasoning","summary":[{"type":"summary_text","text":"The next step needs a tool."}]}, | ||
| {"type":"function_call","call_id":"call_shell","name":"shell","arguments":"{\"cmd\":\"pwd\"}"} | ||
| ] | ||
| }`) | ||
|
|
||
| translated := responsesconverter.ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-flash", raw, false) | ||
| out := PreserveDeepSeekReasoningContent("deepseek-v4-flash", translated, raw) | ||
|
|
||
| if gjson.GetBytes(out, "messages.1.reasoning_content").Exists() { | ||
| t.Fatalf("reasoning_content attached to the wrong assistant message: %s", out) | ||
| } | ||
| if got := gjson.GetBytes(out, "messages.2.reasoning_content").String(); got != "The next step needs a tool." { | ||
| t.Fatalf("messages.2.reasoning_content = %q", got) | ||
| } | ||
| } | ||
|
|
||
| func TestPreserveDeepSeekReasoningContent_SkipsOtherModels(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| raw := []byte(`{ | ||
| "input": [ | ||
| {"type":"reasoning","summary":[{"type":"summary_text","text":"hidden"}]}, | ||
| {"type":"function_call","call_id":"call_1","name":"tool","arguments":"{}"} | ||
| ] | ||
| }`) | ||
| translated := responsesconverter.ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v3.1", raw, true) | ||
| out := PreserveDeepSeekReasoningContent("deepseek-v3.1", translated, raw) | ||
|
|
||
| if gjson.GetBytes(out, "messages.0.reasoning_content").Exists() { | ||
| t.Fatalf("unexpected reasoning_content for non-v4 model: %s", out) | ||
| } | ||
| } | ||
|
|
||
| func TestPreserveDeepSeekReasoningContent_DoesNotOverwriteExistingReasoning(t *testing.T) { | ||
| t.Parallel() | ||
|
|
||
| raw := []byte(`{ | ||
| "input": [ | ||
| {"type":"reasoning","summary":[{"type":"summary_text","text":"new reasoning"}]}, | ||
| {"type":"function_call","call_id":"call_1","name":"tool","arguments":"{}"} | ||
| ] | ||
| }`) | ||
| translated := responsesconverter.ConvertOpenAIResponsesRequestToOpenAIChatCompletions("deepseek-v4-pro", raw, true) | ||
| translated, _ = sjson.SetBytes(translated, "messages.0.reasoning_content", "existing reasoning") | ||
| out := PreserveDeepSeekReasoningContent("deepseek-v4-pro", translated, raw) | ||
|
|
||
| if got := gjson.GetBytes(out, "messages.0.reasoning_content").String(); got != "existing reasoning" { | ||
| t.Fatalf("messages.0.reasoning_content = %q, want existing reasoning", got) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The call to
bytes.Cloneis redundant here.sjson.SetBytesreturns a new byte slice and does not modify the input slice in-place. Furthermore, the inputtranslatedChatCompletionsis already a fresh slice generated by the translator in the calling code. Removing this clone avoids an unnecessary allocation for every request involving these models.