Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/internal/domain/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const (
PlatformOpenAI = "openai"
PlatformGemini = "gemini"
PlatformAntigravity = "antigravity"
PlatformDeepseek = "deepseek"
)

// Account type constants
Expand Down
34 changes: 34 additions & 0 deletions backend/internal/handler/admin/account_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
})
}
4 changes: 2 additions & 2 deletions backend/internal/handler/admin/group_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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"`
Expand Down
170 changes: 170 additions & 0 deletions backend/internal/pkg/deepseek/client.go
Original file line number Diff line number Diff line change
@@ -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
}
33 changes: 33 additions & 0 deletions backend/internal/pkg/deepseek/constants.go
Original file line number Diff line number Diff line change
@@ -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",
}
Loading
Loading