477a811999
The 40-character truncation of tool call IDs in helper.go caused collisions when models (like deepseek-v4-flash) generated longer IDs, leading to "Duplicate value for 'tool_call_id'" errors. Removed the limit to allow full unique IDs. DeepSeek: updated reasoning_content injection to use an empty string instead of a space, better matching provider expectations for history. Improved API error reporting across all providers by capturing raw body content when response parsing fails or returns empty strings.
269 lines
7.5 KiB
Go
269 lines
7.5 KiB
Go
package providers
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-resty/resty/v2"
|
|
"gophergate/internal/config"
|
|
"gophergate/internal/models"
|
|
)
|
|
|
|
type DeepSeekProvider struct {
|
|
client *resty.Client
|
|
config config.DeepSeekConfig
|
|
apiKey string
|
|
}
|
|
|
|
func NewDeepSeekProvider(cfg config.DeepSeekConfig, apiKey string) *DeepSeekProvider {
|
|
return &DeepSeekProvider{
|
|
client: resty.New().SetTimeout(10 * time.Minute),
|
|
config: cfg,
|
|
apiKey: apiKey,
|
|
}
|
|
}
|
|
|
|
func (p *DeepSeekProvider) Name() string {
|
|
return "deepseek"
|
|
}
|
|
|
|
type deepSeekUsage struct {
|
|
PromptTokens uint32 `json:"prompt_tokens"`
|
|
CompletionTokens uint32 `json:"completion_tokens"`
|
|
TotalTokens uint32 `json:"total_tokens"`
|
|
PromptCacheHitTokens uint32 `json:"prompt_cache_hit_tokens"`
|
|
PromptCacheMissTokens uint32 `json:"prompt_cache_miss_tokens"`
|
|
CompletionTokensDetails *struct {
|
|
ReasoningTokens uint32 `json:"reasoning_tokens"`
|
|
} `json:"completion_tokens_details"`
|
|
}
|
|
|
|
func (u *deepSeekUsage) ToUnified() *models.Usage {
|
|
usage := &models.Usage{
|
|
PromptTokens: u.PromptTokens,
|
|
CompletionTokens: u.CompletionTokens,
|
|
TotalTokens: u.TotalTokens,
|
|
}
|
|
if u.PromptCacheHitTokens > 0 {
|
|
usage.CacheReadTokens = &u.PromptCacheHitTokens
|
|
}
|
|
if u.PromptCacheMissTokens > 0 {
|
|
usage.CacheWriteTokens = &u.PromptCacheMissTokens
|
|
}
|
|
if u.CompletionTokensDetails != nil && u.CompletionTokensDetails.ReasoningTokens > 0 {
|
|
usage.ReasoningTokens = &u.CompletionTokensDetails.ReasoningTokens
|
|
}
|
|
return usage
|
|
}
|
|
|
|
func (p *DeepSeekProvider) ChatCompletion(ctx context.Context, req *models.UnifiedRequest) (*models.ChatCompletionResponse, error) {
|
|
// Ensure English responses — DeepSeek defaults to Chinese for some prompts
|
|
ensureEnglish(req)
|
|
|
|
messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to convert messages: %w", err)
|
|
}
|
|
|
|
body := BuildOpenAIBody(req, messagesJSON, false)
|
|
|
|
// Sanitize for models that support reasoning/thinking mode
|
|
isReasoner := strings.Contains(req.Model, "reasoner") || strings.Contains(req.Model, "v4") || strings.Contains(req.Model, "r1")
|
|
|
|
if isReasoner {
|
|
// deepseek-reasoner (R1) does not support these parameters
|
|
if req.Model == "deepseek-reasoner" || strings.HasPrefix(req.Model, "deepseek-r1") {
|
|
delete(body, "temperature")
|
|
delete(body, "top_p")
|
|
delete(body, "presence_penalty")
|
|
delete(body, "frequency_penalty")
|
|
}
|
|
|
|
if msgs, ok := body["messages"].([]interface{}); ok {
|
|
for _, m := range msgs {
|
|
if msg, ok := m.(map[string]interface{}); ok {
|
|
if msg["role"] == "assistant" {
|
|
// DeepSeek requires reasoning_content to be passed back in history
|
|
// if the model is in thinking mode.
|
|
if msg["reasoning_content"] == nil {
|
|
msg["reasoning_content"] = ""
|
|
}
|
|
if msg["content"] == nil || msg["content"] == "" {
|
|
msg["content"] = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
resp, err := p.client.R().
|
|
SetContext(ctx).
|
|
SetHeader("Authorization", "Bearer "+p.apiKey).
|
|
SetBody(body).
|
|
Post(fmt.Sprintf("%s/chat/completions", p.config.BaseURL))
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
|
|
if !resp.IsSuccess() {
|
|
var msg string
|
|
if resp.RawBody() != nil {
|
|
bodyBytes, _ := io.ReadAll(resp.RawBody())
|
|
msg = string(bodyBytes)
|
|
}
|
|
if msg == "" {
|
|
msg = resp.String()
|
|
}
|
|
return nil, fmt.Errorf("DeepSeek API error (%d): %s", resp.StatusCode(), msg)
|
|
}
|
|
|
|
var respJSON map[string]interface{}
|
|
if err := json.Unmarshal(resp.Body(), &respJSON); err != nil {
|
|
return nil, fmt.Errorf("failed to parse response: %w", err)
|
|
}
|
|
|
|
result, err := ParseOpenAIResponse(respJSON, req.Model)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Fix usage for DeepSeek specifically if details were missing in ParseOpenAIResponse
|
|
if usageData, ok := respJSON["usage"]; ok {
|
|
var dUsage deepSeekUsage
|
|
usageBytes, _ := json.Marshal(usageData)
|
|
if err := json.Unmarshal(usageBytes, &dUsage); err == nil {
|
|
result.Usage = dUsage.ToUnified()
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (p *DeepSeekProvider) ChatCompletionStream(ctx context.Context, req *models.UnifiedRequest) (<-chan *models.ChatCompletionStreamResponse, error) {
|
|
ensureEnglish(req)
|
|
|
|
messagesJSON, err := MessagesToOpenAIJSON(req.Messages)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to convert messages: %w", err)
|
|
}
|
|
|
|
body := BuildOpenAIBody(req, messagesJSON, true)
|
|
|
|
// Sanitize for models that support reasoning/thinking mode
|
|
isReasoner := strings.Contains(req.Model, "reasoner") || strings.Contains(req.Model, "v4") || strings.Contains(req.Model, "r1")
|
|
|
|
if isReasoner {
|
|
// deepseek-reasoner (R1) does not support these parameters
|
|
if req.Model == "deepseek-reasoner" || strings.HasPrefix(req.Model, "deepseek-r1") {
|
|
delete(body, "temperature")
|
|
delete(body, "top_p")
|
|
delete(body, "presence_penalty")
|
|
delete(body, "frequency_penalty")
|
|
}
|
|
|
|
if msgs, ok := body["messages"].([]interface{}); ok {
|
|
for _, m := range msgs {
|
|
if msg, ok := m.(map[string]interface{}); ok {
|
|
if msg["role"] == "assistant" {
|
|
// DeepSeek requires reasoning_content to be passed back in history
|
|
// if the model is in thinking mode.
|
|
if msg["reasoning_content"] == nil {
|
|
msg["reasoning_content"] = ""
|
|
}
|
|
if msg["content"] == nil || msg["content"] == "" {
|
|
msg["content"] = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
resp, err := p.client.R().
|
|
SetContext(ctx).
|
|
SetHeader("Authorization", "Bearer "+p.apiKey).
|
|
SetBody(body).
|
|
SetDoNotParseResponse(true).
|
|
Post(fmt.Sprintf("%s/chat/completions", p.config.BaseURL))
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %w", err)
|
|
}
|
|
|
|
if !resp.IsSuccess() {
|
|
var msg string
|
|
if resp.RawBody() != nil {
|
|
bodyBytes, _ := io.ReadAll(resp.RawBody())
|
|
msg = string(bodyBytes)
|
|
}
|
|
if msg == "" {
|
|
msg = resp.String()
|
|
}
|
|
return nil, fmt.Errorf("DeepSeek API error (%d): %s", resp.StatusCode(), msg)
|
|
}
|
|
|
|
ch := make(chan *models.ChatCompletionStreamResponse)
|
|
|
|
go func() {
|
|
defer close(ch)
|
|
// Custom scanner loop to handle DeepSeek specific usage in chunks
|
|
err := StreamDeepSeek(resp.RawBody(), ch)
|
|
if err != nil {
|
|
fmt.Printf("DeepSeek Stream error: %v\n", err)
|
|
}
|
|
}()
|
|
|
|
return ch, nil
|
|
}
|
|
|
|
func StreamDeepSeek(ctx io.ReadCloser, ch chan<- *models.ChatCompletionStreamResponse) error {
|
|
defer ctx.Close()
|
|
scanner := bufio.NewScanner(ctx)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if line == "" || !strings.HasPrefix(line, "data: ") {
|
|
continue
|
|
}
|
|
|
|
data := strings.TrimPrefix(line, "data: ")
|
|
if data == "[DONE]" {
|
|
break
|
|
}
|
|
|
|
var chunk models.ChatCompletionStreamResponse
|
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
|
continue
|
|
}
|
|
|
|
// Fix DeepSeek specific usage in stream
|
|
var rawChunk struct {
|
|
Usage *deepSeekUsage `json:"usage"`
|
|
}
|
|
if err := json.Unmarshal([]byte(data), &rawChunk); err == nil && rawChunk.Usage != nil {
|
|
chunk.Usage = rawChunk.Usage.ToUnified()
|
|
}
|
|
|
|
ch <- &chunk
|
|
}
|
|
return scanner.Err()
|
|
}
|
|
|
|
func (p *DeepSeekProvider) ImageGeneration(ctx context.Context, req *models.ImageGenerationRequest) (*models.ImageGenerationResponse, error) {
|
|
return nil, fmt.Errorf("deepseek does not support image generation")
|
|
}
|
|
|
|
func (p *DeepSeekProvider) Responses(ctx context.Context, req *models.ResponsesRequest) (*models.ResponsesResponse, error) {
|
|
return nil, fmt.Errorf("responses API not supported by deepseek")
|
|
}
|
|
|
|
func (p *DeepSeekProvider) ResponsesStream(ctx context.Context, req *models.ResponsesRequest) (<-chan *models.ResponsesStreamChunk, error) {
|
|
return nil, fmt.Errorf("responses API not supported by deepseek")
|
|
}
|