Skip to content
Open
Show file tree
Hide file tree
Changes from all 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++
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) != "" {
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"))
}
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