mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-04 00:15:28 +08:00
259 lines
7.2 KiB
Go
259 lines
7.2 KiB
Go
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
|
|
}
|