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 8 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,162 @@ | ||
| package helps | ||
|
|
||
| import ( | ||
| "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 := 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++ | ||
| 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("content")) | ||
| if len(parts) == 0 { | ||
| parts = collectReasoningTextParts(item.Get("summary")) | ||
| } | ||
| 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,206 @@ | ||
| package helps | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "fmt" | ||
| "strings" | ||
| "sync" | ||
| "time" | ||
|
|
||
| "github.com/tidwall/gjson" | ||
| "github.com/tidwall/sjson" | ||
| ) | ||
|
|
||
| const ( | ||
| deepSeekReasoningCacheTTL = 30 * time.Minute | ||
| deepSeekReasoningCacheMaxSize = 2048 | ||
| ) | ||
|
|
||
| type deepSeekReasoningCacheEntry struct { | ||
| reasoning string | ||
| expiresAt time.Time | ||
| lastAccess time.Time | ||
| } | ||
|
|
||
| var deepSeekReasoningCache = struct { | ||
| sync.Mutex | ||
| entries map[string]deepSeekReasoningCacheEntry | ||
| }{ | ||
| entries: make(map[string]deepSeekReasoningCacheEntry), | ||
| } | ||
|
|
||
| // DeepSeekReasoningRecorder captures reasoning_content from DeepSeek V4 responses | ||
| // so clients that omit it on the next tool-result request can still be repaired. | ||
| type DeepSeekReasoningRecorder struct { | ||
| modelName string | ||
| reasoning strings.Builder | ||
| toolCallIDs map[string]struct{} | ||
| } | ||
|
|
||
| func NewDeepSeekReasoningRecorder(modelName string) *DeepSeekReasoningRecorder { | ||
| if !IsDeepSeekReasoningModel(modelName) { | ||
| return nil | ||
| } | ||
| return &DeepSeekReasoningRecorder{ | ||
| modelName: modelName, | ||
| toolCallIDs: make(map[string]struct{}), | ||
| } | ||
| } | ||
|
|
||
| func RestoreCachedDeepSeekReasoningContent(modelName string, payload []byte) []byte { | ||
| if !IsDeepSeekReasoningModel(modelName) || len(payload) == 0 || !gjson.ValidBytes(payload) { | ||
| return payload | ||
| } | ||
| messages := gjson.GetBytes(payload, "messages") | ||
| if !messages.Exists() || !messages.IsArray() { | ||
| return payload | ||
| } | ||
|
|
||
| out := payload | ||
| for messageIndex, message := range messages.Array() { | ||
| if strings.TrimSpace(message.Get("role").String()) != "assistant" { | ||
| continue | ||
| } | ||
| if existing := strings.TrimSpace(message.Get("reasoning_content").String()); existing != "" { | ||
| continue | ||
| } | ||
| toolCalls := message.Get("tool_calls") | ||
| if !toolCalls.Exists() || !toolCalls.IsArray() { | ||
| continue | ||
| } | ||
| for _, toolCall := range toolCalls.Array() { | ||
| callID := strings.TrimSpace(toolCall.Get("id").String()) | ||
| if callID == "" { | ||
| continue | ||
| } | ||
| if reasoning := lookupDeepSeekReasoning(callID); strings.TrimSpace(reasoning) != "" { | ||
|
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 two DeepSeek conversations or users reuse the same Useful? React with 👍 / 👎. |
||
| updated, errSet := sjson.SetBytes(out, fmt.Sprintf("messages.%d.reasoning_content", messageIndex), reasoning) | ||
| if errSet == nil { | ||
| out = updated | ||
| } | ||
| break | ||
| } | ||
| } | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| func (r *DeepSeekReasoningRecorder) RecordChatCompletionResponse(body []byte) { | ||
| if r == nil || len(body) == 0 || !gjson.ValidBytes(body) { | ||
| return | ||
| } | ||
| for _, choice := range gjson.GetBytes(body, "choices").Array() { | ||
| message := choice.Get("message") | ||
| if reasoning := message.Get("reasoning_content").String(); strings.TrimSpace(reasoning) != "" { | ||
| r.appendReasoning(reasoning) | ||
| } | ||
| r.recordToolCalls(message.Get("tool_calls")) | ||
| } | ||
|
Comment on lines
+92
to
+98
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 a chat completion request uses multiple choices ( Useful? React with 👍 / 👎. |
||
| r.flush() | ||
| } | ||
|
|
||
| func (r *DeepSeekReasoningRecorder) RecordChatCompletionStreamLine(line []byte) { | ||
| if r == nil { | ||
| return | ||
| } | ||
| trimmed := bytes.TrimSpace(line) | ||
| if bytes.HasPrefix(trimmed, []byte("data:")) { | ||
| trimmed = bytes.TrimSpace(bytes.TrimPrefix(trimmed, []byte("data:"))) | ||
| } | ||
| if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("[DONE]")) || !gjson.ValidBytes(trimmed) { | ||
| return | ||
| } | ||
| for _, choice := range gjson.GetBytes(trimmed, "choices").Array() { | ||
| delta := choice.Get("delta") | ||
| if reasoning := delta.Get("reasoning_content").String(); strings.TrimSpace(reasoning) != "" { | ||
| r.appendReasoning(reasoning) | ||
| } | ||
| r.recordToolCalls(delta.Get("tool_calls")) | ||
| } | ||
| r.flush() | ||
| } | ||
|
|
||
| func (r *DeepSeekReasoningRecorder) appendReasoning(text string) { | ||
| if strings.TrimSpace(text) == "" { | ||
| return | ||
| } | ||
| r.reasoning.WriteString(text) | ||
| } | ||
|
|
||
| func (r *DeepSeekReasoningRecorder) recordToolCalls(toolCalls gjson.Result) { | ||
| if !toolCalls.Exists() || !toolCalls.IsArray() { | ||
| return | ||
| } | ||
| for _, toolCall := range toolCalls.Array() { | ||
| callID := strings.TrimSpace(toolCall.Get("id").String()) | ||
| if callID != "" { | ||
| r.toolCallIDs[callID] = struct{}{} | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func (r *DeepSeekReasoningRecorder) flush() { | ||
| reasoning := r.reasoning.String() | ||
| if strings.TrimSpace(reasoning) == "" || len(r.toolCallIDs) == 0 { | ||
| return | ||
| } | ||
| for callID := range r.toolCallIDs { | ||
| storeDeepSeekReasoning(callID, reasoning) | ||
| } | ||
| } | ||
|
|
||
| func lookupDeepSeekReasoning(callID string) string { | ||
| callID = strings.TrimSpace(callID) | ||
| if callID == "" { | ||
| return "" | ||
| } | ||
| now := time.Now() | ||
| deepSeekReasoningCache.Lock() | ||
| defer deepSeekReasoningCache.Unlock() | ||
| entry, ok := deepSeekReasoningCache.entries[callID] | ||
| if !ok || now.After(entry.expiresAt) { | ||
| delete(deepSeekReasoningCache.entries, callID) | ||
| return "" | ||
| } | ||
| entry.lastAccess = now | ||
| deepSeekReasoningCache.entries[callID] = entry | ||
| return entry.reasoning | ||
| } | ||
|
|
||
| func storeDeepSeekReasoning(callID, reasoning string) { | ||
| callID = strings.TrimSpace(callID) | ||
| if callID == "" || strings.TrimSpace(reasoning) == "" { | ||
| return | ||
| } | ||
| now := time.Now() | ||
| deepSeekReasoningCache.Lock() | ||
| defer deepSeekReasoningCache.Unlock() | ||
| deepSeekReasoningCache.entries[callID] = deepSeekReasoningCacheEntry{ | ||
| reasoning: reasoning, | ||
| expiresAt: now.Add(deepSeekReasoningCacheTTL), | ||
| lastAccess: now, | ||
| } | ||
| pruneDeepSeekReasoningCacheLocked(now) | ||
| } | ||
|
|
||
| func pruneDeepSeekReasoningCacheLocked(now time.Time) { | ||
| for callID, entry := range deepSeekReasoningCache.entries { | ||
| if now.After(entry.expiresAt) { | ||
| delete(deepSeekReasoningCache.entries, callID) | ||
| } | ||
| } | ||
| for len(deepSeekReasoningCache.entries) > deepSeekReasoningCacheMaxSize { | ||
| var oldestID string | ||
| var oldest time.Time | ||
| for callID, entry := range deepSeekReasoningCache.entries { | ||
| if oldestID == "" || entry.lastAccess.Before(oldest) { | ||
| oldestID = callID | ||
| oldest = entry.lastAccess | ||
| } | ||
| } | ||
| if oldestID == "" { | ||
| return | ||
| } | ||
| delete(deepSeekReasoningCache.entries, oldestID) | ||
| } | ||
| } | ||
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.
When an input turn contains
reasoningfollowed by an assistantmessageandfunction_callitems, this logic consumespendingReasoningat the assistant message and increments the ordinal again at the subsequent function-call group, so only the content assistant getsreasoning_contentand the tool-call assistant is left empty. That pattern is produced by this repo’s Responses translation path for mixedcontent + tool_callsassistant outputs (internal/translator/openai/openai/responses/openai_openai-responses_response.goemits bothmessageandfunction_callitems), so follow-up DeepSeek V4 tool-call requests can still hit 400 because the assistant message carryingtool_callsis missing the required reasoning payload.Useful? React with 👍 / 👎.