feat: implement OpenAI-compatible file upload and reference handling for DeepSeek API

This commit is contained in:
CJACK
2026-04-12 23:30:22 +08:00
parent 0a23c77ff7
commit caafdedb00
31 changed files with 1882 additions and 330 deletions

View File

@@ -91,17 +91,25 @@ func (c *Client) CreateSession(ctx context.Context, a *auth.RequestAuth, maxAtte
}
func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts int) (string, error) {
return c.GetPowForTarget(ctx, a, DeepSeekCompletionTargetPath, maxAttempts)
}
func (c *Client) GetPowForTarget(ctx context.Context, a *auth.RequestAuth, targetPath string, maxAttempts int) (string, error) {
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
targetPath = strings.TrimSpace(targetPath)
if targetPath == "" {
targetPath = DeepSeekCompletionTargetPath
}
clients := c.requestClientsForAuth(ctx, a)
attempts := 0
refreshed := false
for attempts < maxAttempts {
headers := c.authHeaders(a.DeepSeekToken)
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekCreatePowURL, headers, map[string]any{"target_path": "/api/v0/chat/completion"})
resp, status, err := c.postJSONWithStatus(ctx, clients.regular, clients.fallback, DeepSeekCreatePowURL, headers, map[string]any{"target_path": targetPath})
if err != nil {
config.Logger.Warn("[get_pow] request error", "error", err, "account", a.AccountID)
config.Logger.Warn("[get_pow] request error", "error", err, "account", a.AccountID, "target_path", targetPath)
attempts++
continue
}
@@ -117,7 +125,7 @@ func (c *Client) GetPow(ctx context.Context, a *auth.RequestAuth, maxAttempts in
}
return BuildPowHeader(challenge, answer)
}
config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID)
config.Logger.Warn("[get_pow] failed", "status", status, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "use_config_token", a.UseConfigToken, "account", a.AccountID, "target_path", targetPath)
if a.UseConfigToken {
if !refreshed && shouldAttemptRefresh(status, code, bizCode, msg, bizMsg) {
if c.Auth.RefreshToken(ctx, a) {

View File

@@ -35,6 +35,12 @@ func preview(b []byte) string {
return s
}
func (c *Client) jsonHeaders(headers map[string]string) map[string]string {
out := cloneStringMap(headers)
out["Content-Type"] = "application/json"
return out
}
func ScanSSELines(resp *http.Response, onLine func([]byte) bool) error {
scanner := bufio.NewScanner(resp.Body)
buf := make([]byte, 0, 64*1024)

View File

@@ -27,6 +27,7 @@ func (c *Client) postJSONWithStatus(ctx context.Context, doer trans.Doer, fallba
if err != nil {
return nil, 0, err
}
headers = c.jsonHeaders(headers)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(b))
if err != nil {
return nil, 0, err

View File

@@ -0,0 +1,258 @@
package deepseek
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/http"
"net/textproto"
"path/filepath"
"strconv"
"strings"
"ds2api/internal/auth"
"ds2api/internal/config"
trans "ds2api/internal/deepseek/transport"
)
type UploadFileRequest struct {
Filename string
ContentType string
Purpose string
Data []byte
}
type UploadFileResult struct {
ID string
Filename string
Bytes int64
Status string
Purpose string
Raw map[string]any
RawHeaders http.Header
}
func (c *Client) UploadFile(ctx context.Context, a *auth.RequestAuth, req UploadFileRequest, maxAttempts int) (*UploadFileResult, error) {
if maxAttempts <= 0 {
maxAttempts = c.maxRetries
}
if len(req.Data) == 0 {
return nil, errors.New("file is required")
}
filename := strings.TrimSpace(req.Filename)
if filename == "" {
filename = "upload.bin"
}
contentType := strings.TrimSpace(req.ContentType)
if contentType == "" {
contentType = "application/octet-stream"
}
purpose := strings.TrimSpace(req.Purpose)
body, contentTypeHeader, err := buildUploadMultipartBody(filename, contentType, purpose, req.Data)
if err != nil {
return nil, err
}
capturePayload := map[string]any{
"filename": filename,
"content_type": contentType,
"purpose": purpose,
"bytes": len(req.Data),
}
captureSession := c.capture.Start("deepseek_upload_file", DeepSeekUploadFileURL, a.AccountID, capturePayload)
attempts := 0
refreshed := false
powHeader := ""
for attempts < maxAttempts {
clients := c.requestClientsForAuth(ctx, a)
if strings.TrimSpace(powHeader) == "" {
powHeader, err = c.GetPowForTarget(ctx, a, DeepSeekUploadTargetPath, maxAttempts)
if err != nil {
return nil, err
}
clients = c.requestClientsForAuth(ctx, a)
}
headers := c.authHeaders(a.DeepSeekToken)
headers["Content-Type"] = contentTypeHeader
headers["x-ds-pow-response"] = powHeader
headers["x-file-size"] = strconv.Itoa(len(req.Data))
headers["x-thinking-enabled"] = "1"
resp, err := c.doUpload(ctx, clients.regular, clients.fallback, DeepSeekUploadFileURL, headers, body)
if err != nil {
config.Logger.Warn("[upload_file] request error", "error", err, "account", a.AccountID, "filename", filename)
powHeader = ""
attempts++
continue
}
if captureSession != nil {
resp.Body = captureSession.WrapBody(resp.Body, resp.StatusCode)
}
payloadBytes, readErr := readResponseBody(resp)
_ = resp.Body.Close()
if readErr != nil {
powHeader = ""
attempts++
continue
}
parsed := map[string]any{}
if len(payloadBytes) > 0 {
if err := json.Unmarshal(payloadBytes, &parsed); err != nil {
config.Logger.Warn("[upload_file] json parse failed", "status", resp.StatusCode, "preview", preview(payloadBytes))
}
}
code, bizCode, msg, bizMsg := extractResponseStatus(parsed)
if resp.StatusCode == http.StatusOK && code == 0 && bizCode == 0 {
result := extractUploadFileResult(parsed)
result.Raw = parsed
result.RawHeaders = resp.Header.Clone()
if result.Filename == "" {
result.Filename = filename
}
if result.Bytes == 0 {
result.Bytes = int64(len(req.Data))
}
if result.Purpose == "" {
result.Purpose = purpose
}
if result.ID == "" {
return nil, errors.New("upload file succeeded without file id")
}
return result, nil
}
config.Logger.Warn("[upload_file] failed", "status", resp.StatusCode, "code", code, "biz_code", bizCode, "msg", msg, "biz_msg", bizMsg, "account", a.AccountID, "filename", filename)
powHeader = ""
if a.UseConfigToken {
if !refreshed && shouldAttemptRefresh(resp.StatusCode, code, bizCode, msg, bizMsg) {
if c.Auth.RefreshToken(ctx, a) {
refreshed = true
attempts++
continue
}
}
if c.Auth.SwitchAccount(ctx, a) {
refreshed = false
attempts++
continue
}
}
attempts++
}
return nil, errors.New("upload file failed")
}
func buildUploadMultipartBody(filename, contentType, purpose string, data []byte) ([]byte, string, error) {
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
if strings.TrimSpace(purpose) != "" {
if err := writer.WriteField("purpose", purpose); err != nil {
return nil, "", err
}
}
partHeader := textproto.MIMEHeader{}
partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename=%q`, escapeMultipartFilename(filename)))
partHeader.Set("Content-Type", contentType)
part, err := writer.CreatePart(partHeader)
if err != nil {
return nil, "", err
}
if _, err := part.Write(data); err != nil {
return nil, "", err
}
if err := writer.Close(); err != nil {
return nil, "", err
}
return buf.Bytes(), writer.FormDataContentType(), nil
}
func escapeMultipartFilename(filename string) string {
filename = filepath.Base(strings.TrimSpace(filename))
filename = strings.ReplaceAll(filename, `\`, "_")
filename = strings.ReplaceAll(filename, `"`, "_")
if filename == "." || filename == "" {
return "upload.bin"
}
return filename
}
func (c *Client) doUpload(ctx context.Context, doer trans.Doer, fallback trans.Doer, url string, headers map[string]string, body []byte) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := doer.Do(req)
if err == nil {
return resp, nil
}
config.Logger.Warn("[deepseek] fingerprint upload request failed, fallback to std transport", "url", url, "error", err)
req2, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if reqErr != nil {
return nil, reqErr
}
for k, v := range headers {
req2.Header.Set(k, v)
}
return fallback.Do(req2)
}
func extractUploadFileResult(resp map[string]any) *UploadFileResult {
result := &UploadFileResult{Status: "uploaded"}
data, _ := resp["data"].(map[string]any)
bizData, _ := data["biz_data"].(map[string]any)
searchMaps := []map[string]any{resp, data, bizData}
for _, parent := range []map[string]any{resp, data, bizData} {
if parent == nil {
continue
}
for _, key := range []string{"file", "biz_data", "data"} {
if nested, ok := parent[key].(map[string]any); ok {
searchMaps = append(searchMaps, nested)
}
}
}
for _, m := range searchMaps {
if m == nil {
continue
}
if result.ID == "" {
result.ID = firstNonEmptyString(m, "id", "file_id")
}
if result.Filename == "" {
result.Filename = firstNonEmptyString(m, "name", "filename", "file_name")
}
if result.Status == "uploaded" {
if status := firstNonEmptyString(m, "status", "file_status"); status != "" {
result.Status = status
}
}
if result.Purpose == "" {
result.Purpose = firstNonEmptyString(m, "purpose")
}
if result.Bytes == 0 {
result.Bytes = firstPositiveInt64(m, "bytes", "size", "file_size")
}
}
return result
}
func firstNonEmptyString(m map[string]any, keys ...string) string {
for _, key := range keys {
if v, _ := m[key].(string); strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func firstPositiveInt64(m map[string]any, keys ...string) int64 {
for _, key := range keys {
if v := toInt64(m[key], 0); v > 0 {
return v
}
}
return 0
}

View File

@@ -0,0 +1,143 @@
package deepseek
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"ds2api/internal/auth"
powpkg "ds2api/pow"
)
func TestBuildUploadMultipartBodyIncludesPurposeAndFilePart(t *testing.T) {
body, contentType, err := buildUploadMultipartBody(`../demo.txt`, "text/plain", "assistants", []byte("hello"))
if err != nil {
t.Fatalf("buildUploadMultipartBody error: %v", err)
}
if !strings.HasPrefix(contentType, "multipart/form-data; boundary=") {
t.Fatalf("unexpected content type: %q", contentType)
}
payload := string(body)
if !strings.Contains(payload, `name="purpose"`) || !strings.Contains(payload, "assistants") {
t.Fatalf("expected purpose field in payload: %q", payload)
}
if !strings.Contains(payload, `name="file"; filename="demo.txt"`) {
t.Fatalf("expected sanitized filename in payload: %q", payload)
}
if !strings.Contains(payload, "Content-Type: text/plain") {
t.Fatalf("expected file content type in payload: %q", payload)
}
if !strings.Contains(payload, "hello") {
t.Fatalf("expected file content in payload: %q", payload)
}
}
func TestExtractUploadFileResultSupportsNestedShapes(t *testing.T) {
got := extractUploadFileResult(map[string]any{
"data": map[string]any{
"biz_data": map[string]any{
"file": map[string]any{
"file_id": "file_123",
"file_name": "report.pdf",
"file_size": 99,
"status": "processed",
"purpose": "assistants",
},
},
},
})
if got.ID != "file_123" {
t.Fatalf("expected id file_123, got %#v", got)
}
if got.Filename != "report.pdf" {
t.Fatalf("expected filename report.pdf, got %#v", got)
}
if got.Bytes != 99 {
t.Fatalf("expected bytes 99, got %#v", got)
}
if got.Status != "processed" {
t.Fatalf("expected status processed, got %#v", got)
}
if got.Purpose != "assistants" {
t.Fatalf("expected purpose assistants, got %#v", got)
}
}
func TestUploadFileUsesUploadTargetPowAndMultipartHeaders(t *testing.T) {
challengeHash := powpkg.DeepSeekHashV1([]byte(powpkg.BuildPrefix("salt", 1712345678) + "42"))
powResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"challenge":{"algorithm":"DeepSeekHashV1","challenge":"` + hex.EncodeToString(challengeHash[:]) + `","salt":"salt","expire_at":1712345678,"difficulty":1000,"signature":"sig","target_path":"` + DeepSeekUploadTargetPath + `"}}}}`
uploadResponse := `{"code":0,"msg":"ok","data":{"biz_code":0,"biz_data":{"file":{"file_id":"file_789","filename":"demo.txt","bytes":5,"status":"uploaded","purpose":"assistants"}}}}`
var seenPow string
var seenTargetPath string
var seenContentType string
var seenFileSize string
var seenBody string
call := 0
client := &Client{
regular: doerFunc(func(req *http.Request) (*http.Response, error) {
call++
bodyBytes, _ := io.ReadAll(req.Body)
switch call {
case 1:
seenTargetPath = string(bodyBytes)
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(powResponse)), Request: req}, nil
case 2:
seenPow = req.Header.Get("x-ds-pow-response")
seenContentType = req.Header.Get("Content-Type")
seenFileSize = req.Header.Get("x-file-size")
seenBody = string(bodyBytes)
return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(uploadResponse)), Request: req}, nil
default:
t.Fatalf("unexpected request count %d", call)
return nil, nil
}
}),
fallback: &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return nil, nil
})},
maxRetries: 1,
}
result, err := client.UploadFile(context.Background(), &auth.RequestAuth{DeepSeekToken: "token", TriedAccounts: map[string]bool{}}, UploadFileRequest{
Filename: "demo.txt",
ContentType: "text/plain",
Purpose: "assistants",
Data: []byte("hello"),
}, 1)
if err != nil {
t.Fatalf("UploadFile error: %v", err)
}
if result.ID != "file_789" {
t.Fatalf("expected uploaded file id file_789, got %#v", result)
}
if !strings.Contains(seenTargetPath, `"target_path":"`+DeepSeekUploadTargetPath+`"`) {
t.Fatalf("expected upload target_path in pow request, got %q", seenTargetPath)
}
if strings.TrimSpace(seenPow) == "" {
t.Fatal("expected x-ds-pow-response header")
}
rawPow, err := base64.StdEncoding.DecodeString(seenPow)
if err != nil {
t.Fatalf("decode pow header failed: %v", err)
}
var powHeader map[string]any
if err := json.Unmarshal(rawPow, &powHeader); err != nil {
t.Fatalf("unmarshal pow header failed: %v", err)
}
if powHeader["target_path"] != DeepSeekUploadTargetPath {
t.Fatalf("expected pow target_path %q, got %#v", DeepSeekUploadTargetPath, powHeader["target_path"])
}
if seenFileSize != "5" {
t.Fatalf("expected x-file-size=5, got %q", seenFileSize)
}
if !strings.HasPrefix(seenContentType, "multipart/form-data; boundary=") {
t.Fatalf("expected multipart content type, got %q", seenContentType)
}
if !strings.Contains(seenBody, `name="file"; filename="demo.txt"`) {
t.Fatalf("expected file part in upload body: %q", seenBody)
}
}

View File

@@ -12,16 +12,19 @@ const (
DeepSeekCreatePowURL = "https://chat.deepseek.com/api/v0/chat/create_pow_challenge"
DeepSeekCompletionURL = "https://chat.deepseek.com/api/v0/chat/completion"
DeepSeekContinueURL = "https://chat.deepseek.com/api/v0/chat/continue"
DeepSeekUploadFileURL = "https://chat.deepseek.com/api/v0/file/upload_file"
DeepSeekFetchFilesURL = "https://chat.deepseek.com/api/v0/file/fetch_files"
DeepSeekFetchSessionURL = "https://chat.deepseek.com/api/v0/chat_session/fetch_page"
DeepSeekDeleteSessionURL = "https://chat.deepseek.com/api/v0/chat_session/delete"
DeepSeekDeleteAllSessionsURL = "https://chat.deepseek.com/api/v0/chat_session/delete_all"
DeepSeekCompletionTargetPath = "/api/v0/chat/completion"
DeepSeekUploadTargetPath = "/api/v0/file/upload_file"
)
var defaultBaseHeaders = map[string]string{
"Host": "chat.deepseek.com",
"User-Agent": "DeepSeek/1.8.0 Android/35",
"Accept": "application/json",
"Content-Type": "application/json",
"x-client-platform": "android",
"x-client-version": "1.8.0",
"x-client-locale": "zh_CN",

View File

@@ -3,7 +3,6 @@
"Host": "chat.deepseek.com",
"User-Agent": "DeepSeek/1.8.0 Android/35",
"Accept": "application/json",
"Content-Type": "application/json",
"x-client-platform": "android",
"x-client-version": "1.8.0",
"x-client-locale": "zh_CN",