Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
6 changes: 6 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,12 @@ type OpenAICompatibilityAPIKey struct {
// APIKey is the authentication key for accessing the external API services.
APIKey string `yaml:"api-key" json:"api-key"`

// Label is an optional human-readable account label, such as the DeepSeek account email.
Label string `yaml:"label,omitempty" json:"label,omitempty"`

// Email is an optional account email used as the label when Label is empty.
Email string `yaml:"email,omitempty" json:"email,omitempty"`

// ProxyURL overrides the global proxy setting for this API key if provided.
ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
}
Expand Down
162 changes: 162 additions & 0 deletions internal/runtime/executor/helps/deepseek_reasoning.go
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++
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("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
}
206 changes: 206 additions & 0 deletions internal/runtime/executor/helps/deepseek_reasoning_cache.go
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) != "" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope cached reasoning beyond the tool call ID

When two DeepSeek conversations or users reuse the same tool_call.id within the 30-minute cache TTL, this restore path looks up reasoning from the package-global cache by that ID alone and injects it into the current request. Tool call IDs are only conversation-local identifiers, so a collision or client-supplied repeated ID can corrupt the follow-up request with another turn's reasoning content, and may expose reasoning across requests. Include additional scope such as auth/request/model context in the cache key, or avoid global restoration on call ID alone.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep DeepSeek reasoning per choice

When a chat completion request uses multiple choices (n > 1), DeepSeek can return separate reasoning_content and tool_calls for each choice, but this loop appends every choice's reasoning into one recorder and then records every choice's tool call ID against the combined text. A client that follows up with only one selected choice can therefore get another choice's reasoning injected from the cache, which can break DeepSeek's round-trip validation or expose unrelated reasoning; track the recorder state by choice.index instead of sharing one builder across all 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)
}
}
Loading
Loading