| package client |
|
|
| import ( |
| "bytes" |
| "context" |
| dsprotocol "ds2api/internal/deepseek/protocol" |
| "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 |
| ModelType string |
| Data []byte |
| } |
|
|
| type UploadFileResult struct { |
| ID string |
| Filename string |
| Bytes int64 |
| Status string |
| Purpose string |
| AccountID string |
| IsImage bool |
| 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) |
| modelType := strings.ToLower(strings.TrimSpace(req.ModelType)) |
| body, contentTypeHeader, err := buildUploadMultipartBody(filename, contentType, req.Data) |
| if err != nil { |
| return nil, err |
| } |
| capturePayload := map[string]any{ |
| "filename": filename, |
| "content_type": contentType, |
| "purpose": purpose, |
| "bytes": len(req.Data), |
| } |
| if modelType != "" { |
| capturePayload["model_type"] = modelType |
| } |
| captureSession := c.capture.Start("deepseek_upload_file", dsprotocol.DeepSeekUploadFileURL, a.AccountID, capturePayload) |
| attempts := 0 |
| refreshed := false |
| powHeader := "" |
| lastFailureKind := FailureUnknown |
| lastFailureMessage := "" |
| for attempts < maxAttempts { |
| clients := c.requestClientsForAuth(ctx, a) |
| if strings.TrimSpace(powHeader) == "" { |
| powHeader, err = c.GetPowForTarget(ctx, a, dsprotocol.DeepSeekUploadTargetPath, maxAttempts) |
| if err != nil { |
| return nil, err |
| } |
| clients = c.requestClientsForAuth(ctx, a) |
| } |
| headers := c.authHeaders(a.DeepSeekToken) |
| headers["Content-Type"] = contentTypeHeader |
| if modelType != "" { |
| headers["x-model-type"] = modelType |
| } |
| 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, dsprotocol.DeepSeekUploadFileURL, headers, body) |
| if err != nil { |
| config.Logger.Warn("[upload_file] request error", "error", err, "account", a.AccountID, "filename", filename) |
| powHeader = "" |
| lastFailureKind = FailureUnknown |
| lastFailureMessage = err.Error() |
| 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.AccountID == "" { |
| result.AccountID = a.AccountID |
| } |
| if result.ID == "" { |
| return nil, errors.New("upload file succeeded without file id") |
| } |
| if err := c.waitForUploadedFile(ctx, a, result); err != nil { |
| return nil, err |
| } |
| 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 = "" |
| lastFailureMessage = failureMessage(msg, bizMsg, "upload file failed") |
| if isTokenInvalid(resp.StatusCode, code, bizCode, msg, bizMsg) || isAuthIndicativeBizFailure(msg, bizMsg) { |
| lastFailureKind = authFailureKind(a.UseConfigToken) |
| } else { |
| lastFailureKind = FailureUnknown |
| } |
| 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++ |
| } |
| if lastFailureKind != FailureUnknown { |
| return nil, &RequestFailure{Op: "upload file", Kind: lastFailureKind, Message: lastFailureMessage} |
| } |
| return nil, errors.New("upload file failed") |
| } |
|
|
| func buildUploadMultipartBody(filename, contentType string, data []byte) ([]byte, string, error) { |
| var buf bytes.Buffer |
| writer := multipart.NewWriter(&buf) |
| 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.IsImage { |
| result.IsImage = firstBool(m, "is_image", "isImage") |
| } |
| if result.Purpose == "" { |
| result.Purpose = firstNonEmptyString(m, "purpose") |
| } |
| if result.AccountID == "" { |
| result.AccountID = firstNonEmptyString(m, "account_id", "accountId", "owner_account_id", "ownerAccountId") |
| } |
| if result.Bytes == 0 { |
| result.Bytes = firstPositiveInt64(m, "bytes", "size", "file_size") |
| } |
| } |
| return result |
| } |
|
|
| func firstBool(m map[string]any, keys ...string) bool { |
| for _, key := range keys { |
| switch v := m[key].(type) { |
| case bool: |
| return v |
| case string: |
| switch strings.ToLower(strings.TrimSpace(v)) { |
| case "true", "1", "yes", "y": |
| return true |
| } |
| } |
| } |
| return false |
| } |
|
|
| 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 |
| } |
|
|