From 4c0c2adb20f2891e0fadb185335d5bf8c476fd66 Mon Sep 17 00:00:00 2001 From: hanzhijian Date: Wed, 17 Jun 2026 22:09:04 +0800 Subject: [PATCH 1/2] feat: add DeepSeek web (cookie-based) account support Add support for DeepSeek accounts authenticated via browser cookies and tokens, enabling free API access through the DeepSeek web interface. Backend changes: - Add deepseek package: PoW solver (Keccak), HTTP client, SSE stream converter - Add gateway handler routing DeepSeek requests through OpenAI-compatible API - Register DeepSeek platform in domain constants and validation whitelists - Support account creation with Cookie/Token authentication - Route /v1/chat/completions to DeepSeek handler when account is DeepSeek type Frontend changes: - Add DeepSeek Cookie/Token input fields in CreateAccountModal - Hide generic API Key input for DeepSeek accounts in EditAccountModal - Add DeepSeek platform badge, colors, and i18n entries - Support DeepSeek in group creation platform selector Tested with deepseek-chat, deepseek-v4-pro, deepseek-coder, deepseek-reasoner, deepseek-r1 in both stream and non-stream modes. Signed-off-by: hanzhijian --- backend/internal/domain/constants.go | 1 + .../internal/handler/admin/account_handler.go | 34 ++ .../internal/handler/admin/group_handler.go | 4 +- backend/internal/pkg/deepseek/client.go | 170 +++++++++ backend/internal/pkg/deepseek/constants.go | 33 ++ backend/internal/pkg/deepseek/pow.go | 303 +++++++++++++++ backend/internal/pkg/deepseek/stream.go | 229 +++++++++++ backend/internal/server/routes/admin.go | 1 + backend/internal/server/routes/gateway.go | 31 +- backend/internal/service/account.go | 2 +- backend/internal/service/domain_constants.go | 1 + .../openai_gateway_chat_completions.go | 5 + .../service/openai_gateway_deepseek.go | 355 ++++++++++++++++++ .../service/openai_gateway_service.go | 14 +- .../components/account/CreateAccountModal.vue | 156 +++++++- .../components/account/EditAccountModal.vue | 6 +- .../components/common/PlatformTypeBadge.vue | 1 + frontend/src/i18n/locales/en.ts | 2 + frontend/src/i18n/locales/zh.ts | 2 + frontend/src/types/index.ts | 4 +- frontend/src/utils/platformColors.ts | 16 +- frontend/src/views/admin/GroupsView.vue | 2 + 22 files changed, 1343 insertions(+), 29 deletions(-) create mode 100644 backend/internal/pkg/deepseek/client.go create mode 100644 backend/internal/pkg/deepseek/constants.go create mode 100644 backend/internal/pkg/deepseek/pow.go create mode 100644 backend/internal/pkg/deepseek/stream.go create mode 100644 backend/internal/service/openai_gateway_deepseek.go diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 4ed3f72b3a6..f9cb9933c7b 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -22,6 +22,7 @@ const ( PlatformOpenAI = "openai" PlatformGemini = "gemini" PlatformAntigravity = "antigravity" + PlatformDeepseek = "deepseek" ) // Account type constants diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index 65ad5804fc1..92ee2fca2a9 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -20,6 +20,7 @@ import ( "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/deepseek" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" @@ -2426,3 +2427,36 @@ func sanitizeExtraBaseRPM(extra map[string]any) { } extra["base_rpm"] = v } + +// DeepSeekCookieAuthRequest represents the request for DeepSeek cookie-based authentication +type DeepSeekCookieAuthRequest struct { + Token string `json:"token" binding:"required"` + Cookie string `json:"cookie"` +} + +// DeepSeekCookieAuth validates DeepSeek credentials by creating a test session. +// POST /api/v1/admin/accounts/deepseek-cookie-auth +func (h *AccountHandler) DeepSeekCookieAuth(c *gin.Context) { + var req DeepSeekCookieAuthRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + + dsClient := deepseek.NewClient(nil) + sessionID, err := dsClient.CreateSession(c.Request.Context(), req.Token, req.Cookie) + if err != nil { + response.BadRequest(c, "DeepSeek authentication failed: "+err.Error()) + return + } + + go func() { + bgCtx := context.Background() + _ = dsClient.DeleteSession(bgCtx, req.Token, req.Cookie, sessionID) + }() + + response.Success(c, gin.H{ + "valid": true, + "session_id": sessionID, + }) +} diff --git a/backend/internal/handler/admin/group_handler.go b/backend/internal/handler/admin/group_handler.go index 0468fc34e9b..4fa976bc3a4 100644 --- a/backend/internal/handler/admin/group_handler.go +++ b/backend/internal/handler/admin/group_handler.go @@ -84,7 +84,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic type CreateGroupRequest struct { Name string `json:"name" binding:"required"` Description string `json:"description"` - Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity deepseek"` RateMultiplier float64 `json:"rate_multiplier"` IsExclusive bool `json:"is_exclusive"` SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"` @@ -124,7 +124,7 @@ type CreateGroupRequest struct { type UpdateGroupRequest struct { Name string `json:"name"` Description *string `json:"description"` - Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"` + Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity deepseek"` RateMultiplier *float64 `json:"rate_multiplier"` IsExclusive *bool `json:"is_exclusive"` Status string `json:"status" binding:"omitempty,oneof=active inactive"` diff --git a/backend/internal/pkg/deepseek/client.go b/backend/internal/pkg/deepseek/client.go new file mode 100644 index 00000000000..11d5938376e --- /dev/null +++ b/backend/internal/pkg/deepseek/client.go @@ -0,0 +1,170 @@ +package deepseek + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Client is a DeepSeek web API client. +type Client struct { + httpClient *http.Client +} + +// defaultClient is a shared HTTP client with connection pooling. +var defaultClient = &http.Client{Timeout: 300 * time.Second} + +// NewClient creates a new DeepSeek client. Pass nil for the shared default client. +func NewClient(httpClient *http.Client) *Client { + if httpClient == nil { + httpClient = defaultClient + } + return &Client{httpClient: httpClient} +} + +// CreateSession creates a new chat session and returns the session ID. +func (c *Client) CreateSession(ctx context.Context, token, cookie string) (string, error) { + body, err := c.doJSON(ctx, http.MethodPost, DeepSeekCreateSessionURL, token, cookie, map[string]any{}) + if err != nil { + return "", fmt.Errorf("create session: %w", err) + } + bizData, err := extractBizData(body) + if err != nil { + return "", err + } + // Try data.biz_data.chat_session.id first (some versions) + if chatSession, ok := bizData["chat_session"].(map[string]any); ok { + if id, ok := chatSession["id"].(string); ok && id != "" { + return id, nil + } + } + // Fallback: data.biz_data.id (direct session id) + if id, ok := bizData["id"].(string); ok && id != "" { + return id, nil + } + return "", fmt.Errorf("create session: no session id in response") +} + +// DeleteSession deletes a chat session. +func (c *Client) DeleteSession(ctx context.Context, token, cookie, sessionID string) error { + _, err := c.doJSON(ctx, http.MethodPost, DeepSeekDeleteSessionURL, token, cookie, map[string]any{ + "chat_session_id": sessionID, + }) + return err +} + +// CreatePowChallenge gets a PoW challenge for the completion endpoint. +func (c *Client) CreatePowChallenge(ctx context.Context, token, cookie string) (*PowChallenge, error) { + body, err := c.doJSON(ctx, http.MethodPost, DeepSeekCreatePowURL, token, cookie, map[string]any{ + "target_path": CompletionTargetPath, + }) + if err != nil { + return nil, fmt.Errorf("get pow challenge: %w", err) + } + bizData, err := extractBizData(body) + if err != nil { + return nil, err + } + challengeData, _ := bizData["challenge"].(map[string]any) + if challengeData == nil { + return nil, fmt.Errorf("get pow challenge: missing challenge in response") + } + b, _ := json.Marshal(challengeData) + var challenge PowChallenge + if err := json.Unmarshal(b, &challenge); err != nil { + return nil, fmt.Errorf("parse challenge: %w", err) + } + return &challenge, nil +} + +// CallCompletion sends a chat completion request and returns the streaming response. +func (c *Client) CallCompletion(ctx context.Context, token, cookie, powHeader string, payload map[string]any) (*http.Response, error) { + b, err := json.Marshal(payload) + if err != nil { + return nil, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, DeepSeekCompletionURL, bytes.NewReader(b)) + if err != nil { + return nil, err + } + c.setHeaders(httpReq, token, cookie) + httpReq.Header.Set("x-ds-pow-response", powHeader) + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, err + } + return resp, nil +} + +// ValidateToken checks if a DeepSeek token is valid by creating and deleting a session. +func (c *Client) ValidateToken(ctx context.Context, token, cookie string) error { + sessionID, err := c.CreateSession(ctx, token, cookie) + if err != nil { + return fmt.Errorf("token validation failed: %w", err) + } + _ = c.DeleteSession(ctx, token, cookie, sessionID) + return nil +} + +func (c *Client) doJSON(ctx context.Context, method, url, token, cookie string, payload any) (map[string]any, error) { + var body io.Reader + if payload != nil { + b, err := json.Marshal(payload) + if err != nil { + return nil, err + } + body = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + c.setHeaders(req, token, cookie) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody)) + } + var result map[string]any + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, err + } + return result, nil +} + +func (c *Client) setHeaders(req *http.Request, token, cookie string) { + for k, v := range defaultHeaders { + req.Header.Set(k, v) + } + req.Header.Set("Authorization", token) + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + req.Header.Set("Origin", DeepSeekBaseURL) + req.Header.Set("Referer", DeepSeekBaseURL+"/") +} + +func extractBizData(body map[string]any) (map[string]any, error) { + data, ok := body["data"].(map[string]any) + if !ok { + return nil, fmt.Errorf("missing data in response") + } + bizData, ok := data["biz_data"].(map[string]any) + if !ok { + return nil, fmt.Errorf("missing biz_data in response") + } + return bizData, nil +} diff --git a/backend/internal/pkg/deepseek/constants.go b/backend/internal/pkg/deepseek/constants.go new file mode 100644 index 00000000000..bd029f55019 --- /dev/null +++ b/backend/internal/pkg/deepseek/constants.go @@ -0,0 +1,33 @@ +package deepseek + +const ( + DeepSeekHost = "chat.deepseek.com" + DeepSeekBaseURL = "https://chat.deepseek.com" + DeepSeekCreateSessionURL = DeepSeekBaseURL + "/api/v0/chat_session/create" + DeepSeekCreatePowURL = DeepSeekBaseURL + "/api/v0/chat/create_pow_challenge" + DeepSeekCompletionURL = DeepSeekBaseURL + "/api/v0/chat/completion" + DeepSeekDeleteSessionURL = DeepSeekBaseURL + "/api/v0/chat_session/delete" + + CompletionTargetPath = "/api/v0/chat/completion" +) + +var defaultHeaders = map[string]string{ + "Host": DeepSeekHost, + "Accept": "*/*", + "Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7", + "Content-Type": "application/json", + "accept-charset": "UTF-8", + "x-app-version": "2.0.0", + "x-client-bundle-id": "com.deepseek.chat", + "x-client-locale": "zh_CN", + "x-client-platform": "web", + "x-client-version": "2.0.0", + "x-client-timezone-offset": "28800", + "sec-ch-ua": "\"Google Chrome\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"", + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": "\"Linux\"", + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", +} diff --git a/backend/internal/pkg/deepseek/pow.go b/backend/internal/pkg/deepseek/pow.go new file mode 100644 index 00000000000..eabc9deb823 --- /dev/null +++ b/backend/internal/pkg/deepseek/pow.go @@ -0,0 +1,303 @@ +// Package deepseek provides DeepSeek web API client with PoW solver. +// Ported from https://github.com/CJackHwang/ds2api +package deepseek + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "strconv" +) + +// PoW challenge response from DeepSeek API +type PowChallenge struct { + Algorithm string `json:"algorithm"` + Challenge string `json:"challenge"` + Salt string `json:"salt"` + ExpireAt int64 `json:"expire_at"` + Difficulty int64 `json:"difficulty"` + Signature string `json:"signature"` + TargetPath string `json:"target_path"` +} + +// Round constants for Keccak-f[1600] +var rc = [24]uint64{ + 0x0000000000000001, 0x0000000000008082, 0x800000000000808A, 0x8000000080008000, + 0x000000000000808B, 0x0000000080000001, 0x8000000080008081, 0x8000000000008009, + 0x000000000000008A, 0x0000000000000088, 0x0000000080008009, 0x000000008000000A, + 0x000000008000808B, 0x800000000000008B, 0x8000000000008089, 0x8000000000008003, + 0x8000000000008002, 0x8000000000000080, 0x000000000000800A, 0x800000008000000A, + 0x8000000080008081, 0x8000000000008080, 0x0000000080000001, 0x8000000080008008, +} + +func rotl64(v uint64, k uint) uint64 { return v<>(64-k) } + +// keccakF23 performs Keccak-f[1600] with rounds 1..23 (skipping round 0). +// This is the core of DeepSeekHashV1. +func keccakF23(s *[25]uint64) { + a0, a1, a2, a3, a4 := s[0], s[1], s[2], s[3], s[4] + a5, a6, a7, a8, a9 := s[5], s[6], s[7], s[8], s[9] + a10, a11, a12, a13, a14 := s[10], s[11], s[12], s[13], s[14] + a15, a16, a17, a18, a19 := s[15], s[16], s[17], s[18], s[19] + a20, a21, a22, a23, a24 := s[20], s[21], s[22], s[23], s[24] + + for r := 1; r < 24; r++ { + c0 := a0 ^ a5 ^ a10 ^ a15 ^ a20 + c1 := a1 ^ a6 ^ a11 ^ a16 ^ a21 + c2 := a2 ^ a7 ^ a12 ^ a17 ^ a22 + c3 := a3 ^ a8 ^ a13 ^ a18 ^ a23 + c4 := a4 ^ a9 ^ a14 ^ a19 ^ a24 + d0 := c4 ^ rotl64(c1, 1) + d1 := c0 ^ rotl64(c2, 1) + d2 := c1 ^ rotl64(c3, 1) + d3 := c2 ^ rotl64(c4, 1) + d4 := c3 ^ rotl64(c0, 1) + a0 ^= d0 + a5 ^= d0 + a10 ^= d0 + a15 ^= d0 + a20 ^= d0 + a1 ^= d1 + a6 ^= d1 + a11 ^= d1 + a16 ^= d1 + a21 ^= d1 + a2 ^= d2 + a7 ^= d2 + a12 ^= d2 + a17 ^= d2 + a22 ^= d2 + a3 ^= d3 + a8 ^= d3 + a13 ^= d3 + a18 ^= d3 + a23 ^= d3 + a4 ^= d4 + a9 ^= d4 + a14 ^= d4 + a19 ^= d4 + a24 ^= d4 + + b0 := a0 + b10 := rotl64(a1, 1) + b20 := rotl64(a2, 62) + b5 := rotl64(a3, 28) + b15 := rotl64(a4, 27) + b16 := rotl64(a5, 36) + b1 := rotl64(a6, 44) + b11 := rotl64(a7, 6) + b21 := rotl64(a8, 55) + b6 := rotl64(a9, 20) + b7 := rotl64(a10, 3) + b17 := rotl64(a11, 10) + b2 := rotl64(a12, 43) + b12 := rotl64(a13, 25) + b22 := rotl64(a14, 39) + b23 := rotl64(a15, 41) + b8 := rotl64(a16, 45) + b18 := rotl64(a17, 15) + b3 := rotl64(a18, 21) + b13 := rotl64(a19, 8) + b14 := rotl64(a20, 18) + b24 := rotl64(a21, 2) + b9 := rotl64(a22, 61) + b19 := rotl64(a23, 56) + b4 := rotl64(a24, 14) + + a0 = b0 ^ (^b1 & b2) + a1 = b1 ^ (^b2 & b3) + a2 = b2 ^ (^b3 & b4) + a3 = b3 ^ (^b4 & b0) + a4 = b4 ^ (^b0 & b1) + a5 = b5 ^ (^b6 & b7) + a6 = b6 ^ (^b7 & b8) + a7 = b7 ^ (^b8 & b9) + a8 = b8 ^ (^b9 & b5) + a9 = b9 ^ (^b5 & b6) + a10 = b10 ^ (^b11 & b12) + a11 = b11 ^ (^b12 & b13) + a12 = b12 ^ (^b13 & b14) + a13 = b13 ^ (^b14 & b10) + a14 = b14 ^ (^b10 & b11) + a15 = b15 ^ (^b16 & b17) + a16 = b16 ^ (^b17 & b18) + a17 = b17 ^ (^b18 & b19) + a18 = b18 ^ (^b19 & b15) + a19 = b19 ^ (^b15 & b16) + a20 = b20 ^ (^b21 & b22) + a21 = b21 ^ (^b22 & b23) + a22 = b22 ^ (^b23 & b24) + a23 = b23 ^ (^b24 & b20) + a24 = b24 ^ (^b20 & b21) + + a0 ^= rc[r] + } + + s[0], s[1], s[2], s[3], s[4] = a0, a1, a2, a3, a4 + s[5], s[6], s[7], s[8], s[9] = a5, a6, a7, a8, a9 + s[10], s[11], s[12], s[13], s[14] = a10, a11, a12, a13, a14 + s[15], s[16], s[17], s[18], s[19] = a15, a16, a17, a18, a19 + s[20], s[21], s[22], s[23], s[24] = a20, a21, a22, a23, a24 +} + +// deepseekHashV1 returns the 32-byte hash of data using DeepSeekHashV1 algorithm. +// This is equivalent to SHA3-256 but with Keccak-f[1600] rounds 1..23 (skipping round 0). +func deepseekHashV1(data []byte) [32]byte { + const rate = 136 + var s [25]uint64 + + off := 0 + for off+rate <= len(data) { + for i := 0; i < rate/8; i++ { + s[i] ^= binary.LittleEndian.Uint64(data[off+i*8:]) + } + keccakF23(&s) + off += rate + } + + var final [rate]byte + copy(final[:], data[off:]) + final[len(data)-off] = 0x06 + final[rate-1] |= 0x80 + for i := 0; i < rate/8; i++ { + s[i] ^= binary.LittleEndian.Uint64(final[i*8:]) + } + keccakF23(&s) + + var out [32]byte + binary.LittleEndian.PutUint64(out[0:], s[0]) + binary.LittleEndian.PutUint64(out[8:], s[1]) + binary.LittleEndian.PutUint64(out[16:], s[2]) + binary.LittleEndian.PutUint64(out[24:], s[3]) + return out +} + +// buildPrefix builds the PoW prefix: "__" +func buildPrefix(salt string, expireAt int64) string { + return salt + "_" + strconv.FormatInt(expireAt, 10) + "_" +} + +// solvePow searches for nonce in [0, difficulty) such that +// DeepSeekHashV1(prefix + str(nonce)) equals the challenge. +func solvePow(ctx context.Context, challengeHex, salt string, expireAt, difficulty int64) (int64, error) { + if len(challengeHex) != 64 { + return 0, errors.New("pow: challenge must be 64 hex chars") + } + target, err := hex.DecodeString(challengeHex) + if err != nil { + return 0, err + } + var ta [32]byte + copy(ta[:], target) + t0 := binary.LittleEndian.Uint64(ta[0:]) + t1 := binary.LittleEndian.Uint64(ta[8:]) + t2 := binary.LittleEndian.Uint64(ta[16:]) + t3 := binary.LittleEndian.Uint64(ta[24:]) + + prefix := []byte(buildPrefix(salt, expireAt)) + const rate = 136 + var baseState [25]uint64 + off := 0 + for off+rate <= len(prefix) { + for i := 0; i < rate/8; i++ { + baseState[i] ^= binary.LittleEndian.Uint64(prefix[off+i*8:]) + } + keccakF23(&baseState) + off += rate + } + tailLen := len(prefix) - off + var tail [rate]byte + copy(tail[:], prefix[off:]) + + var numBuf [20]byte + for n := int64(0); n < difficulty; n++ { + if n&0x3FF == 0 { + if err := ctx.Err(); err != nil { + return 0, err + } + } + + v := uint64(n) + pos := 20 + if v == 0 { + pos-- + numBuf[pos] = '0' + } else { + for v > 0 { + pos-- + numBuf[pos] = byte('0' + v%10) + v /= 10 + } + } + numLen := 20 - pos + s := baseState + totalTail := tailLen + numLen + if totalTail < rate { + var buf [rate]byte + copy(buf[:tailLen], tail[:tailLen]) + copy(buf[tailLen:totalTail], numBuf[pos:]) + buf[totalTail] = 0x06 + buf[rate-1] |= 0x80 + for i := 0; i < rate/8; i++ { + s[i] ^= binary.LittleEndian.Uint64(buf[i*8:]) + } + keccakF23(&s) + } else { + var buf [rate]byte + copy(buf[:tailLen], tail[:tailLen]) + copy(buf[tailLen:rate], numBuf[pos:pos+(rate-tailLen)]) + for i := 0; i < rate/8; i++ { + s[i] ^= binary.LittleEndian.Uint64(buf[i*8:]) + } + keccakF23(&s) + var buf2 [rate]byte + rem := totalTail - rate + copy(buf2[:rem], numBuf[pos+(rate-tailLen):pos+(rate-tailLen)+rem]) + buf2[rem] = 0x06 + buf2[rate-1] |= 0x80 + for i := 0; i < rate/8; i++ { + s[i] ^= binary.LittleEndian.Uint64(buf2[i*8:]) + } + keccakF23(&s) + } + if s[0] == t0 && s[1] == t1 && s[2] == t2 && s[3] == t3 { + return n, nil + } + } + return 0, errors.New("pow: no solution within difficulty") +} + +// BuildPowHeader serializes the PoW response as base64(JSON) for the x-ds-pow-response header. +func BuildPowHeader(c *PowChallenge, answer int64) (string, error) { + b, err := json.Marshal(map[string]any{ + "algorithm": c.Algorithm, + "challenge": c.Challenge, + "salt": c.Salt, + "answer": answer, + "signature": c.Signature, + "target_path": c.TargetPath, + }) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(b), nil +} + +// SolveAndBuildHeader is the end-to-end function: Challenge → x-ds-pow-response header string. +func SolveAndBuildHeader(ctx context.Context, c *PowChallenge) (string, error) { + if c.Algorithm != "DeepSeekHashV1" { + return "", errors.New("pow: unsupported algorithm: " + c.Algorithm) + } + d := c.Difficulty + if d == 0 { + d = 144000 + } + answer, err := solvePow(ctx, c.Challenge, c.Salt, c.ExpireAt, d) + if err != nil { + return "", err + } + return BuildPowHeader(c, answer) +} diff --git a/backend/internal/pkg/deepseek/stream.go b/backend/internal/pkg/deepseek/stream.go new file mode 100644 index 00000000000..88dfff95bbb --- /dev/null +++ b/backend/internal/pkg/deepseek/stream.go @@ -0,0 +1,229 @@ +package deepseek + +import ( + "bufio" + "encoding/json" + "io" + "strings" + "time" +) + +// StreamConverter converts DeepSeek SSE events to OpenAI SSE format. +type StreamConverter struct { + reader *bufio.Reader + model string + created int64 + chunkID string + closed bool + finished bool +} + +// NewStreamConverter creates a new stream converter that reads from the given reader. +func NewStreamConverter(reader io.Reader, model string, completionID string) *StreamConverter { + return &StreamConverter{ + reader: bufio.NewReaderSize(reader, 64*1024), + model: model, + created: time.Now().Unix(), + chunkID: completionID, + } +} + +// NextEvent reads the next DeepSeek SSE event and returns the OpenAI SSE formatted string. +// Returns (event, done, error). When done is true, the stream has ended. +func (sc *StreamConverter) NextEvent() (string, bool, error) { + if sc.closed || sc.finished { + return "", true, nil + } + + for { + line, err := sc.reader.ReadBytes('\n') + if len(line) > 0 { + lineStr := strings.TrimSpace(string(line)) + + // Skip empty lines + if lineStr == "" { + continue + } + + // Skip SSE event name lines (e.g., "event: update_session", "event: title", "event: close") + if strings.HasPrefix(lineStr, "event:") { + continue + } + + // Handle [DONE] + if lineStr == "data: [DONE]" || lineStr == "[DONE]" { + sc.finished = true + return "data: [DONE]\n\n", true, nil + } + + // Strip "data: " prefix + data := lineStr + if strings.HasPrefix(data, "data: ") { + data = data[6:] + } + + // Parse DeepSeek JSON Patch event + var event map[string]any + if jsonErr := json.Unmarshal([]byte(data), &event); jsonErr != nil { + continue // Skip unparseable events + } + + chunk := sc.convertEvent(event) + if chunk != "" { + return chunk, false, nil + } + continue + } + if err != nil { + if err == io.EOF { + sc.finished = true + return "data: [DONE]\n\n", true, nil + } + return "", true, err + } + } +} + +// Close marks the converter as closed. +func (sc *StreamConverter) Close() { + sc.closed = true +} + +// convertEvent converts a single DeepSeek event to OpenAI SSE chunk. +func (sc *StreamConverter) convertEvent(event map[string]any) string { + path, _ := event["p"].(string) + op, _ := event["o"].(string) + value := event["v"] + + // New format: {"v":"text"} — content token without p/o fields + if path == "" && op == "" { + if text, ok := value.(string); ok && text != "" { + return sc.buildTextChunk(text) + } + return "" + } + + // Handle fragment content: {"p":"response/fragments/0/content","o":"REPLACE","v":"text"} + if strings.Contains(path, "fragments") && strings.HasSuffix(path, "/content") && op == "REPLACE" { + text, ok := value.(string) + if !ok || text == "" { + return "" + } + return sc.buildTextChunk(text) + } + + // Handle fragment append: {"p":"response/fragments","o":"APPEND","v":[{...}]} + if path == "response/fragments" && op == "APPEND" { + fragments, ok := value.([]any) + if !ok { + return "" + } + var result strings.Builder + for _, f := range fragments { + frag, ok := f.(map[string]any) + if !ok { + continue + } + fragType, _ := frag["type"].(string) + content, _ := frag["content"].(string) + if content == "" { + continue + } + if fragType == "THINKING" { + result.WriteString(sc.buildThinkingChunk(content)) + } else { + result.WriteString(sc.buildTextChunk(content)) + } + } + return result.String() + } + + // Handle status events for finish reason + if strings.Contains(path, "status") { + if statusStr, ok := value.(string); ok && statusStr == "FINISHED" { + return sc.buildFinishChunk() + } + } + + // Handle BATCH events (e.g., {"p":"response","o":"BATCH","v":[...]}) + if op == "BATCH" { + if items, ok := value.([]any); ok { + for _, item := range items { + if itemMap, ok := item.(map[string]any); ok { + // Check for status in batch + if p, _ := itemMap["p"].(string); strings.Contains(p, "status") { + if v, _ := itemMap["v"].(string); v == "FINISHED" { + return sc.buildFinishChunk() + } + } + // Check for quasi_status + if p, _ := itemMap["p"].(string); p == "quasi_status" { + if v, _ := itemMap["v"].(string); v == "FINISHED" { + return sc.buildFinishChunk() + } + } + } + } + } + } + + return "" +} + +func (sc *StreamConverter) buildTextChunk(text string) string { + chunk := map[string]any{ + "id": sc.chunkID, + "object": "chat.completion.chunk", + "created": sc.created, + "model": sc.model, + "choices": []map[string]any{ + { + "index": 0, + "delta": map[string]any{ + "content": text, + }, + "finish_reason": nil, + }, + }, + } + b, _ := json.Marshal(chunk) + return "data: " + string(b) + "\n\n" +} + +func (sc *StreamConverter) buildThinkingChunk(text string) string { + chunk := map[string]any{ + "id": sc.chunkID, + "object": "chat.completion.chunk", + "created": sc.created, + "model": sc.model, + "choices": []map[string]any{ + { + "index": 0, + "delta": map[string]any{ + "reasoning_content": text, + }, + "finish_reason": nil, + }, + }, + } + b, _ := json.Marshal(chunk) + return "data: " + string(b) + "\n\n" +} + +func (sc *StreamConverter) buildFinishChunk() string { + chunk := map[string]any{ + "id": sc.chunkID, + "object": "chat.completion.chunk", + "created": sc.created, + "model": sc.model, + "choices": []map[string]any{ + { + "index": 0, + "delta": map[string]any{}, + "finish_reason": "stop", + }, + }, + } + b, _ := json.Marshal(chunk) + return "data: " + string(b) + "\n\n" +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 24c52acb9e7..db60416f659 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -339,6 +339,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.POST("/exchange-setup-token-code", h.Admin.OAuth.ExchangeSetupTokenCode) accounts.POST("/cookie-auth", h.Admin.OAuth.CookieAuth) accounts.POST("/setup-token-cookie-auth", h.Admin.OAuth.SetupTokenCookieAuth) + accounts.POST("/deepseek-cookie-auth", h.Admin.Account.DeepSeekCookieAuth) } } diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index b039a6ecd48..5b52c86d783 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -42,7 +42,7 @@ func RegisterGatewayRoutes( { // /v1/messages: auto-route based on group platform gateway.POST("/messages", func(c *gin.Context) { - if getGroupPlatform(c) == service.PlatformOpenAI { + if isOpenAICompatiblePlatform(c) { h.OpenAIGateway.Messages(c) return } @@ -50,7 +50,7 @@ func RegisterGatewayRoutes( }) // /v1/messages/count_tokens: OpenAI groups get 404 gateway.POST("/messages/count_tokens", func(c *gin.Context) { - if getGroupPlatform(c) == service.PlatformOpenAI { + if isOpenAICompatiblePlatform(c) { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{ "type": "error", @@ -67,14 +67,14 @@ func RegisterGatewayRoutes( gateway.GET("/usage", h.Gateway.Usage) // OpenAI Responses API: auto-route based on group platform gateway.POST("/responses", func(c *gin.Context) { - if getGroupPlatform(c) == service.PlatformOpenAI { + if isOpenAICompatiblePlatform(c) { h.OpenAIGateway.Responses(c) return } h.Gateway.Responses(c) }) gateway.POST("/responses/*subpath", func(c *gin.Context) { - if getGroupPlatform(c) == service.PlatformOpenAI { + if isOpenAICompatiblePlatform(c) { h.OpenAIGateway.Responses(c) return } @@ -83,14 +83,14 @@ func RegisterGatewayRoutes( gateway.GET("/responses", h.OpenAIGateway.ResponsesWebSocket) // OpenAI Chat Completions API: auto-route based on group platform gateway.POST("/chat/completions", func(c *gin.Context) { - if getGroupPlatform(c) == service.PlatformOpenAI { + if isOpenAICompatiblePlatform(c) { h.OpenAIGateway.ChatCompletions(c) return } h.Gateway.ChatCompletions(c) }) gateway.POST("/embeddings", func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { + if !isOpenAICompatiblePlatform(c) { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{ "error": gin.H{ @@ -103,7 +103,7 @@ func RegisterGatewayRoutes( h.OpenAIGateway.Embeddings(c) }) gateway.POST("/images/generations", func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { + if !isOpenAICompatiblePlatform(c) { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{ "error": gin.H{ @@ -116,7 +116,7 @@ func RegisterGatewayRoutes( h.OpenAIGateway.Images(c) }) gateway.POST("/images/edits", func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { + if !isOpenAICompatiblePlatform(c) { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{ "error": gin.H{ @@ -147,7 +147,7 @@ func RegisterGatewayRoutes( // OpenAI Responses API(不带v1前缀的别名)— auto-route based on group platform responsesHandler := func(c *gin.Context) { - if getGroupPlatform(c) == service.PlatformOpenAI { + if isOpenAICompatiblePlatform(c) { h.OpenAIGateway.Responses(c) return } @@ -165,14 +165,14 @@ func RegisterGatewayRoutes( } // OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { - if getGroupPlatform(c) == service.PlatformOpenAI { + if isOpenAICompatiblePlatform(c) { h.OpenAIGateway.ChatCompletions(c) return } h.Gateway.ChatCompletions(c) }) r.POST("/embeddings", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { + if !isOpenAICompatiblePlatform(c) { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{ "error": gin.H{ @@ -185,7 +185,7 @@ func RegisterGatewayRoutes( h.OpenAIGateway.Embeddings(c) }) r.POST("/images/generations", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { + if !isOpenAICompatiblePlatform(c) { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{ "error": gin.H{ @@ -198,7 +198,7 @@ func RegisterGatewayRoutes( h.OpenAIGateway.Images(c) }) r.POST("/images/edits", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), requireGroupAnthropic, func(c *gin.Context) { - if getGroupPlatform(c) != service.PlatformOpenAI { + if !isOpenAICompatiblePlatform(c) { service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate) c.JSON(http.StatusNotFound, gin.H{ "error": gin.H{ @@ -247,6 +247,11 @@ func RegisterGatewayRoutes( } // getGroupPlatform extracts the group platform from the API Key stored in context. +// isOpenAICompatiblePlatform checks if the platform should be routed through the OpenAI gateway. +func isOpenAICompatiblePlatform(c *gin.Context) bool { + p := getGroupPlatform(c) + return p == service.PlatformOpenAI || p == service.PlatformDeepseek +} func getGroupPlatform(c *gin.Context) string { apiKey, ok := middleware.GetAPIKeyFromContext(c) if !ok || apiKey.Group == nil { diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 42c7674462e..9e01b283bc5 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1049,7 +1049,7 @@ func (a *Account) IsAPIKeyOrBedrock() bool { } func (a *Account) IsOpenAI() bool { - return a.Platform == PlatformOpenAI + return a.Platform == PlatformOpenAI || a.Platform == PlatformDeepseek } func (a *Account) IsAnthropic() bool { diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index 3be2df7eae1..315a3a7acb2 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -41,6 +41,7 @@ const ( PlatformOpenAI = domain.PlatformOpenAI PlatformGemini = domain.PlatformGemini PlatformAntigravity = domain.PlatformAntigravity + PlatformDeepseek = domain.PlatformDeepseek ) // AllowedQuotaPlatforms 是允许设置 user × platform quota 的平台列表(单一权威来源)。 diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index a1051afe5e0..a0fce742412 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -61,6 +61,11 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { + // DeepSeek cookie accounts: route to DeepSeek-specific handler + if account.Platform == PlatformDeepseek { + return s.forwardAsDeepSeekChatCompletions(ctx, c, account, body) + } + // 入口分流:APIKey 账号 + 强制或已探测确认上游不支持 Responses,走 CC 直转。 // 自动模式下标记缺失(未探测)按"现状即证据"原则继续走下方原 Responses 转换路径。 if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { diff --git a/backend/internal/service/openai_gateway_deepseek.go b/backend/internal/service/openai_gateway_deepseek.go new file mode 100644 index 00000000000..fbbe16a9ffc --- /dev/null +++ b/backend/internal/service/openai_gateway_deepseek.go @@ -0,0 +1,355 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/deepseek" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/tidwall/gjson" + "go.uber.org/zap" +) + +// forwardAsDeepSeekChatCompletions handles DeepSeek cookie-based accounts. +func (s *OpenAIGatewayService) forwardAsDeepSeekChatCompletions( + ctx context.Context, + c *gin.Context, + account *Account, + body []byte, +) (*OpenAIForwardResult, error) { + startTime := time.Now() + + originalModel := gjson.GetBytes(body, "model").String() + if originalModel == "" { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "model is required") + return nil, fmt.Errorf("missing model in request") + } + clientStream := gjson.GetBytes(body, "stream").Bool() + + token, _ := account.Credentials["token"].(string) + cookie, _ := account.Credentials["cookie"].(string) + if token == "" { + writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "DeepSeek token is required") + return nil, fmt.Errorf("missing DeepSeek token in credentials") + } + + dsClient := deepseek.NewClient(nil) + + sessionID, err := dsClient.CreateSession(ctx, token, cookie) + if err != nil { + logger.L().Error("deepseek: create session failed", zap.Int64("account_id", account.ID), zap.Error(err)) + writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", "Failed to create DeepSeek session") + return nil, fmt.Errorf("deepseek create session: %w", err) + } + + defer func() { + go func() { + bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = dsClient.DeleteSession(bgCtx, token, cookie, sessionID) + }() + }() + + challenge, err := dsClient.CreatePowChallenge(ctx, token, cookie) + if err != nil { + logger.L().Error("deepseek: pow challenge failed", zap.Int64("account_id", account.ID), zap.Error(err)) + writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", "Failed to get DeepSeek PoW challenge") + return nil, fmt.Errorf("deepseek pow challenge: %w", err) + } + + powResp, err := deepseek.SolveAndBuildHeader(ctx, challenge) + if err != nil { + logger.L().Error("deepseek: pow solve failed", zap.Int64("account_id", account.ID), zap.Error(err)) + writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", "Failed to solve DeepSeek PoW") + return nil, fmt.Errorf("deepseek pow solve: %w", err) + } + + completionPayload := buildDeepSeekPayload(body, sessionID) + + resp, err := dsClient.CallCompletion(ctx, token, cookie, powResp, completionPayload) + if err != nil { + logger.L().Error("deepseek: completion failed", zap.Int64("account_id", account.ID), zap.Error(err)) + writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", "DeepSeek completion request failed") + return nil, fmt.Errorf("deepseek completion: %w", err) + } + + if resp.StatusCode != http.StatusOK { + errBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + logger.L().Error("deepseek: completion error", + zap.Int64("account_id", account.ID), + zap.Int("status", resp.StatusCode), + zap.String("body", string(errBody)), + ) + writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", "DeepSeek completion error") + return nil, fmt.Errorf("deepseek completion: status=%d body=%s", resp.StatusCode, string(errBody)) + } + + completionID := "chatcmpl-" + uuid.New().String()[:8] + + if clientStream { + return s.handleDeepSeekStreamResponse(c, resp, completionID, originalModel, startTime) + } + + return s.handleDeepSeekNonStreamResponse(c, resp, completionID, originalModel, startTime) +} + +func (s *OpenAIGatewayService) handleDeepSeekStreamResponse( + c *gin.Context, + resp *http.Response, + completionID, model string, + startTime time.Time, +) (*OpenAIForwardResult, error) { + defer func() { _ = resp.Body.Close() }() + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache, no-transform") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + converter := deepseek.NewStreamConverter(resp.Body, model, completionID) + defer converter.Close() + + flusher, canFlush := c.Writer.(http.Flusher) + + for { + event, done, err := converter.NextEvent() + if err != nil { + logger.L().Error("deepseek: stream read error", zap.Error(err)) + break + } + + if _, writeErr := c.Writer.WriteString(event); writeErr != nil { + break + } + if canFlush { + flusher.Flush() + } + + if done { + break + } + } + + return &OpenAIForwardResult{ + RequestID: completionID, + Stream: true, + Duration: time.Since(startTime), + }, nil +} + +// handleDeepSeekNonStreamResponse reads DeepSeek SSE stream and accumulates content. +func (s *OpenAIGatewayService) handleDeepSeekNonStreamResponse( + c *gin.Context, + resp *http.Response, + completionID, model string, + startTime time.Time, +) (*OpenAIForwardResult, error) { + defer func() { _ = resp.Body.Close() }() + + var fullContent string + var fullThinking string + + // Read entire body first — resp.Body may already be partially consumed + bodyBytes, err := io.ReadAll(resp.Body) + logger.L().Info("deepseek: non-stream body read", zap.Int("bytes", len(bodyBytes)), zap.Error(err)) + + // Parse each line of the SSE stream + lines := strings.Split(string(bodyBytes), "\n") + for _, lineStr := range lines { + lineStr = strings.TrimSpace(lineStr) + + // Skip empty lines and SSE event name lines + if lineStr == "" || strings.HasPrefix(lineStr, "event:") { + continue + } + + // Handle [DONE] + if lineStr == "data: [DONE]" || lineStr == "[DONE]" { + break + } + + // Strip "data: " prefix + data := lineStr + if strings.HasPrefix(data, "data: ") { + data = data[6:] + } + + // Parse JSON event + var event map[string]any + if jsonErr := json.Unmarshal([]byte(data), &event); jsonErr != nil { + continue + } + + // Extract content from DeepSeek events + path, _ := event["p"].(string) + op, _ := event["o"].(string) + value := event["v"] + + // New format: {"v":"text"} — content token without p/o fields + if path == "" && op == "" { + if text, ok := value.(string); ok && text != "" { + fullContent += text + } + continue + } + + // Old format: fragment content + if strings.Contains(path, "fragments") && strings.HasSuffix(path, "/content") && op == "REPLACE" { + if text, ok := value.(string); ok && text != "" { + fullContent += text + } + continue + } + + // Old format: fragment append + if path == "response/fragments" && op == "APPEND" { + if fragments, ok := value.([]any); ok { + for _, f := range fragments { + if frag, ok := f.(map[string]any); ok { + fragType, _ := frag["type"].(string) + content, _ := frag["content"].(string) + if content == "" { + continue + } + if fragType == "THINKING" { + fullThinking += content + } else { + fullContent += content + } + } + } + } + continue + } + + // BATCH and status events don't contribute content, just skip + } + + // Build OpenAI Chat Completion response + response := map[string]any{ + "id": completionID, + "object": "chat.completion", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]any{ + { + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": fullContent, + }, + "finish_reason": "stop", + }, + }, + "usage": map[string]any{ + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + }, + } + + if fullThinking != "" { + if choices, ok := response["choices"].([]map[string]any); ok && len(choices) > 0 { + choices[0]["message"].(map[string]any)["reasoning_content"] = fullThinking + } + } + + c.JSON(http.StatusOK, response) + + return &OpenAIForwardResult{ + RequestID: completionID, + Stream: false, + Duration: time.Since(startTime), + }, nil +} + +// buildDeepSeekPayload converts an OpenAI Chat Completions request body to DeepSeek format. +// The DeepSeek web API uses a single "prompt" field. We extract system messages as prefix +// and the last user message as the main prompt. +func buildDeepSeekPayload(body []byte, sessionID string) map[string]any { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return map[string]any{ + "chat_session_id": sessionID, + "model": "deepseek-chat", + "prompt": "", + "stream": true, + "ref_file_ids": []any{}, + } + } + + messages := payload["messages"] + + model, _ := payload["model"].(string) + if model == "" { + model = "deepseek-chat" + } + if model != "deepseek-chat" && model != "deepseek-coder" && !strings.HasPrefix(model, "deepseek") { + model = "deepseek-chat" + } + + stream := true + if s, ok := payload["stream"].(bool); ok { + stream = s + } + + // Extract system message prefix and last user message + var systemPrefix string + var lastUserMsg string + if msgArr, ok := messages.([]any); ok { + for _, msg := range msgArr { + msgMap, ok := msg.(map[string]any) + if !ok { + continue + } + role, _ := msgMap["role"].(string) + content, _ := msgMap["content"].(string) + if content == "" { + continue + } + if role == "system" { + systemPrefix = content + } else if role == "user" { + lastUserMsg = content + } + } + } + + prompt := lastUserMsg + if systemPrefix != "" && lastUserMsg != "" { + prompt = systemPrefix + "\n\n" + lastUserMsg + } + + dsPayload := map[string]any{ + "chat_session_id": sessionID, + "prompt": prompt, + "model": model, + "stream": stream, + "ref_file_ids": []any{}, + } + + if temp, ok := payload["temperature"]; ok { + dsPayload["temperature"] = temp + } + if topP, ok := payload["top_p"]; ok { + dsPayload["top_p"] = topP + } + if maxTokens, ok := payload["max_tokens"]; ok { + dsPayload["max_tokens"] = maxTokens + } + if maxTokens, ok := payload["max_completion_tokens"]; ok { + if _, exists := dsPayload["max_tokens"]; !exists { + dsPayload["max_tokens"] = maxTokens + } + } + + return dsPayload +} diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 2bed9e0394d..460702fb01d 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -2142,18 +2142,26 @@ func (s *OpenAIGatewayService) selectAccountWithLoadAwareness(ctx context.Contex } func (s *OpenAIGatewayService) listSchedulableAccounts(ctx context.Context, groupID *int64) ([]Account, error) { + // OpenAI gateway also handles DeepSeek cookie accounts + openAIPlatforms := []string{PlatformOpenAI, PlatformDeepseek} if s.schedulerSnapshot != nil { accounts, _, err := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, PlatformOpenAI, false) + if err == nil { + dsAccounts, _, dsErr := s.schedulerSnapshot.ListSchedulableAccounts(ctx, groupID, PlatformDeepseek, false) + if dsErr == nil { + accounts = append(accounts, dsAccounts...) + } + } return accounts, err } var accounts []Account var err error if s.cfg != nil && s.cfg.RunMode == config.RunModeSimple { - accounts, err = s.accountRepo.ListSchedulableByPlatform(ctx, PlatformOpenAI) + accounts, err = s.accountRepo.ListSchedulableByPlatforms(ctx, openAIPlatforms) } else if groupID != nil { - accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI) + accounts, err = s.accountRepo.ListSchedulableByGroupIDAndPlatforms(ctx, *groupID, openAIPlatforms) } else { - accounts, err = s.accountRepo.ListSchedulableUngroupedByPlatform(ctx, PlatformOpenAI) + accounts, err = s.accountRepo.ListSchedulableUngroupedByPlatforms(ctx, openAIPlatforms) } if err != nil { return nil, fmt.Errorf("query accounts failed: %w", err) diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index d1a7729a58d..9b18ded2627 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -147,6 +147,76 @@ Antigravity + + + + + +
+ +
+ +
@@ -1008,8 +1078,52 @@ - -
+ +
+
+

How to get your credentials:

+
    +
  1. Open chat.deepseek.com and log in
  2. +
  3. Open browser DevTools (F12) → Network tab
  4. +
  5. Send a message and look for a request to /api/v0/chat/completion
  6. +
  7. Copy the Authorization header value (starts with Bearer eyJ)
  8. +
  9. Copy the Cookie header value
  10. +
+
+
+ + +

The Authorization header value from chat.deepseek.com

+
+
+ +