Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,16 @@ nonstream-keepalive-interval: 0
# alias: "claude-opus-4.66"
# - name: "kimi-k2.5"
# alias: "claude-opus-4.66"
#
# - name: "deepseek" # Example DeepSeek OpenAI-compatible provider.
# base-url: "https://api.deepseek.com"
# api-key-entries:
# - api-key: "sk-..."
# models:
# - name: "deepseek-v4-pro"
# alias: "deepseek-v4-pro"
# - name: "deepseek-v4-flash"
# alias: "deepseek-v4-flash"

# Vertex API keys (Vertex-compatible endpoints, base-url is optional)
# vertex-api-key:
Expand Down
163 changes: 163 additions & 0 deletions internal/runtime/executor/helps/deepseek_reasoning.go
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The call to bytes.Clone is redundant here. sjson.SetBytes returns a new byte slice and does not modify the input slice in-place. Furthermore, the input translatedChatCompletions is 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.

Suggested change
out := bytes.Clone(translatedChatCompletions)
out := 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve reasoning on tool-call assistant in mixed turns

When an input turn contains reasoning followed by an assistant message and function_call items, this logic consumes pendingReasoning at the assistant message and increments the ordinal again at the subsequent function-call group, so only the content assistant gets reasoning_content and the tool-call assistant is left empty. That pattern is produced by this repo’s Responses translation path for mixed content + tool_calls assistant outputs (internal/translator/openai/openai/responses/openai_openai-responses_response.go emits both message and function_call items), so follow-up DeepSeek V4 tool-call requests can still hit 400 because the assistant message carrying tool_calls is missing the required reasoning payload.

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"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use full reasoning content when rebuilding DeepSeek history

The restoration logic reads reasoning.summary first and only falls back to reasoning.content when summary is absent, so requests that carry both fields will replay the abbreviated summary instead of the full reasoning trace. DeepSeek V4 tool-call turns require the prior reasoning_content to be fully round-tripped; sending a shortened summary can still trigger 400 errors on subsequent turns even though the helper ran. Prefer content when present (or merge with explicit precedence) to avoid lossy replay.

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
}
117 changes: 117 additions & 0 deletions internal/runtime/executor/helps/deepseek_reasoning_test.go
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)
}
}
5 changes: 5 additions & 0 deletions internal/runtime/executor/openai_compat_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, opts.Stream)
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, opts.Stream)
originalTranslated = helps.PreserveDeepSeekReasoningContent(baseModel, originalTranslated, originalPayload)
translated = helps.PreserveDeepSeekReasoningContent(baseModel, translated, req.Payload)

translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
Expand Down Expand Up @@ -307,6 +309,8 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
originalPayload := originalPayloadSource
originalTranslated := sdktranslator.TranslateRequest(from, to, baseModel, originalPayload, true)
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, true)
originalTranslated = helps.PreserveDeepSeekReasoningContent(baseModel, originalTranslated, originalPayload)
translated = helps.PreserveDeepSeekReasoningContent(baseModel, translated, req.Payload)

translated, err = thinking.ApplyThinking(translated, req.Model, from.String(), to.String(), e.Identifier())
if err != nil {
Expand Down Expand Up @@ -570,6 +574,7 @@ func (e *OpenAICompatExecutor) CountTokens(ctx context.Context, auth *cliproxyau
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
translated := sdktranslator.TranslateRequest(from, to, baseModel, req.Payload, false)
translated = helps.PreserveDeepSeekReasoningContent(baseModel, translated, req.Payload)

modelForCounting := baseModel

Expand Down
Loading