luckfun233 commited on
Commit
3678d9b
·
1 Parent(s): 04c88a2

feat: update platform handling and add web support

Browse files

- Updated Android client version to 2.1.5 in constants_shared.json.
- Introduced new constants_web.json for web platform configuration.
- Enhanced transport layer to support platform-specific TLS settings.
- Added platform configuration handling in settings update and read requests.
- Implemented platform selection UI in the web interface.
- Updated localization files for platform settings.
- Added Shumei device ID generation logic for web platform.
- Introduced confusion mapping and encryption for device ID generation.

Files changed (33) hide show
  1. .gitignore +3 -1
  2. internal/config/codec.go +8 -0
  3. internal/config/config.go +25 -1
  4. internal/config/store_accessors.go +14 -0
  5. internal/config/validation.go +13 -0
  6. internal/deepseek/client/client_auth.go +26 -3
  7. internal/deepseek/client/client_completion.go +7 -0
  8. internal/deepseek/client/client_continue.go +5 -0
  9. internal/deepseek/client/client_core.go +57 -5
  10. internal/deepseek/client/proxy.go +5 -4
  11. internal/deepseek/hif/hif.go +183 -0
  12. internal/deepseek/hif/hif_test.go +291 -0
  13. internal/deepseek/protocol/constants.go +69 -7
  14. internal/deepseek/protocol/constants_shared.json +1 -1
  15. internal/deepseek/protocol/constants_web.json +32 -0
  16. internal/deepseek/transport/transport.go +50 -3
  17. internal/httpapi/admin/accounts/handler_accounts_testing_test.go +4 -0
  18. internal/httpapi/admin/proxies/test_http_helpers_test.go +1 -0
  19. internal/httpapi/admin/settings/handler_settings_parse.go +27 -14
  20. internal/httpapi/admin/settings/handler_settings_read.go +5 -1
  21. internal/httpapi/admin/settings/handler_settings_runtime.go +7 -0
  22. internal/httpapi/admin/settings/handler_settings_write.go +5 -1
  23. internal/httpapi/admin/shared/deps.go +2 -0
  24. internal/httpapi/admin/test_bridge_test.go +2 -0
  25. internal/shumei/api.go +94 -0
  26. internal/shumei/confusion.go +51 -0
  27. internal/shumei/crypto.go +124 -0
  28. internal/shumei/device_id.go +121 -0
  29. webui/src/features/settings/PlatformSection.jsx +43 -0
  30. webui/src/features/settings/SettingsContainer.jsx +3 -0
  31. webui/src/features/settings/useSettingsForm.js +6 -0
  32. webui/src/locales/en.json +5 -0
  33. webui/src/locales/zh.json +5 -0
.gitignore CHANGED
@@ -70,4 +70,6 @@ data/
70
  .roomodes
71
 
72
  deepseek2api旧版/
73
- chat.deepseek.com_2026_05_30_09_34_45.har.txt
 
 
 
70
  .roomodes
71
 
72
  deepseek2api旧版/
73
+ chat.deepseek.com.har.txt
74
+ chat.deepseek.com11.har.txt
75
+ chat.deepseek.com/
internal/config/codec.go CHANGED
@@ -51,6 +51,9 @@ func (c Config) MarshalJSON() ([]byte, error) {
51
  if strings.TrimSpace(c.Vercel.Token) != "" || strings.TrimSpace(c.Vercel.ProjectID) != "" || strings.TrimSpace(c.Vercel.TeamID) != "" {
52
  m["vercel"] = NormalizeVercelConfig(c.Vercel)
53
  }
 
 
 
54
  if c.VercelSyncHash != "" {
55
  m["_vercel_sync_hash"] = c.VercelSyncHash
56
  }
@@ -132,6 +135,10 @@ func (c *Config) UnmarshalJSON(b []byte) error {
132
  if err := json.Unmarshal(v, &c.Vercel); err != nil {
133
  return fmt.Errorf("invalid field %q: %w", k, err)
134
  }
 
 
 
 
135
  case "_vercel_sync_hash":
136
  if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
137
  return fmt.Errorf("invalid field %q: %w", k, err)
@@ -172,6 +179,7 @@ func (c Config) Clone() Config {
172
  Prompt: c.ThinkingInjection.Prompt,
173
  },
174
  Vercel: c.Vercel,
 
175
  VercelSyncHash: c.VercelSyncHash,
176
  VercelSyncTime: c.VercelSyncTime,
177
  AdditionalFields: map[string]any{},
 
51
  if strings.TrimSpace(c.Vercel.Token) != "" || strings.TrimSpace(c.Vercel.ProjectID) != "" || strings.TrimSpace(c.Vercel.TeamID) != "" {
52
  m["vercel"] = NormalizeVercelConfig(c.Vercel)
53
  }
54
+ if strings.TrimSpace(c.Platform.Mode) != "" {
55
+ m["platform"] = c.Platform
56
+ }
57
  if c.VercelSyncHash != "" {
58
  m["_vercel_sync_hash"] = c.VercelSyncHash
59
  }
 
135
  if err := json.Unmarshal(v, &c.Vercel); err != nil {
136
  return fmt.Errorf("invalid field %q: %w", k, err)
137
  }
138
+ case "platform":
139
+ if err := json.Unmarshal(v, &c.Platform); err != nil {
140
+ return fmt.Errorf("invalid field %q: %w", k, err)
141
+ }
142
  case "_vercel_sync_hash":
143
  if err := json.Unmarshal(v, &c.VercelSyncHash); err != nil {
144
  return fmt.Errorf("invalid field %q: %w", k, err)
 
179
  Prompt: c.ThinkingInjection.Prompt,
180
  },
181
  Vercel: c.Vercel,
182
+ Platform: c.Platform,
183
  VercelSyncHash: c.VercelSyncHash,
184
  VercelSyncTime: c.VercelSyncTime,
185
  AdditionalFields: map[string]any{},
internal/config/config.go CHANGED
@@ -21,6 +21,7 @@ type Config struct {
21
  CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"`
22
  ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
23
  Vercel VercelConfig `json:"vercel,omitempty"`
 
24
  VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
25
  VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
26
  AdditionalFields map[string]any `json:"-"`
@@ -34,7 +35,7 @@ type Account struct {
34
  Password string `json:"password,omitempty"`
35
  Token string `json:"token,omitempty"`
36
  ProxyID string `json:"proxy_id,omitempty"`
37
- Role string `json:"role,omitempty"` // "normal" (default) or "standby"
38
  Banned bool `json:"banned,omitempty"` // true when USER_IS_BANNED detected
39
  }
40
 
@@ -188,6 +189,10 @@ type VercelConfig struct {
188
  TeamID string `json:"team_id,omitempty"`
189
  }
190
 
 
 
 
 
191
  func NormalizeVercelConfig(v VercelConfig) VercelConfig {
192
  return VercelConfig{
193
  Token: strings.TrimSpace(v.Token),
@@ -202,3 +207,22 @@ func (c *Config) ClearVercelCredentials() {
202
  }
203
  c.Vercel = VercelConfig{}
204
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  CurrentInputFile CurrentInputFileConfig `json:"current_input_file,omitempty"`
22
  ThinkingInjection ThinkingInjectionConfig `json:"thinking_injection,omitempty"`
23
  Vercel VercelConfig `json:"vercel,omitempty"`
24
+ Platform PlatformConfig `json:"platform,omitempty"`
25
  VercelSyncHash string `json:"_vercel_sync_hash,omitempty"`
26
  VercelSyncTime int64 `json:"_vercel_sync_time,omitempty"`
27
  AdditionalFields map[string]any `json:"-"`
 
35
  Password string `json:"password,omitempty"`
36
  Token string `json:"token,omitempty"`
37
  ProxyID string `json:"proxy_id,omitempty"`
38
+ Role string `json:"role,omitempty"` // "normal" (default) or "standby"
39
  Banned bool `json:"banned,omitempty"` // true when USER_IS_BANNED detected
40
  }
41
 
 
189
  TeamID string `json:"team_id,omitempty"`
190
  }
191
 
192
+ type PlatformConfig struct {
193
+ Mode string `json:"mode,omitempty"` // "android" (default) or "web"
194
+ }
195
+
196
  func NormalizeVercelConfig(v VercelConfig) VercelConfig {
197
  return VercelConfig{
198
  Token: strings.TrimSpace(v.Token),
 
207
  }
208
  c.Vercel = VercelConfig{}
209
  }
210
+
211
+ func (c *Config) PlatformMode() string {
212
+ if c == nil {
213
+ return "android"
214
+ }
215
+ mode := strings.TrimSpace(strings.ToLower(c.Platform.Mode))
216
+ if mode == "web" {
217
+ return "web"
218
+ }
219
+ return "android"
220
+ }
221
+
222
+ func (c *Config) IsWebPlatform() bool {
223
+ return c.PlatformMode() == "web"
224
+ }
225
+
226
+ func (c *Config) IsAndroidPlatform() bool {
227
+ return c.PlatformMode() == "android"
228
+ }
internal/config/store_accessors.go CHANGED
@@ -186,6 +186,20 @@ func (s *Store) RuntimeBackupReleaseCount() int {
186
  return 2
187
  }
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  // SetAccountBanned marks an account as banned or unbanned. Returns the updated account.
190
  func (s *Store) SetAccountBanned(identifier string, banned bool) (Account, error) {
191
  identifier = strings.TrimSpace(identifier)
 
186
  return 2
187
  }
188
 
189
+ func (s *Store) PlatformMode() string {
190
+ s.mu.RLock()
191
+ defer s.mu.RUnlock()
192
+ return s.cfg.PlatformMode()
193
+ }
194
+
195
+ func (s *Store) IsWebPlatform() bool {
196
+ return s.PlatformMode() == "web"
197
+ }
198
+
199
+ func (s *Store) IsAndroidPlatform() bool {
200
+ return s.PlatformMode() == "android"
201
+ }
202
+
203
  // SetAccountBanned marks an account as banned or unbanned. Returns the updated account.
204
  func (s *Store) SetAccountBanned(identifier string, banned bool) (Account, error) {
205
  identifier = strings.TrimSpace(identifier)
internal/config/validation.go CHANGED
@@ -30,6 +30,9 @@ func ValidateConfig(c Config) error {
30
  if err := ValidateAccountProxyReferences(c.Accounts, c.Proxies); err != nil {
31
  return err
32
  }
 
 
 
33
  return nil
34
  }
35
 
@@ -154,3 +157,13 @@ func ValidateAutoDeleteMode(mode string) error {
154
  return fmt.Errorf("auto_delete.mode must be one of none, single, all")
155
  }
156
  }
 
 
 
 
 
 
 
 
 
 
 
30
  if err := ValidateAccountProxyReferences(c.Accounts, c.Proxies); err != nil {
31
  return err
32
  }
33
+ if err := ValidatePlatformConfig(c.Platform); err != nil {
34
+ return err
35
+ }
36
  return nil
37
  }
38
 
 
157
  return fmt.Errorf("auto_delete.mode must be one of none, single, all")
158
  }
159
  }
160
+
161
+ func ValidatePlatformConfig(platform PlatformConfig) error {
162
+ mode := strings.ToLower(strings.TrimSpace(platform.Mode))
163
+ switch mode {
164
+ case "", "android", "web":
165
+ return nil
166
+ default:
167
+ return fmt.Errorf("platform.mode must be one of android, web")
168
+ }
169
+ }
internal/deepseek/client/client_auth.go CHANGED
@@ -11,14 +11,26 @@ import (
11
 
12
  "ds2api/internal/auth"
13
  "ds2api/internal/config"
 
14
  )
15
 
16
  func (c *Client) Login(ctx context.Context, acc config.Account) (string, error) {
17
  clients := c.requestClientsForAccount(acc)
 
18
  payload := map[string]any{
19
- "password": strings.TrimSpace(acc.Password),
20
- "device_id": "android_device",
21
- "os": "android",
 
 
 
 
 
 
 
 
 
 
22
  }
23
  if email := strings.TrimSpace(acc.Email); email != "" {
24
  payload["email"] = email
@@ -164,6 +176,17 @@ func (c *Client) authHeaders(token string) map[string]string {
164
  headers[k] = v
165
  }
166
  headers["authorization"] = "Bearer " + token
 
 
 
 
 
 
 
 
 
 
 
167
  return headers
168
  }
169
 
 
11
 
12
  "ds2api/internal/auth"
13
  "ds2api/internal/config"
14
+ "ds2api/internal/shumei"
15
  )
16
 
17
  func (c *Client) Login(ctx context.Context, acc config.Account) (string, error) {
18
  clients := c.requestClientsForAccount(acc)
19
+ platform := c.Store.PlatformMode()
20
  payload := map[string]any{
21
+ "password": strings.TrimSpace(acc.Password),
22
+ }
23
+ if platform == "web" {
24
+ deviceID, err := shumei.GetDeviceID(ctx)
25
+ if err != nil {
26
+ config.Logger.Warn("[login] shumei device_id failed, using fallback", "error", err)
27
+ deviceID = "web_device"
28
+ }
29
+ payload["device_id"] = deviceID
30
+ payload["os"] = "web"
31
+ } else {
32
+ payload["device_id"] = "android_device"
33
+ payload["os"] = "android"
34
  }
35
  if email := strings.TrimSpace(acc.Email); email != "" {
36
  payload["email"] = email
 
176
  headers[k] = v
177
  }
178
  headers["authorization"] = "Bearer " + token
179
+
180
+ if dsprotocol.IsWebPlatform() {
181
+ for k, v := range dsprotocol.WebExtraHeaders() {
182
+ headers[k] = v
183
+ }
184
+ if hifHeaders := c.getHIFHeaders(); len(hifHeaders) > 0 {
185
+ for k, v := range hifHeaders {
186
+ headers[k] = v
187
+ }
188
+ }
189
+ }
190
  return headers
191
  }
192
 
internal/deepseek/client/client_completion.go CHANGED
@@ -17,6 +17,13 @@ func (c *Client) CallCompletion(ctx context.Context, a *auth.RequestAuth, payloa
17
  clients := c.requestClientsForAuth(ctx, a)
18
  headers := c.authHeaders(a.DeepSeekToken)
19
  headers["x-ds-pow-response"] = powResp
 
 
 
 
 
 
 
20
  captureSession := c.capture.Start("deepseek_completion", dsprotocol.DeepSeekCompletionURL, a.AccountID, payload)
21
  resp, err := c.streamPostOnce(ctx, clients.stream, dsprotocol.DeepSeekCompletionURL, headers, payload)
22
  if err != nil {
 
17
  clients := c.requestClientsForAuth(ctx, a)
18
  headers := c.authHeaders(a.DeepSeekToken)
19
  headers["x-ds-pow-response"] = powResp
20
+
21
+ if dsprotocol.IsWebPlatform() {
22
+ for k, v := range dsprotocol.WebExtraHeaders() {
23
+ headers[k] = v
24
+ }
25
+ }
26
+
27
  captureSession := c.capture.Start("deepseek_completion", dsprotocol.DeepSeekCompletionURL, a.AccountID, payload)
28
  resp, err := c.streamPostOnce(ctx, clients.stream, dsprotocol.DeepSeekCompletionURL, headers, payload)
29
  if err != nil {
internal/deepseek/client/client_continue.go CHANGED
@@ -56,6 +56,11 @@ func (c *Client) callContinue(ctx context.Context, a *auth.RequestAuth, sessionI
56
  clients := c.requestClientsForAuth(ctx, a)
57
  headers := c.authHeaders(a.DeepSeekToken)
58
  headers["x-ds-pow-response"] = powResp
 
 
 
 
 
59
  payload := map[string]any{
60
  "chat_session_id": sessionID,
61
  "message_id": responseMessageID,
 
56
  clients := c.requestClientsForAuth(ctx, a)
57
  headers := c.authHeaders(a.DeepSeekToken)
58
  headers["x-ds-pow-response"] = powResp
59
+ if dsprotocol.IsWebPlatform() {
60
+ for k, v := range dsprotocol.WebExtraHeaders() {
61
+ headers[k] = v
62
+ }
63
+ }
64
  payload := map[string]any{
65
  "chat_session_id": sessionID,
66
  "message_id": responseMessageID,
internal/deepseek/client/client_core.go CHANGED
@@ -8,9 +8,12 @@ import (
8
 
9
  "ds2api/internal/auth"
10
  "ds2api/internal/config"
 
11
  trans "ds2api/internal/deepseek/transport"
12
  "ds2api/internal/devcapture"
13
  "ds2api/internal/util"
 
 
14
  )
15
 
16
  // intFrom is a package-internal alias for the shared util version.
@@ -25,23 +28,72 @@ type Client struct {
25
  fallback *http.Client
26
  fallbackS *http.Client
27
  maxRetries int
 
28
 
29
  proxyClientsMu sync.RWMutex
30
  proxyClients map[string]requestClients
31
  }
32
 
33
  func NewClient(store *config.Store, resolver *auth.Resolver) *Client {
34
- return &Client{
35
  Store: store,
36
  Auth: resolver,
37
  capture: devcapture.Global(),
38
- regular: trans.New(60 * time.Second),
39
- stream: trans.New(0),
40
- fallback: &http.Client{Timeout: 60 * time.Second},
41
- fallbackS: &http.Client{Timeout: 0},
42
  maxRetries: 3,
43
  proxyClients: map[string]requestClients{},
44
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  }
46
 
47
  // PreloadPow 保留兼容接口,纯 Go 实现无需预加载。
 
8
 
9
  "ds2api/internal/auth"
10
  "ds2api/internal/config"
11
+ dsprotocol "ds2api/internal/deepseek/protocol"
12
  trans "ds2api/internal/deepseek/transport"
13
  "ds2api/internal/devcapture"
14
  "ds2api/internal/util"
15
+
16
+ "ds2api/internal/deepseek/hif"
17
  )
18
 
19
  // intFrom is a package-internal alias for the shared util version.
 
28
  fallback *http.Client
29
  fallbackS *http.Client
30
  maxRetries int
31
+ hifPoller *hif.HIFPoller
32
 
33
  proxyClientsMu sync.RWMutex
34
  proxyClients map[string]requestClients
35
  }
36
 
37
  func NewClient(store *config.Store, resolver *auth.Resolver) *Client {
38
+ c := &Client{
39
  Store: store,
40
  Auth: resolver,
41
  capture: devcapture.Global(),
 
 
 
 
42
  maxRetries: 3,
43
  proxyClients: map[string]requestClients{},
44
  }
45
+ platform := "android"
46
+ if store != nil {
47
+ platform = store.PlatformMode()
48
+ }
49
+ dsprotocol.ApplyPlatform(platform)
50
+ c.regular = trans.NewWithPlatform(60*time.Second, platform)
51
+ c.stream = trans.NewWithPlatform(0, platform)
52
+ c.fallback = trans.NewFallbackClientWithPlatform(60*time.Second, nil, platform)
53
+ c.fallbackS = trans.NewFallbackClientWithPlatform(0, nil, platform)
54
+
55
+ if platform == "web" {
56
+ c.hifPoller = hif.NewHIFPoller()
57
+ c.hifPoller.Start()
58
+ }
59
+
60
+ return c
61
+ }
62
+
63
+ // RefreshTransport recreates the transport clients based on the current platform mode.
64
+ func (c *Client) RefreshTransport() {
65
+ platform := c.Store.PlatformMode()
66
+ c.regular = trans.NewWithPlatform(60*time.Second, platform)
67
+ c.stream = trans.NewWithPlatform(0, platform)
68
+ c.fallback = trans.NewFallbackClientWithPlatform(60*time.Second, nil, platform)
69
+ c.fallbackS = trans.NewFallbackClientWithPlatform(0, nil, platform)
70
+
71
+ c.proxyClientsMu.Lock()
72
+ c.proxyClients = map[string]requestClients{}
73
+ c.proxyClientsMu.Unlock()
74
+ }
75
+
76
+ // SetPlatform switches the client to the given platform ("android" or "web"),
77
+ // updating protocol constants, transport clients, and the HIF poller.
78
+ func (c *Client) SetPlatform(platform string) {
79
+ dsprotocol.ApplyPlatform(platform)
80
+ c.RefreshTransport()
81
+
82
+ if platform == "web" && c.hifPoller == nil {
83
+ c.hifPoller = hif.NewHIFPoller()
84
+ c.hifPoller.Start()
85
+ } else if platform != "web" && c.hifPoller != nil {
86
+ c.hifPoller.Stop()
87
+ c.hifPoller = nil
88
+ }
89
+ }
90
+
91
+ // getHIFHeaders returns the current HIF headers from the poller, if active.
92
+ func (c *Client) getHIFHeaders() map[string]string {
93
+ if c.hifPoller == nil {
94
+ return nil
95
+ }
96
+ return c.hifPoller.GetHeaders()
97
  }
98
 
99
  // PreloadPow 保留兼容接口,纯 Go 实现无需预加载。
internal/deepseek/client/proxy.go CHANGED
@@ -157,11 +157,12 @@ func (c *Client) requestClientsForAccount(acc config.Account) requestClients {
157
  return c.defaultRequestClients()
158
  }
159
 
 
160
  bundle := requestClients{
161
- regular: trans.NewWithDialContext(60*time.Second, dialContext),
162
- stream: trans.NewWithDialContext(0, dialContext),
163
- fallback: trans.NewFallbackClient(60*time.Second, dialContext),
164
- fallbackS: trans.NewFallbackClient(0, dialContext),
165
  }
166
 
167
  c.proxyClientsMu.Lock()
 
157
  return c.defaultRequestClients()
158
  }
159
 
160
+ platform := c.Store.PlatformMode()
161
  bundle := requestClients{
162
+ regular: trans.NewWithDialContextAndPlatform(60*time.Second, dialContext, platform),
163
+ stream: trans.NewWithDialContextAndPlatform(0, dialContext, platform),
164
+ fallback: trans.NewFallbackClientWithPlatform(60*time.Second, dialContext, platform),
165
+ fallbackS: trans.NewFallbackClientWithPlatform(0, dialContext, platform),
166
  }
167
 
168
  c.proxyClientsMu.Lock()
internal/deepseek/hif/hif.go ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package hif
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "net/http"
9
+ "strconv"
10
+ "sync"
11
+ "time"
12
+
13
+ "ds2api/internal/config"
14
+ dsprotocol "ds2api/internal/deepseek/protocol"
15
+ trans "ds2api/internal/deepseek/transport"
16
+ )
17
+
18
+ const (
19
+ dliqURL = "https://hif-dliq.deepseek.com/query"
20
+ leimURL = "https://hif-leim.deepseek.com/query"
21
+ defaultTTL = 600 // seconds
22
+ initialInterval = 1 * time.Second
23
+ maxInterval = 600 * time.Second
24
+ backoffFactor = 2
25
+ )
26
+
27
+ // HIFPoller polls the DeepSeek HIF endpoints for x-hif-dliq and x-hif-leim header values.
28
+ type HIFPoller struct {
29
+ mu sync.RWMutex
30
+ dliq string
31
+ leim string
32
+ dliqAt time.Time
33
+ leimAt time.Time
34
+ stopCh chan struct{}
35
+ client *trans.Client
36
+ fallback *http.Client
37
+ }
38
+
39
+ // NewHIFPoller creates a new HIFPoller using web-platform TLS fingerprinting.
40
+ func NewHIFPoller() *HIFPoller {
41
+ return &HIFPoller{
42
+ stopCh: make(chan struct{}),
43
+ client: trans.NewWithPlatform(15*time.Second, "web"),
44
+ fallback: trans.NewFallbackClientWithPlatform(15*time.Second, nil, "web"),
45
+ }
46
+ }
47
+
48
+ // Start begins the polling goroutines for both HIF header values.
49
+ func (p *HIFPoller) Start() {
50
+ go p.pollLoop("dliq", dliqURL)
51
+ go p.pollLoop("leim", leimURL)
52
+ }
53
+
54
+ // Stop terminates the polling goroutines.
55
+ func (p *HIFPoller) Stop() {
56
+ close(p.stopCh)
57
+ }
58
+
59
+ // GetHeaders returns the current HIF headers to add to requests.
60
+ func (p *HIFPoller) GetHeaders() map[string]string {
61
+ p.mu.RLock()
62
+ defer p.mu.RUnlock()
63
+ headers := make(map[string]string)
64
+ if p.dliq != "" {
65
+ headers["x-hif-dliq"] = p.dliq
66
+ }
67
+ if p.leim != "" {
68
+ headers["x-hif-leim"] = p.leim
69
+ }
70
+ return headers
71
+ }
72
+
73
+ func (p *HIFPoller) pollLoop(kind, url string) {
74
+ interval := initialInterval
75
+ for {
76
+ select {
77
+ case <-p.stopCh:
78
+ return
79
+ default:
80
+ }
81
+
82
+ value, ttl, err := p.query(url)
83
+ if err != nil {
84
+ config.Logger.Warn("[hif] query failed", "kind", kind, "error", err, "retry_after", interval)
85
+ select {
86
+ case <-p.stopCh:
87
+ return
88
+ case <-time.After(interval):
89
+ }
90
+ interval = time.Duration(float64(interval) * float64(backoffFactor))
91
+ if interval > maxInterval {
92
+ interval = maxInterval
93
+ }
94
+ continue
95
+ }
96
+
97
+ p.mu.Lock()
98
+ if kind == "dliq" {
99
+ p.dliq = value
100
+ p.dliqAt = time.Now()
101
+ } else {
102
+ p.leim = value
103
+ p.leimAt = time.Now()
104
+ }
105
+ p.mu.Unlock()
106
+
107
+ config.Logger.Info("[hif] query success", "kind", kind, "ttl", ttl)
108
+ interval = initialInterval
109
+
110
+ sleepDuration := time.Duration(ttl) * time.Second
111
+ select {
112
+ case <-p.stopCh:
113
+ return
114
+ case <-time.After(sleepDuration):
115
+ }
116
+ }
117
+ }
118
+
119
+ func (p *HIFPoller) query(url string) (value string, ttl int, err error) {
120
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
121
+ defer cancel()
122
+
123
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
124
+ if err != nil {
125
+ return "", defaultTTL, err
126
+ }
127
+
128
+ for k, v := range dsprotocol.BaseHeaders {
129
+ req.Header.Set(k, v)
130
+ }
131
+ for k, v := range dsprotocol.WebExtraHeaders() {
132
+ req.Header.Set(k, v)
133
+ }
134
+ req.Header.Set("User-Agent", dsprotocol.UserAgent())
135
+ req.Header.Set("Accept", "application/json")
136
+
137
+ resp, err := p.client.Do(req)
138
+ if err != nil {
139
+ req2, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
140
+ if reqErr != nil {
141
+ return "", defaultTTL, err
142
+ }
143
+ for k, v := range req.Header {
144
+ req2.Header[k] = v
145
+ }
146
+ resp, err = p.fallback.Do(req2)
147
+ if err != nil {
148
+ return "", defaultTTL, err
149
+ }
150
+ }
151
+ defer func() { _ = resp.Body.Close() }()
152
+
153
+ ttl = defaultTTL
154
+ if ttlStr := resp.Header.Get("x-hif-ttl"); ttlStr != "" {
155
+ if n, e := strconv.Atoi(ttlStr); e == nil && n > 0 {
156
+ ttl = n
157
+ }
158
+ }
159
+
160
+ body, err := io.ReadAll(resp.Body)
161
+ if err != nil {
162
+ return "", defaultTTL, err
163
+ }
164
+
165
+ var result struct {
166
+ Code int `json:"code"`
167
+ Data struct {
168
+ BizCode int `json:"biz_code"`
169
+ BizData struct {
170
+ Value string `json:"value"`
171
+ } `json:"biz_data"`
172
+ } `json:"data"`
173
+ }
174
+ if err := json.Unmarshal(body, &result); err != nil {
175
+ return "", defaultTTL, err
176
+ }
177
+
178
+ if result.Data.BizCode != 0 || result.Data.BizData.Value == "" {
179
+ return "", defaultTTL, fmt.Errorf("hif query returned biz_code=%d", result.Data.BizCode)
180
+ }
181
+
182
+ return result.Data.BizData.Value, ttl, nil
183
+ }
internal/deepseek/hif/hif_test.go ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package hif
2
+
3
+ import (
4
+ "encoding/json"
5
+ "io"
6
+ "net/http"
7
+ "net/http/httptest"
8
+ "strings"
9
+ "sync"
10
+ "testing"
11
+ "time"
12
+ )
13
+
14
+ func TestNewHIFPollerCreation(t *testing.T) {
15
+ p := NewHIFPoller()
16
+ if p == nil {
17
+ t.Fatal("expected non-nil poller")
18
+ }
19
+ headers := p.GetHeaders()
20
+ if len(headers) != 0 {
21
+ t.Fatalf("expected empty headers on fresh poller, got %v", headers)
22
+ }
23
+ }
24
+
25
+ func TestGetHeadersReturnsCachedValues(t *testing.T) {
26
+ p := NewHIFPoller()
27
+ p.mu.Lock()
28
+ p.dliq = "dliq-value-123"
29
+ p.leim = "leim-value-456"
30
+ p.mu.Unlock()
31
+
32
+ headers := p.GetHeaders()
33
+ if headers["x-hif-dliq"] != "dliq-value-123" {
34
+ t.Fatalf("expected x-hif-dliq=dliq-value-123, got %q", headers["x-hif-dliq"])
35
+ }
36
+ if headers["x-hif-leim"] != "leim-value-456" {
37
+ t.Fatalf("expected x-hif-leim=leim-value-456, got %q", headers["x-hif-leim"])
38
+ }
39
+ }
40
+
41
+ func TestGetHeadersOmitsEmptyValues(t *testing.T) {
42
+ p := NewHIFPoller()
43
+ p.mu.Lock()
44
+ p.dliq = "only-dliq"
45
+ p.mu.Unlock()
46
+
47
+ headers := p.GetHeaders()
48
+ if _, ok := headers["x-hif-leim"]; ok {
49
+ t.Fatal("expected x-hif-leim to be omitted when empty")
50
+ }
51
+ if headers["x-hif-dliq"] != "only-dliq" {
52
+ t.Fatalf("expected x-hif-dliq=only-dliq, got %q", headers["x-hif-dliq"])
53
+ }
54
+ }
55
+
56
+ func TestQueryParsesResponse(t *testing.T) {
57
+ responseBody := map[string]any{
58
+ "code": 0,
59
+ "msg": "",
60
+ "data": map[string]any{
61
+ "biz_code": 0,
62
+ "biz_msg": "",
63
+ "biz_data": map[string]any{
64
+ "value": "test-hif-token",
65
+ },
66
+ },
67
+ }
68
+ body, _ := json.Marshal(responseBody)
69
+
70
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71
+ w.Header().Set("x-hif-ttl", "300")
72
+ w.Header().Set("Content-Type", "application/json")
73
+ _, _ = w.Write(body)
74
+ }))
75
+ defer server.Close()
76
+
77
+ p := NewHIFPoller()
78
+ value, ttl, err := p.query(server.URL)
79
+ if err != nil {
80
+ t.Fatalf("unexpected error: %v", err)
81
+ }
82
+ if value != "test-hif-token" {
83
+ t.Fatalf("expected value=test-hif-token, got %q", value)
84
+ }
85
+ if ttl != 300 {
86
+ t.Fatalf("expected ttl=300, got %d", ttl)
87
+ }
88
+ }
89
+
90
+ func TestQueryFallsBackToDefaultTTL(t *testing.T) {
91
+ responseBody := map[string]any{
92
+ "code": 0,
93
+ "data": map[string]any{
94
+ "biz_code": 0,
95
+ "biz_data": map[string]any{
96
+ "value": "val",
97
+ },
98
+ },
99
+ }
100
+ body, _ := json.Marshal(responseBody)
101
+
102
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
103
+ w.Header().Set("Content-Type", "application/json")
104
+ _, _ = w.Write(body)
105
+ }))
106
+ defer server.Close()
107
+
108
+ p := NewHIFPoller()
109
+ _, ttl, err := p.query(server.URL)
110
+ if err != nil {
111
+ t.Fatalf("unexpected error: %v", err)
112
+ }
113
+ if ttl != defaultTTL {
114
+ t.Fatalf("expected default ttl=%d, got %d", defaultTTL, ttl)
115
+ }
116
+ }
117
+
118
+ func TestQueryReturnsErrorOnBadBizCode(t *testing.T) {
119
+ responseBody := map[string]any{
120
+ "code": 0,
121
+ "data": map[string]any{
122
+ "biz_code": 1,
123
+ "biz_msg": "failed",
124
+ "biz_data": map[string]any{
125
+ "value": "",
126
+ },
127
+ },
128
+ }
129
+ body, _ := json.Marshal(responseBody)
130
+
131
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
132
+ w.Header().Set("Content-Type", "application/json")
133
+ _, _ = w.Write(body)
134
+ }))
135
+ defer server.Close()
136
+
137
+ p := NewHIFPoller()
138
+ _, _, err := p.query(server.URL)
139
+ if err == nil {
140
+ t.Fatal("expected error for non-zero biz_code")
141
+ }
142
+ }
143
+
144
+ func TestQueryReturnsErrorOnEmptyValue(t *testing.T) {
145
+ responseBody := map[string]any{
146
+ "code": 0,
147
+ "data": map[string]any{
148
+ "biz_code": 0,
149
+ "biz_data": map[string]any{
150
+ "value": "",
151
+ },
152
+ },
153
+ }
154
+ body, _ := json.Marshal(responseBody)
155
+
156
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
157
+ w.Header().Set("Content-Type", "application/json")
158
+ _, _ = w.Write(body)
159
+ }))
160
+ defer server.Close()
161
+
162
+ p := NewHIFPoller()
163
+ _, _, err := p.query(server.URL)
164
+ if err == nil {
165
+ t.Fatal("expected error for empty value")
166
+ }
167
+ }
168
+
169
+ func TestStopTerminatesPollLoop(t *testing.T) {
170
+ p := NewHIFPoller()
171
+ p.Stop()
172
+ // Stop should not panic when called once
173
+ }
174
+
175
+ func TestPollLoopUpdatesValue(t *testing.T) {
176
+ callCount := 0
177
+ var mu sync.Mutex
178
+
179
+ responseBody := map[string]any{
180
+ "code": 0,
181
+ "data": map[string]any{
182
+ "biz_code": 0,
183
+ "biz_data": map[string]any{
184
+ "value": "polled-value",
185
+ },
186
+ },
187
+ }
188
+ body, _ := json.Marshal(responseBody)
189
+
190
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
191
+ mu.Lock()
192
+ callCount++
193
+ mu.Unlock()
194
+ w.Header().Set("x-hif-ttl", "1")
195
+ w.Header().Set("Content-Type", "application/json")
196
+ _, _ = w.Write(body)
197
+ }))
198
+ defer server.Close()
199
+
200
+ p := NewHIFPoller()
201
+ // Override URLs by testing pollLoop directly with the test server URL
202
+ go p.pollLoop("dliq", server.URL)
203
+
204
+ // Wait for at least one successful poll
205
+ deadline := time.After(5 * time.Second)
206
+ for {
207
+ p.mu.RLock()
208
+ val := p.dliq
209
+ p.mu.RUnlock()
210
+ if val == "polled-value" {
211
+ break
212
+ }
213
+ select {
214
+ case <-deadline:
215
+ p.Stop()
216
+ t.Fatal("timed out waiting for pollLoop to update dliq value")
217
+ case <-time.After(50 * time.Millisecond):
218
+ }
219
+ }
220
+
221
+ p.Stop()
222
+
223
+ p.mu.RLock()
224
+ val := p.dliq
225
+ p.mu.RUnlock()
226
+ if val != "polled-value" {
227
+ t.Fatalf("expected dliq=polled-value, got %q", val)
228
+ }
229
+ }
230
+
231
+ func TestQuerySendsCorrectHeaders(t *testing.T) {
232
+ responseBody := map[string]any{
233
+ "code": 0,
234
+ "data": map[string]any{
235
+ "biz_code": 0,
236
+ "biz_data": map[string]any{
237
+ "value": "v",
238
+ },
239
+ },
240
+ }
241
+ body, _ := json.Marshal(responseBody)
242
+
243
+ var gotUA string
244
+ var gotAccept string
245
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
246
+ gotUA = r.Header.Get("User-Agent")
247
+ gotAccept = r.Header.Get("Accept")
248
+ w.Header().Set("Content-Type", "application/json")
249
+ _, _ = w.Write(body)
250
+ }))
251
+ defer server.Close()
252
+
253
+ p := NewHIFPoller()
254
+ _, _, err := p.query(server.URL)
255
+ if err != nil {
256
+ t.Fatalf("unexpected error: %v", err)
257
+ }
258
+ if gotUA == "" {
259
+ t.Fatal("expected User-Agent header to be set")
260
+ }
261
+ if !strings.Contains(gotAccept, "application/json") {
262
+ t.Fatalf("expected Accept to contain application/json, got %q", gotAccept)
263
+ }
264
+ }
265
+
266
+ func TestQueryHandlesInvalidJSON(t *testing.T) {
267
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
268
+ w.Header().Set("Content-Type", "application/json")
269
+ _, _ = io.WriteString(w, "not-json")
270
+ }))
271
+ defer server.Close()
272
+
273
+ p := NewHIFPoller()
274
+ _, _, err := p.query(server.URL)
275
+ if err == nil {
276
+ t.Fatal("expected error for invalid JSON response")
277
+ }
278
+ }
279
+
280
+ func TestQueryHandlesServerError(t *testing.T) {
281
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
282
+ w.WriteHeader(http.StatusInternalServerError)
283
+ }))
284
+ defer server.Close()
285
+
286
+ p := NewHIFPoller()
287
+ _, _, err := p.query(server.URL)
288
+ if err == nil {
289
+ t.Fatal("expected error for server returning non-success biz response")
290
+ }
291
+ }
internal/deepseek/protocol/constants.go CHANGED
@@ -4,6 +4,7 @@ import (
4
  _ "embed"
5
  "encoding/json"
6
  "fmt"
 
7
  )
8
 
9
  const (
@@ -45,6 +46,7 @@ var defaultSkipExactPaths = []string{
45
  }
46
 
47
  var ClientVersion string
 
48
  var BaseHeaders = map[string]string{}
49
  var SkipContainsPatterns = cloneStringSlice(defaultSkipContainsPatterns)
50
  var SkipExactPathSet = toStringSet(defaultSkipExactPaths)
@@ -67,6 +69,9 @@ type sharedConstants struct {
67
  //go:embed constants_shared.json
68
  var sharedConstantsJSON []byte
69
 
 
 
 
70
  func init() {
71
  cfg := sharedConstants{}
72
  if err := json.Unmarshal(sharedConstantsJSON, &cfg); err != nil {
@@ -113,13 +118,7 @@ func buildBaseHeaders(client clientConstants, overrides map[string]string) map[s
113
  }
114
  out[k] = v
115
  }
116
- if client.Name != "" && client.Version != "" {
117
- userAgent := client.Name + "/" + client.Version
118
- if client.Platform == "android" && client.AndroidAPILevel != "" {
119
- userAgent += " Android/" + client.AndroidAPILevel
120
- }
121
- out["User-Agent"] = userAgent
122
- }
123
  if client.Platform != "" {
124
  out["x-client-platform"] = client.Platform
125
  }
@@ -132,6 +131,69 @@ func buildBaseHeaders(client clientConstants, overrides map[string]string) map[s
132
  return out
133
  }
134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  func cloneStringMap(in map[string]string) map[string]string {
136
  out := make(map[string]string, len(in))
137
  for k, v := range in {
 
4
  _ "embed"
5
  "encoding/json"
6
  "fmt"
7
+ "strings"
8
  )
9
 
10
  const (
 
46
  }
47
 
48
  var ClientVersion string
49
+ var currentPlatform = "android"
50
  var BaseHeaders = map[string]string{}
51
  var SkipContainsPatterns = cloneStringSlice(defaultSkipContainsPatterns)
52
  var SkipExactPathSet = toStringSet(defaultSkipExactPaths)
 
69
  //go:embed constants_shared.json
70
  var sharedConstantsJSON []byte
71
 
72
+ //go:embed constants_web.json
73
+ var webConstantsJSON []byte
74
+
75
  func init() {
76
  cfg := sharedConstants{}
77
  if err := json.Unmarshal(sharedConstantsJSON, &cfg); err != nil {
 
118
  }
119
  out[k] = v
120
  }
121
+ out["User-Agent"] = userAgentForPlatform(client)
 
 
 
 
 
 
122
  if client.Platform != "" {
123
  out["x-client-platform"] = client.Platform
124
  }
 
131
  return out
132
  }
133
 
134
+ const webUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36"
135
+
136
+ func userAgentForPlatform(client clientConstants) string {
137
+ if client.Platform == "web" {
138
+ return webUserAgent
139
+ }
140
+ if client.Name != "" && client.Version != "" {
141
+ ua := client.Name + "/" + client.Version
142
+ if client.Platform == "android" && client.AndroidAPILevel != "" {
143
+ ua += " Android/" + client.AndroidAPILevel
144
+ }
145
+ return ua
146
+ }
147
+ return ""
148
+ }
149
+
150
+ // ApplyPlatform reloads constants for the given platform ("android" or "web").
151
+ func ApplyPlatform(platform string) {
152
+ p := strings.ToLower(platform)
153
+ var data []byte
154
+ switch p {
155
+ case "web":
156
+ data = webConstantsJSON
157
+ default:
158
+ data = sharedConstantsJSON
159
+ p = "android"
160
+ }
161
+ cfg := sharedConstants{}
162
+ if err := json.Unmarshal(data, &cfg); err != nil {
163
+ panic(fmt.Errorf("load DeepSeek %s constants: %w", p, err))
164
+ }
165
+ currentPlatform = p
166
+ applySharedConstants(cfg)
167
+ }
168
+
169
+ // IsWebPlatform returns whether the current platform is web.
170
+ func IsWebPlatform() bool {
171
+ return currentPlatform == "web"
172
+ }
173
+
174
+ // UserAgent returns the appropriate User-Agent string based on the current platform.
175
+ func UserAgent() string {
176
+ if currentPlatform == "web" {
177
+ return webUserAgent
178
+ }
179
+ return "DeepSeek/" + ClientVersion
180
+ }
181
+
182
+ // WebExtraHeaders returns browser-specific headers that are not in the JSON
183
+ // config but need to be added dynamically for the web platform.
184
+ func WebExtraHeaders() map[string]string {
185
+ return map[string]string{
186
+ "sec-ch-ua": `"Chromium";v="137", "Not/A)Brand";v="24"`,
187
+ "sec-ch-ua-mobile": "?0",
188
+ "sec-ch-ua-platform": `"Windows"`,
189
+ "origin": "https://chat.deepseek.com",
190
+ "referer": "https://chat.deepseek.com/",
191
+ "sec-fetch-dest": "empty",
192
+ "sec-fetch-mode": "cors",
193
+ "sec-fetch-site": "same-origin",
194
+ }
195
+ }
196
+
197
  func cloneStringMap(in map[string]string) map[string]string {
198
  out := make(map[string]string, len(in))
199
  for k, v := range in {
internal/deepseek/protocol/constants_shared.json CHANGED
@@ -2,7 +2,7 @@
2
  "client": {
3
  "name": "DeepSeek",
4
  "platform": "android",
5
- "version": "2.1.2",
6
  "android_api_level": "32",
7
  "locale": "zh_CN"
8
  },
 
2
  "client": {
3
  "name": "DeepSeek",
4
  "platform": "android",
5
+ "version": "2.1.5",
6
  "android_api_level": "32",
7
  "locale": "zh_CN"
8
  },
internal/deepseek/protocol/constants_web.json ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "client": {
3
+ "name": "DeepSeek",
4
+ "platform": "web",
5
+ "version": "2.0.0",
6
+ "locale": "zh_CN"
7
+ },
8
+ "base_headers": {
9
+ "Host": "chat.deepseek.com",
10
+ "Accept": "*/*",
11
+ "Content-Type": "application/json",
12
+ "accept-encoding": "gzip, deflate, br, zstd",
13
+ "accept-charset": "UTF-8",
14
+ "x-app-version": "2.0.0",
15
+ "x-client-locale": "zh_CN",
16
+ "x-client-platform": "web",
17
+ "x-client-timezone-offset": "28800",
18
+ "x-client-version": "2.0.0"
19
+ },
20
+ "skip_contains_patterns": [
21
+ "quasi_status",
22
+ "elapsed_secs",
23
+ "pending_fragment",
24
+ "conversation_mode",
25
+ "fragments/-1/status",
26
+ "fragments/-2/status",
27
+ "fragments/-3/status"
28
+ ],
29
+ "skip_exact_paths": [
30
+ "response/search_status"
31
+ ]
32
+ }
internal/deepseek/transport/transport.go CHANGED
@@ -26,17 +26,27 @@ func New(timeout time.Duration) *Client {
26
  }
27
 
28
  func NewWithDialContext(timeout time.Duration, dialContext DialContextFunc) *Client {
 
 
 
 
 
 
 
 
29
  useEnvProxy := dialContext == nil
30
  if dialContext == nil {
31
  dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
32
  }
 
 
33
  base := &http.Transport{
34
- ForceAttemptHTTP2: false,
35
  MaxIdleConns: 200,
36
  MaxIdleConnsPerHost: 100,
37
  IdleConnTimeout: 90 * time.Second,
38
  DialContext: dialContext,
39
- DialTLSContext: safariTLSDialer(dialContext),
40
  TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
41
  }
42
  if useEnvProxy {
@@ -50,12 +60,17 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
50
  }
51
 
52
  func NewFallbackClient(timeout time.Duration, dialContext DialContextFunc) *http.Client {
 
 
 
 
53
  useEnvProxy := dialContext == nil
54
  if dialContext == nil {
55
  dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
56
  }
 
57
  base := &http.Transport{
58
- ForceAttemptHTTP2: false,
59
  MaxIdleConns: 200,
60
  MaxIdleConnsPerHost: 100,
61
  IdleConnTimeout: 90 * time.Second,
@@ -68,6 +83,38 @@ func NewFallbackClient(timeout time.Duration, dialContext DialContextFunc) *http
68
  return &http.Client{Timeout: timeout, Transport: base}
69
  }
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  func safariTLSDialer(dialContext DialContextFunc) func(ctx context.Context, network, addr string) (net.Conn, error) {
72
  if dialContext == nil {
73
  dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
 
26
  }
27
 
28
  func NewWithDialContext(timeout time.Duration, dialContext DialContextFunc) *Client {
29
+ return NewWithDialContextAndPlatform(timeout, dialContext, "android")
30
+ }
31
+
32
+ func NewWithPlatform(timeout time.Duration, platform string) *Client {
33
+ return NewWithDialContextAndPlatform(timeout, nil, platform)
34
+ }
35
+
36
+ func NewWithDialContextAndPlatform(timeout time.Duration, dialContext DialContextFunc, platform string) *Client {
37
  useEnvProxy := dialContext == nil
38
  if dialContext == nil {
39
  dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
40
  }
41
+ tlsDialer := TLSDialerForPlatform(platform, dialContext)
42
+ forceHTTP2 := platform == "web"
43
  base := &http.Transport{
44
+ ForceAttemptHTTP2: forceHTTP2,
45
  MaxIdleConns: 200,
46
  MaxIdleConnsPerHost: 100,
47
  IdleConnTimeout: 90 * time.Second,
48
  DialContext: dialContext,
49
+ DialTLSContext: tlsDialer,
50
  TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
51
  }
52
  if useEnvProxy {
 
60
  }
61
 
62
  func NewFallbackClient(timeout time.Duration, dialContext DialContextFunc) *http.Client {
63
+ return NewFallbackClientWithPlatform(timeout, dialContext, "android")
64
+ }
65
+
66
+ func NewFallbackClientWithPlatform(timeout time.Duration, dialContext DialContextFunc, platform string) *http.Client {
67
  useEnvProxy := dialContext == nil
68
  if dialContext == nil {
69
  dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
70
  }
71
+ forceHTTP2 := platform == "web"
72
  base := &http.Transport{
73
+ ForceAttemptHTTP2: forceHTTP2,
74
  MaxIdleConns: 200,
75
  MaxIdleConnsPerHost: 100,
76
  IdleConnTimeout: 90 * time.Second,
 
83
  return &http.Client{Timeout: timeout, Transport: base}
84
  }
85
 
86
+ // TLSDialerForPlatform returns the appropriate TLS dialer based on the platform.
87
+ // "web" uses Chrome TLS fingerprinting with HTTP/2 support;
88
+ // all other values (including "android") use Safari TLS fingerprinting with HTTP/1.1 only.
89
+ func TLSDialerForPlatform(platform string, dialContext DialContextFunc) func(ctx context.Context, network, addr string) (net.Conn, error) {
90
+ if platform == "web" {
91
+ return chromeTLSDialer(dialContext)
92
+ }
93
+ return safariTLSDialer(dialContext)
94
+ }
95
+
96
+ func chromeTLSDialer(dialContext DialContextFunc) func(ctx context.Context, network, addr string) (net.Conn, error) {
97
+ if dialContext == nil {
98
+ dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
99
+ }
100
+ return func(ctx context.Context, network, addr string) (net.Conn, error) {
101
+ plainConn, err := dialContext(ctx, network, addr)
102
+ if err != nil {
103
+ return nil, err
104
+ }
105
+ host, _, _ := net.SplitHostPort(addr)
106
+ uCfg := &utls.Config{ServerName: host}
107
+ uConn := utls.UClient(plainConn, uCfg, utls.HelloChrome_Auto)
108
+ // Do NOT force HTTP/1.1 ALPN; allow natural ALPN negotiation (h2 or http/1.1).
109
+ err = uConn.HandshakeContext(ctx)
110
+ if err != nil {
111
+ _ = plainConn.Close()
112
+ return nil, err
113
+ }
114
+ return uConn, nil
115
+ }
116
+ }
117
+
118
  func safariTLSDialer(dialContext DialContextFunc) func(ctx context.Context, network, addr string) (net.Conn, error) {
119
  if dialContext == nil {
120
  dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
internal/httpapi/admin/accounts/handler_accounts_testing_test.go CHANGED
@@ -62,6 +62,8 @@ func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*d
62
  return &dsclient.SessionStats{Success: true}, nil
63
  }
64
 
 
 
65
  func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
66
  t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
67
  store := config.LoadStore()
@@ -167,6 +169,8 @@ func (m *completionPayloadDSMock) GetSessionCountForToken(_ context.Context, _ s
167
  return &dsclient.SessionStats{Success: true}, nil
168
  }
169
 
 
 
170
  func TestTestAccount_MessageModeUsesExpertModelTypeForExpertModel(t *testing.T) {
171
  t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"seed-token"}]}`)
172
  store := config.LoadStore()
 
62
  return &dsclient.SessionStats{Success: true}, nil
63
  }
64
 
65
+ func (m *testingDSMock) SetPlatform(_ string) {}
66
+
67
  func TestTestAccount_BatchModeOnlyCreatesSession(t *testing.T) {
68
  t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":""}]}`)
69
  store := config.LoadStore()
 
169
  return &dsclient.SessionStats{Success: true}, nil
170
  }
171
 
172
+ func (m *completionPayloadDSMock) SetPlatform(_ string) {}
173
+
174
  func TestTestAccount_MessageModeUsesExpertModelTypeForExpertModel(t *testing.T) {
175
  t.Setenv("DS2API_CONFIG_JSON", `{"accounts":[{"email":"batch@example.com","password":"pwd","token":"seed-token"}]}`)
176
  store := config.LoadStore()
internal/httpapi/admin/proxies/test_http_helpers_test.go CHANGED
@@ -35,6 +35,7 @@ func (m *testingDSMock) DeleteAllSessionsForToken(_ context.Context, _ string) e
35
  func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*dsclient.SessionStats, error) {
36
  return &dsclient.SessionStats{}, nil
37
  }
 
38
 
39
  func newHTTPAdminHarness(t *testing.T, rawConfig string, ds adminshared.DeepSeekCaller) http.Handler {
40
  t.Helper()
 
35
  func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*dsclient.SessionStats, error) {
36
  return &dsclient.SessionStats{}, nil
37
  }
38
+ func (m *testingDSMock) SetPlatform(_ string) {}
39
 
40
  func newHTTPAdminHarness(t *testing.T, rawConfig string, ds adminshared.DeepSeekCaller) http.Handler {
41
  t.Helper()
internal/httpapi/admin/settings/handler_settings_parse.go CHANGED
@@ -21,7 +21,7 @@ func boolFrom(v any) bool {
21
  }
22
  }
23
 
24
- func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.CurrentInputFileConfig, *config.ThinkingInjectionConfig, map[string]string, error) {
25
  var (
26
  adminCfg *config.AdminConfig
27
  runtimeCfg *config.RuntimeConfig
@@ -30,6 +30,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
30
  autoDeleteCfg *config.AutoDeleteConfig
31
  currentInputCfg *config.CurrentInputFileConfig
32
  thinkingInjCfg *config.ThinkingInjectionConfig
 
33
  aliasMap map[string]string
34
  )
35
 
@@ -38,7 +39,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
38
  if v, exists := raw["jwt_expire_hours"]; exists {
39
  n := intFrom(v)
40
  if err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil {
41
- return nil, nil, nil, nil, nil, nil, nil, nil, err
42
  }
43
  cfg.JWTExpireHours = n
44
  }
@@ -50,40 +51,40 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
50
  if v, exists := raw["account_max_inflight"]; exists {
51
  n := intFrom(v)
52
  if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil {
53
- return nil, nil, nil, nil, nil, nil, nil, nil, err
54
  }
55
  cfg.AccountMaxInflight = n
56
  }
57
  if v, exists := raw["account_max_queue"]; exists {
58
  n := intFrom(v)
59
  if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil {
60
- return nil, nil, nil, nil, nil, nil, nil, nil, err
61
  }
62
  cfg.AccountMaxQueue = n
63
  }
64
  if v, exists := raw["global_max_inflight"]; exists {
65
  n := intFrom(v)
66
  if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil {
67
- return nil, nil, nil, nil, nil, nil, nil, nil, err
68
  }
69
  cfg.GlobalMaxInflight = n
70
  }
71
  if v, exists := raw["token_refresh_interval_hours"]; exists {
72
  n := intFrom(v)
73
  if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil {
74
- return nil, nil, nil, nil, nil, nil, nil, nil, err
75
  }
76
  cfg.TokenRefreshIntervalHours = n
77
  }
78
  if v, exists := raw["backup_release_count"]; exists {
79
  n := intFrom(v)
80
  if err := config.ValidateIntRange("runtime.backup_release_count", n, 1, 100, true); err != nil {
81
- return nil, nil, nil, nil, nil, nil, nil, nil, err
82
  }
83
  cfg.BackupReleaseCount = n
84
  }
85
  if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
86
- return nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
87
  }
88
  runtimeCfg = cfg
89
  }
@@ -93,7 +94,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
93
  if v, exists := raw["store_ttl_seconds"]; exists {
94
  n := intFrom(v)
95
  if err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil {
96
- return nil, nil, nil, nil, nil, nil, nil, nil, err
97
  }
98
  cfg.StoreTTLSeconds = n
99
  }
@@ -105,7 +106,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
105
  if v, exists := raw["provider"]; exists {
106
  p := strings.TrimSpace(fmt.Sprintf("%v", v))
107
  if err := config.ValidateTrimmedString("embeddings.provider", p, false); err != nil {
108
- return nil, nil, nil, nil, nil, nil, nil, nil, err
109
  }
110
  cfg.Provider = p
111
  }
@@ -131,7 +132,7 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
131
  if v, exists := raw["mode"]; exists {
132
  mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
133
  if err := config.ValidateAutoDeleteMode(mode); err != nil {
134
- return nil, nil, nil, nil, nil, nil, nil, nil, err
135
  }
136
  if mode == "" {
137
  mode = "none"
@@ -153,12 +154,12 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
153
  if v, exists := raw["min_chars"]; exists {
154
  n := intFrom(v)
155
  if err := config.ValidateIntRange("current_input_file.min_chars", n, 0, 100000000, true); err != nil {
156
- return nil, nil, nil, nil, nil, nil, nil, nil, err
157
  }
158
  cfg.MinChars = n
159
  }
160
  if err := config.ValidateCurrentInputFileConfig(*cfg); err != nil {
161
- return nil, nil, nil, nil, nil, nil, nil, nil, err
162
  }
163
  currentInputCfg = cfg
164
  }
@@ -175,5 +176,17 @@ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *confi
175
  thinkingInjCfg = cfg
176
  }
177
 
178
- return adminCfg, runtimeCfg, respCfg, embCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, aliasMap, nil
 
 
 
 
 
 
 
 
 
 
 
 
179
  }
 
21
  }
22
  }
23
 
24
+ func parseSettingsUpdateRequest(req map[string]any) (*config.AdminConfig, *config.RuntimeConfig, *config.ResponsesConfig, *config.EmbeddingsConfig, *config.AutoDeleteConfig, *config.CurrentInputFileConfig, *config.ThinkingInjectionConfig, *config.PlatformConfig, map[string]string, error) {
25
  var (
26
  adminCfg *config.AdminConfig
27
  runtimeCfg *config.RuntimeConfig
 
30
  autoDeleteCfg *config.AutoDeleteConfig
31
  currentInputCfg *config.CurrentInputFileConfig
32
  thinkingInjCfg *config.ThinkingInjectionConfig
33
+ platformCfg *config.PlatformConfig
34
  aliasMap map[string]string
35
  )
36
 
 
39
  if v, exists := raw["jwt_expire_hours"]; exists {
40
  n := intFrom(v)
41
  if err := config.ValidateIntRange("admin.jwt_expire_hours", n, 1, 720, true); err != nil {
42
+ return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
43
  }
44
  cfg.JWTExpireHours = n
45
  }
 
51
  if v, exists := raw["account_max_inflight"]; exists {
52
  n := intFrom(v)
53
  if err := config.ValidateIntRange("runtime.account_max_inflight", n, 1, 256, true); err != nil {
54
+ return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
55
  }
56
  cfg.AccountMaxInflight = n
57
  }
58
  if v, exists := raw["account_max_queue"]; exists {
59
  n := intFrom(v)
60
  if err := config.ValidateIntRange("runtime.account_max_queue", n, 1, 200000, true); err != nil {
61
+ return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
62
  }
63
  cfg.AccountMaxQueue = n
64
  }
65
  if v, exists := raw["global_max_inflight"]; exists {
66
  n := intFrom(v)
67
  if err := config.ValidateIntRange("runtime.global_max_inflight", n, 1, 200000, true); err != nil {
68
+ return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
69
  }
70
  cfg.GlobalMaxInflight = n
71
  }
72
  if v, exists := raw["token_refresh_interval_hours"]; exists {
73
  n := intFrom(v)
74
  if err := config.ValidateIntRange("runtime.token_refresh_interval_hours", n, 1, 720, true); err != nil {
75
+ return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
76
  }
77
  cfg.TokenRefreshIntervalHours = n
78
  }
79
  if v, exists := raw["backup_release_count"]; exists {
80
  n := intFrom(v)
81
  if err := config.ValidateIntRange("runtime.backup_release_count", n, 1, 100, true); err != nil {
82
+ return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
83
  }
84
  cfg.BackupReleaseCount = n
85
  }
86
  if cfg.AccountMaxInflight > 0 && cfg.GlobalMaxInflight > 0 && cfg.GlobalMaxInflight < cfg.AccountMaxInflight {
87
+ return nil, nil, nil, nil, nil, nil, nil, nil, nil, fmt.Errorf("runtime.global_max_inflight must be >= runtime.account_max_inflight")
88
  }
89
  runtimeCfg = cfg
90
  }
 
94
  if v, exists := raw["store_ttl_seconds"]; exists {
95
  n := intFrom(v)
96
  if err := config.ValidateIntRange("responses.store_ttl_seconds", n, 30, 86400, true); err != nil {
97
+ return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
98
  }
99
  cfg.StoreTTLSeconds = n
100
  }
 
106
  if v, exists := raw["provider"]; exists {
107
  p := strings.TrimSpace(fmt.Sprintf("%v", v))
108
  if err := config.ValidateTrimmedString("embeddings.provider", p, false); err != nil {
109
+ return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
110
  }
111
  cfg.Provider = p
112
  }
 
132
  if v, exists := raw["mode"]; exists {
133
  mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
134
  if err := config.ValidateAutoDeleteMode(mode); err != nil {
135
+ return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
136
  }
137
  if mode == "" {
138
  mode = "none"
 
154
  if v, exists := raw["min_chars"]; exists {
155
  n := intFrom(v)
156
  if err := config.ValidateIntRange("current_input_file.min_chars", n, 0, 100000000, true); err != nil {
157
+ return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
158
  }
159
  cfg.MinChars = n
160
  }
161
  if err := config.ValidateCurrentInputFileConfig(*cfg); err != nil {
162
+ return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
163
  }
164
  currentInputCfg = cfg
165
  }
 
176
  thinkingInjCfg = cfg
177
  }
178
 
179
+ if raw, ok := req["platform"].(map[string]any); ok {
180
+ cfg := &config.PlatformConfig{}
181
+ if v, exists := raw["mode"]; exists {
182
+ mode := strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", v)))
183
+ if err := config.ValidatePlatformConfig(config.PlatformConfig{Mode: mode}); err != nil {
184
+ return nil, nil, nil, nil, nil, nil, nil, nil, nil, err
185
+ }
186
+ cfg.Mode = mode
187
+ }
188
+ platformCfg = cfg
189
+ }
190
+
191
+ return adminCfg, runtimeCfg, respCfg, embCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, platformCfg, aliasMap, nil
192
  }
internal/httpapi/admin/settings/handler_settings_read.go CHANGED
@@ -38,6 +38,7 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
38
  "prompt": snap.ThinkingInjection.Prompt,
39
  },
40
  "model_aliases": snap.ModelAliases,
 
41
  },
42
  "admin": map[string]any{
43
  "has_password_hash": strings.TrimSpace(snap.Admin.PasswordHash) != "",
@@ -64,7 +65,10 @@ func (h *Handler) getSettings(w http.ResponseWriter, _ *http.Request) {
64
  "prompt": h.Store.ThinkingInjectionPrompt(),
65
  "default_prompt": promptcompat.DefaultThinkingInjectionPrompt,
66
  },
67
- "model_aliases": snap.ModelAliases,
 
 
 
68
  "env_backed": h.Store.IsEnvBacked(),
69
  "needs_vercel_sync": needsSync,
70
  })
 
38
  "prompt": snap.ThinkingInjection.Prompt,
39
  },
40
  "model_aliases": snap.ModelAliases,
41
+ "platform": snap.Platform,
42
  },
43
  "admin": map[string]any{
44
  "has_password_hash": strings.TrimSpace(snap.Admin.PasswordHash) != "",
 
65
  "prompt": h.Store.ThinkingInjectionPrompt(),
66
  "default_prompt": promptcompat.DefaultThinkingInjectionPrompt,
67
  },
68
+ "model_aliases": snap.ModelAliases,
69
+ "platform": map[string]any{
70
+ "mode": h.Store.PlatformMode(),
71
+ },
72
  "env_backed": h.Store.IsEnvBacked(),
73
  "needs_vercel_sync": needsSync,
74
  })
internal/httpapi/admin/settings/handler_settings_runtime.go CHANGED
@@ -36,6 +36,13 @@ func (h *Handler) applyRuntimeSettings() {
36
  h.Pool.ApplyRuntimeLimits(maxPer, maxQueue, global)
37
  }
38
 
 
 
 
 
 
 
 
39
  func defaultRuntimeRecommended(accountCount, maxPer int) int {
40
  if maxPer <= 0 {
41
  maxPer = 1
 
36
  h.Pool.ApplyRuntimeLimits(maxPer, maxQueue, global)
37
  }
38
 
39
+ func (h *Handler) applyPlatformChange() {
40
+ if h == nil || h.Store == nil || h.DS == nil {
41
+ return
42
+ }
43
+ h.DS.SetPlatform(h.Store.PlatformMode())
44
+ }
45
+
46
  func defaultRuntimeRecommended(accountCount, maxPer int) int {
47
  if maxPer <= 0 {
48
  maxPer = 1
internal/httpapi/admin/settings/handler_settings_write.go CHANGED
@@ -17,7 +17,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
17
  return
18
  }
19
 
20
- adminCfg, runtimeCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, aliasMap, err := parseSettingsUpdateRequest(req)
21
  if err != nil {
22
  writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
23
  return
@@ -85,6 +85,9 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
85
  if aliasMap != nil {
86
  c.ModelAliases = aliasMap
87
  }
 
 
 
88
  return nil
89
  }); err != nil {
90
  writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
@@ -92,6 +95,7 @@ func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
92
  }
93
 
94
  h.applyRuntimeSettings()
 
95
  needsSync := config.IsVercel() || h.Store.IsEnvBacked()
96
  writeJSON(w, http.StatusOK, map[string]any{
97
  "success": true,
 
17
  return
18
  }
19
 
20
+ adminCfg, runtimeCfg, responsesCfg, embeddingsCfg, autoDeleteCfg, currentInputCfg, thinkingInjCfg, platformCfg, aliasMap, err := parseSettingsUpdateRequest(req)
21
  if err != nil {
22
  writeJSON(w, http.StatusBadRequest, map[string]any{"detail": err.Error()})
23
  return
 
85
  if aliasMap != nil {
86
  c.ModelAliases = aliasMap
87
  }
88
+ if platformCfg != nil {
89
+ c.Platform.Mode = platformCfg.Mode
90
+ }
91
  return nil
92
  }); err != nil {
93
  writeJSON(w, http.StatusInternalServerError, map[string]any{"detail": err.Error()})
 
95
  }
96
 
97
  h.applyRuntimeSettings()
98
+ h.applyPlatformChange()
99
  needsSync := config.IsVercel() || h.Store.IsEnvBacked()
100
  writeJSON(w, http.StatusOK, map[string]any{
101
  "success": true,
internal/httpapi/admin/shared/deps.go CHANGED
@@ -41,6 +41,7 @@ type ConfigStore interface {
41
  AutoDeleteSessions() bool
42
  SetAccountBanned(identifier string, banned bool) (config.Account, error)
43
  SetAccountRole(identifier string, role string) (config.Account, error)
 
44
  ActiveNormalAccounts() int
45
  TotalNormalAccounts() int
46
  PromoteStandbyAccounts(n int) []string
@@ -63,6 +64,7 @@ type DeepSeekCaller interface {
63
  CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
64
  GetSessionCountForToken(ctx context.Context, token string) (*dsclient.SessionStats, error)
65
  DeleteAllSessionsForToken(ctx context.Context, token string) error
 
66
  }
67
 
68
  var _ ConfigStore = (*config.Store)(nil)
 
41
  AutoDeleteSessions() bool
42
  SetAccountBanned(identifier string, banned bool) (config.Account, error)
43
  SetAccountRole(identifier string, role string) (config.Account, error)
44
+ PlatformMode() string
45
  ActiveNormalAccounts() int
46
  TotalNormalAccounts() int
47
  PromoteStandbyAccounts(n int) []string
 
64
  CallCompletion(ctx context.Context, a *auth.RequestAuth, payload map[string]any, powResp string, maxAttempts int) (*http.Response, error)
65
  GetSessionCountForToken(ctx context.Context, token string) (*dsclient.SessionStats, error)
66
  DeleteAllSessionsForToken(ctx context.Context, token string) error
67
+ SetPlatform(platform string)
68
  }
69
 
70
  var _ ConfigStore = (*config.Store)(nil)
internal/httpapi/admin/test_bridge_test.go CHANGED
@@ -82,6 +82,8 @@ func (m *testingDSMock) GetSessionCountForToken(_ context.Context, _ string) (*d
82
  return &dsclient.SessionStats{}, nil
83
  }
84
 
 
 
85
  func (h *Handler) configHandler() *adminconfig.Handler {
86
  return &adminconfig.Handler{Store: h.Store, Pool: h.Pool, DS: h.DS, OpenAI: h.OpenAI, ChatHistory: h.ChatHistory}
87
  }
 
82
  return &dsclient.SessionStats{}, nil
83
  }
84
 
85
+ func (m *testingDSMock) SetPlatform(_ string) {}
86
+
87
  func (h *Handler) configHandler() *adminconfig.Handler {
88
  return &adminconfig.Handler{Store: h.Store, Pool: h.Pool, DS: h.DS, OpenAI: h.OpenAI, ChatHistory: h.ChatHistory}
89
  }
internal/shumei/api.go ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package shumei
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "io"
9
+ "net/http"
10
+ "time"
11
+ )
12
+
13
+ // callDeviceAPI POSTs the encrypted payload to the Shumei device profile API
14
+ // and returns the deviceId from the response.
15
+ func callDeviceAPI(ctx context.Context, ep, data string) (string, error) {
16
+ payload := map[string]any{
17
+ "appId": appId,
18
+ "organization": organization,
19
+ "ep": ep,
20
+ "data": data,
21
+ "os": "web",
22
+ "encode": 5,
23
+ "compress": 2,
24
+ }
25
+ body, err := json.Marshal(payload)
26
+ if err != nil {
27
+ return "", fmt.Errorf("marshal payload: %w", err)
28
+ }
29
+
30
+ url := "https://" + apiHost + apiPath
31
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
32
+ if err != nil {
33
+ return "", fmt.Errorf("create request: %w", err)
34
+ }
35
+ req.Header.Set("Content-Type", "application/json")
36
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36")
37
+ req.Header.Set("Origin", "https://chat.deepseek.com")
38
+ req.Header.Set("Referer", "https://chat.deepseek.com/")
39
+
40
+ resp, err := httpDoer.Do(req)
41
+ if err != nil {
42
+ return "", fmt.Errorf("http post: %w", err)
43
+ }
44
+ defer func() { _ = resp.Body.Close() }()
45
+
46
+ respBody, err := io.ReadAll(resp.Body)
47
+ if err != nil {
48
+ return "", fmt.Errorf("read response: %w", err)
49
+ }
50
+
51
+ if resp.StatusCode != http.StatusOK {
52
+ return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
53
+ }
54
+
55
+ var result map[string]any
56
+ if err := json.Unmarshal(respBody, &result); err != nil {
57
+ return "", fmt.Errorf("parse response: %w", err)
58
+ }
59
+
60
+ code, _ := result["code"].(float64)
61
+ if int(code) != 1100 {
62
+ return "", fmt.Errorf("api error code %v: %s", result["code"], string(respBody))
63
+ }
64
+
65
+ detail, _ := result["detail"].(map[string]any)
66
+ deviceID, _ := detail["deviceId"].(string)
67
+ if deviceID == "" {
68
+ return "", fmt.Errorf("missing deviceId in response: %s", string(respBody))
69
+ }
70
+
71
+ return deviceID, nil
72
+ }
73
+
74
+ // httpDoer is the HTTP client used for Shumei API calls.
75
+ // Initialized in init() using the project's Chrome TLS transport.
76
+ var httpDoer httpDoerInterface
77
+
78
+ type httpDoerInterface interface {
79
+ Do(req *http.Request) (*http.Response, error)
80
+ }
81
+
82
+ func init() {
83
+ httpDoer = &http.Client{Timeout: defaultHTTPTimeout}
84
+ }
85
+
86
+ // SetHTTPDoer allows overriding the HTTP client (e.g. for testing or
87
+ // to use the project's Chrome TLS transport).
88
+ func SetHTTPDoer(d httpDoerInterface) {
89
+ if d != nil {
90
+ httpDoer = d
91
+ }
92
+ }
93
+
94
+ const defaultHTTPTimeout = 15 * time.Second
internal/shumei/confusion.go ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package shumei
2
+
3
+ // confusionEntry maps an original field name to its DES key and obfuscated name.
4
+ type confusionEntry struct {
5
+ desKey string
6
+ obfuscated string
7
+ }
8
+
9
+ // confusionMapping is Configuration A for organization P9usCUBauxft8eAmUXaZ.
10
+ var confusionMapping = map[string]confusionEntry{
11
+ "appId": {"q8zb1bs1", "vw"},
12
+ "canvas": {"qy1d6fmu", "mi"},
13
+ "organization": {"ht432iov", "tj"},
14
+ "os": {"i7g8jtql", "vh"},
15
+ "platform": {"mcbmtg5y", "ye"},
16
+ "plugins": {"j916a142", "vc"},
17
+ "referer": {"rf149ntc", "hv"},
18
+ "res": {"rbtd2cl6", "pi"},
19
+ "sdkver": {"rw74ssux", "xh"},
20
+ "status": {"4xx5u0ww", "uu"},
21
+ "subVersion": {"9wg0vhb9", "xi"},
22
+ "svm": {"mqcala0h", "nu"},
23
+ "time": {"8e224y3f", "gw"},
24
+ "timezone": {"3w9lg8pr", "oi"},
25
+ "rtype": {"rx7ob4gc", "na"},
26
+ "tn": {"ilnts67v", "nb"},
27
+ "trees": {"al8x9zt7", "hc"},
28
+ "ua": {"z5jc9qyp", "wr"},
29
+ "url": {"6tbl1wpw", "no"},
30
+ "vpw": {"xzvnu5jt", "st"},
31
+ }
32
+
33
+ // applyConfusion encrypts each field value with its DES key using ECB mode,
34
+ // base64-encodes the result, and renames the field to its obfuscated name.
35
+ // Fields not in the mapping are dropped.
36
+ func applyConfusion(data map[string]string) map[string]string {
37
+ out := make(map[string]string, len(data))
38
+ for field, value := range data {
39
+ entry, ok := confusionMapping[field]
40
+ if !ok {
41
+ continue
42
+ }
43
+ encrypted, err := desEncryptECB(entry.desKey, value)
44
+ if err != nil {
45
+ // Skip fields that fail to encrypt
46
+ continue
47
+ }
48
+ out[entry.obfuscated] = encrypted
49
+ }
50
+ return out
51
+ }
internal/shumei/crypto.go ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package shumei
2
+
3
+ import (
4
+ "bytes"
5
+ "compress/gzip"
6
+ "crypto/aes"
7
+ "crypto/cipher"
8
+ "crypto/des"
9
+ "crypto/md5"
10
+ "crypto/rand"
11
+ "crypto/rsa"
12
+ "crypto/x509"
13
+ "encoding/base64"
14
+ "encoding/hex"
15
+ "encoding/pem"
16
+ "fmt"
17
+ "sort"
18
+ "strings"
19
+ )
20
+
21
+ // generateUUID returns a random UUID v4 string.
22
+ func generateUUID() string {
23
+ b := make([]byte, 16)
24
+ _, _ = rand.Read(b)
25
+ b[6] = (b[6] & 0x0f) | 0x40 // version 4
26
+ b[8] = (b[8] & 0x3f) | 0x80 // variant 10
27
+ return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
28
+ b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
29
+ }
30
+
31
+ // rsaEncryptUID encrypts uid with the Shumei RSA public key using PKCS#1 v1.5
32
+ // and returns the result as a hex-encoded string.
33
+ func rsaEncryptUID(uid string) (string, error) {
34
+ block, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\n" + rsaPublicKey + "\n-----END PUBLIC KEY-----"))
35
+ if block == nil {
36
+ return "", fmt.Errorf("failed to decode PEM block")
37
+ }
38
+ pub, err := x509.ParsePKIXPublicKey(block.Bytes)
39
+ if err != nil {
40
+ return "", fmt.Errorf("parse PKIX public key: %w", err)
41
+ }
42
+ rsaPub, ok := pub.(*rsa.PublicKey)
43
+ if !ok {
44
+ return "", fmt.Errorf("not an RSA public key")
45
+ }
46
+ ciphertext, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPub, []byte(uid))
47
+ if err != nil {
48
+ return "", fmt.Errorf("RSA encrypt: %w", err)
49
+ }
50
+ return hex.EncodeToString(ciphertext), nil
51
+ }
52
+
53
+ // desEncryptECB encrypts plaintext with DES in ECB mode using the given key
54
+ // (8-byte string). The plaintext is zero-padded to a multiple of 8 bytes.
55
+ // Returns the base64-encoded ciphertext.
56
+ func desEncryptECB(key, plaintext string) (string, error) {
57
+ block, err := des.NewCipher([]byte(key))
58
+ if err != nil {
59
+ return "", fmt.Errorf("des new cipher: %w", err)
60
+ }
61
+ src := []byte(plaintext)
62
+ // Zero-pad to block size
63
+ if rem := len(src) % des.BlockSize; rem != 0 {
64
+ src = append(src, make([]byte, des.BlockSize-rem)...)
65
+ }
66
+ dst := make([]byte, len(src))
67
+ for i := 0; i < len(src); i += des.BlockSize {
68
+ block.Encrypt(dst[i:i+des.BlockSize], src[i:i+des.BlockSize])
69
+ }
70
+ return base64.StdEncoding.EncodeToString(dst), nil
71
+ }
72
+
73
+ // aesEncryptCBC encrypts plaintext with AES-CBC using the given key and IV,
74
+ // applying zero-padding to a multiple of the block size.
75
+ func aesEncryptCBC(key, iv, plaintext []byte) ([]byte, error) {
76
+ block, err := aes.NewCipher(key)
77
+ if err != nil {
78
+ return nil, fmt.Errorf("aes new cipher: %w", err)
79
+ }
80
+ // Zero-pad to block size
81
+ bs := aes.BlockSize
82
+ if rem := len(plaintext) % bs; rem != 0 {
83
+ plaintext = append(plaintext, make([]byte, bs-rem)...)
84
+ }
85
+ ciphertext := make([]byte, len(plaintext))
86
+ mode := cipher.NewCBCEncrypter(block, iv)
87
+ mode.CryptBlocks(ciphertext, plaintext)
88
+ return ciphertext, nil
89
+ }
90
+
91
+ // computeTN computes the integrity check: MD5 of sorted key=value pairs
92
+ // joined by "&".
93
+ func computeTN(data map[string]string) string {
94
+ keys := make([]string, 0, len(data))
95
+ for k := range data {
96
+ keys = append(keys, k)
97
+ }
98
+ sort.Strings(keys)
99
+ var b strings.Builder
100
+ for i, k := range keys {
101
+ if i > 0 {
102
+ b.WriteByte('&')
103
+ }
104
+ b.WriteString(k)
105
+ b.WriteByte('=')
106
+ b.WriteString(data[k])
107
+ }
108
+ hash := md5.Sum([]byte(b.String()))
109
+ return hex.EncodeToString(hash[:])
110
+ }
111
+
112
+ // gzipCompress compresses data with gzip.
113
+ func gzipCompress(data []byte) ([]byte, error) {
114
+ var buf bytes.Buffer
115
+ w := gzip.NewWriter(&buf)
116
+ if _, err := w.Write(data); err != nil {
117
+ _ = w.Close()
118
+ return nil, fmt.Errorf("gzip write: %w", err)
119
+ }
120
+ if err := w.Close(); err != nil {
121
+ return nil, fmt.Errorf("gzip close: %w", err)
122
+ }
123
+ return buf.Bytes(), nil
124
+ }
internal/shumei/device_id.go ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package shumei
2
+
3
+ import (
4
+ "context"
5
+ "crypto/md5"
6
+ "encoding/hex"
7
+ "encoding/json"
8
+ "fmt"
9
+ "time"
10
+ )
11
+
12
+ const (
13
+ rsaPublicKey = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDetfEgYD4aE1ZjmWJ6/jnPurhzI+yeRoJHWrnNtQMte3stQ4VjG3yu21FuN75E6cDpA9KtDXwcB2M/FiGUAe3G0rNotbWI8+SjZfUbW/OILFTzY0uaeEkmVGW5WyJ6weQbbr1xTCPa2OO3YIMeZljWUYHG5h21WAm/PATg8im8cQIDAQAB"
14
+ organization = "P9usCUBauxft8eAmUXaZ"
15
+ appId = "default"
16
+ apiHost = "fp-it-acc.portal101.cn"
17
+ apiPath = "/deviceprofile/v4"
18
+ sdkVersion = "3.0.0"
19
+ subVersion = "1.0.0"
20
+ aesIV = "0102030405060708"
21
+ )
22
+
23
+ // GetDeviceID generates a Shumei device_id for web platform login.
24
+ // It returns the device ID prefixed with "B".
25
+ func GetDeviceID(ctx context.Context) (string, error) {
26
+ // 1. Generate UUID v4
27
+ uid := generateUUID()
28
+
29
+ // 2. Compute priId = md5(uid)[:16]
30
+ hash := md5.Sum([]byte(uid))
31
+ priId := hex.EncodeToString(hash[:])[:16]
32
+
33
+ // 3. RSA encrypt uid
34
+ ep, err := rsaEncryptUID(uid)
35
+ if err != nil {
36
+ return "", fmt.Errorf("rsa encrypt: %w", err)
37
+ }
38
+
39
+ // 4. Build fingerprint data
40
+ fpData := buildFingerprintData(uid)
41
+
42
+ // 5. Apply DES confusion (encrypt + rename fields)
43
+ confused := applyConfusion(fpData)
44
+
45
+ // 6. Compute tn (integrity check) and add remaining fields
46
+ tn := computeTN(confused)
47
+ confused["nb"] = tn // "tn" maps to "nb" in confusion
48
+ confused["protocol"] = "-1"
49
+
50
+ // 7. JSON marshal, gzip, AES-CBC encrypt
51
+ data, err := encryptPayload(confused, priId)
52
+ if err != nil {
53
+ return "", fmt.Errorf("encrypt payload: %w", err)
54
+ }
55
+
56
+ // 8. Call API
57
+ deviceID, err := callDeviceAPI(ctx, ep, data)
58
+ if err != nil {
59
+ return "", fmt.Errorf("call api: %w", err)
60
+ }
61
+
62
+ return "B" + deviceID, nil
63
+ }
64
+
65
+ // buildFingerprintData constructs the fingerprint fields with realistic but
66
+ // minimal values suitable for server-side use.
67
+ func buildFingerprintData(uid string) map[string]string {
68
+ now := time.Now()
69
+ return map[string]string{
70
+ "appId": appId,
71
+ "organization": organization,
72
+ "os": "web",
73
+ "sdkver": sdkVersion,
74
+ "subVersion": subVersion,
75
+ "platform": "Win32",
76
+ "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36",
77
+ "timezone": "-480",
78
+ "res": "1920_1080_24_1",
79
+ "canvas": generateCanvasHash(),
80
+ "plugins": "",
81
+ "referer": "https://chat.deepseek.com/",
82
+ "url": "https://chat.deepseek.com/",
83
+ "status": "",
84
+ "vpw": uid,
85
+ "svm": fmt.Sprintf("%d", now.UnixMilli()),
86
+ "trees": uid,
87
+ "rtype": "1",
88
+ }
89
+ }
90
+
91
+ // generateCanvasHash returns a deterministic but random-looking MD5 hash
92
+ // for the canvas fingerprint field.
93
+ func generateCanvasHash() string {
94
+ h := md5.Sum([]byte("shumei-canvas-fp-v1"))
95
+ return hex.EncodeToString(h[:])
96
+ }
97
+
98
+ // encryptPayload marshals the confused data to JSON, gzip-compresses it,
99
+ // then AES-CBC encrypts with the given key and a fixed IV.
100
+ // Returns the base64-encoded ciphertext.
101
+ func encryptPayload(data map[string]string, priId string) (string, error) {
102
+ jsonData, err := json.Marshal(data)
103
+ if err != nil {
104
+ return "", fmt.Errorf("json marshal: %w", err)
105
+ }
106
+
107
+ compressed, err := gzipCompress(jsonData)
108
+ if err != nil {
109
+ return "", fmt.Errorf("gzip compress: %w", err)
110
+ }
111
+
112
+ // AES-CBC encrypt: key = priId (16 bytes), IV = fixed
113
+ key := []byte(priId)
114
+ iv := []byte(aesIV)
115
+ ciphertext, err := aesEncryptCBC(key, iv, compressed)
116
+ if err != nil {
117
+ return "", fmt.Errorf("aes encrypt: %w", err)
118
+ }
119
+
120
+ return hex.EncodeToString(ciphertext), nil
121
+ }
webui/src/features/settings/PlatformSection.jsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export default function PlatformSection({ t, form, setForm }) {
2
+ return (
3
+ <div className="bg-card border border-border rounded-xl p-5 space-y-4">
4
+ <h3 className="font-semibold">{t('settings.platformTitle')}</h3>
5
+ <p className="text-sm text-muted-foreground">{t('settings.platformDescription')}</p>
6
+ <div className="flex gap-4">
7
+ <label className="flex items-center gap-2 cursor-pointer">
8
+ <input
9
+ type="radio"
10
+ name="platform-mode"
11
+ value="android"
12
+ checked={form.platform.mode === 'android'}
13
+ onChange={() => setForm((prev) => ({
14
+ ...prev,
15
+ platform: { ...prev.platform, mode: 'android' },
16
+ }))}
17
+ className="w-4 h-4"
18
+ />
19
+ <span className="text-sm">{t('settings.platformAndroid')}</span>
20
+ </label>
21
+ <label className="flex items-center gap-2 cursor-pointer">
22
+ <input
23
+ type="radio"
24
+ name="platform-mode"
25
+ value="web"
26
+ checked={form.platform.mode === 'web'}
27
+ onChange={() => setForm((prev) => ({
28
+ ...prev,
29
+ platform: { ...prev.platform, mode: 'web' },
30
+ }))}
31
+ className="w-4 h-4"
32
+ />
33
+ <span className="text-sm">{t('settings.platformWeb')}</span>
34
+ </label>
35
+ </div>
36
+ {form.platform.mode === 'web' && (
37
+ <p className="text-xs text-amber-600 dark:text-amber-400">
38
+ {t('settings.platformWebNotice')}
39
+ </p>
40
+ )}
41
+ </div>
42
+ )
43
+ }
webui/src/features/settings/SettingsContainer.jsx CHANGED
@@ -3,6 +3,7 @@ import { AlertTriangle, Save } from 'lucide-react'
3
  import { useI18n } from '../../i18n'
4
  import { useSettingsForm } from './useSettingsForm'
5
  import SecuritySection from './SecuritySection'
 
6
  import RuntimeSection from './RuntimeSection'
7
  import BehaviorSection from './BehaviorSection'
8
  import CurrentInputFileSection from './CurrentInputFileSection'
@@ -91,6 +92,8 @@ export default function SettingsContainer({ onRefresh, onMessage, authFetch, onF
91
  onUpdatePassword={updatePassword}
92
  />
93
 
 
 
94
  <RuntimeSection t={t} form={form} setForm={setForm} />
95
 
96
  <BehaviorSection t={t} form={form} setForm={setForm} />
 
3
  import { useI18n } from '../../i18n'
4
  import { useSettingsForm } from './useSettingsForm'
5
  import SecuritySection from './SecuritySection'
6
+ import PlatformSection from './PlatformSection'
7
  import RuntimeSection from './RuntimeSection'
8
  import BehaviorSection from './BehaviorSection'
9
  import CurrentInputFileSection from './CurrentInputFileSection'
 
92
  onUpdatePassword={updatePassword}
93
  />
94
 
95
+ <PlatformSection t={t} form={form} setForm={setForm} />
96
+
97
  <RuntimeSection t={t} form={form} setForm={setForm} />
98
 
99
  <BehaviorSection t={t} form={form} setForm={setForm} />
webui/src/features/settings/useSettingsForm.js CHANGED
@@ -16,6 +16,7 @@ const DEFAULT_FORM = {
16
  responses: { store_ttl_seconds: 900 },
17
  embeddings: { provider: '' },
18
  auto_delete: { mode: 'none' },
 
19
  current_input_file: { enabled: true, min_chars: 0 },
20
  thinking_injection: { enabled: true, prompt: '', default_prompt: '' },
21
  model_aliases_text: '{}',
@@ -56,6 +57,7 @@ function fromServerForm(data) {
56
  const responsesSource = snapshot.responses || data.responses || {}
57
  const embeddingsSource = snapshot.embeddings || data.embeddings || {}
58
  const autoDeleteSource = snapshot.auto_delete || data.auto_delete || {}
 
59
  const currentInputSource = snapshot.current_input_file || data.current_input_file || {}
60
  const thinkingSource = snapshot.thinking_injection || data.thinking_injection || {}
61
  const modelAliasesSource = snapshot.model_aliases || data.model_aliases || {}
@@ -78,6 +80,9 @@ function fromServerForm(data) {
78
  auto_delete: {
79
  mode: normalizeAutoDeleteMode(autoDeleteSource),
80
  },
 
 
 
81
  current_input_file: {
82
  enabled: currentInputFileEnabled,
83
  min_chars: Number(currentInputSource?.min_chars ?? 0),
@@ -105,6 +110,7 @@ function toServerPayload(form) {
105
  responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
106
  embeddings: { provider: String(form.embeddings.provider || '').trim() },
107
  auto_delete: { mode: normalizeAutoDeleteMode(form.auto_delete) },
 
108
  current_input_file: {
109
  enabled: currentInputFileEnabled,
110
  min_chars: Number(form.current_input_file?.min_chars ?? 0),
 
16
  responses: { store_ttl_seconds: 900 },
17
  embeddings: { provider: '' },
18
  auto_delete: { mode: 'none' },
19
+ platform: { mode: 'android' },
20
  current_input_file: { enabled: true, min_chars: 0 },
21
  thinking_injection: { enabled: true, prompt: '', default_prompt: '' },
22
  model_aliases_text: '{}',
 
57
  const responsesSource = snapshot.responses || data.responses || {}
58
  const embeddingsSource = snapshot.embeddings || data.embeddings || {}
59
  const autoDeleteSource = snapshot.auto_delete || data.auto_delete || {}
60
+ const platformSource = snapshot.platform || data.platform || {}
61
  const currentInputSource = snapshot.current_input_file || data.current_input_file || {}
62
  const thinkingSource = snapshot.thinking_injection || data.thinking_injection || {}
63
  const modelAliasesSource = snapshot.model_aliases || data.model_aliases || {}
 
80
  auto_delete: {
81
  mode: normalizeAutoDeleteMode(autoDeleteSource),
82
  },
83
+ platform: {
84
+ mode: platformSource?.mode || 'android',
85
+ },
86
  current_input_file: {
87
  enabled: currentInputFileEnabled,
88
  min_chars: Number(currentInputSource?.min_chars ?? 0),
 
110
  responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
111
  embeddings: { provider: String(form.embeddings.provider || '').trim() },
112
  auto_delete: { mode: normalizeAutoDeleteMode(form.auto_delete) },
113
+ platform: { mode: String(form.platform.mode || 'android') },
114
  current_input_file: {
115
  enabled: currentInputFileEnabled,
116
  min_chars: Number(form.current_input_file?.min_chars ?? 0),
webui/src/locales/en.json CHANGED
@@ -444,6 +444,11 @@
444
  "autoDeleteSingleDesc": "Delete only the remote session created by this request.",
445
  "autoDeleteAllDesc": "Delete every remote session for the account after the request completes.",
446
  "autoDeleteWarning": "This mode deletes remote chat records. Use with caution.",
 
 
 
 
 
447
  "backupTitle": "Backup & Restore",
448
  "loadExport": "Load current export",
449
  "downloadExport": "Download backup file",
 
444
  "autoDeleteSingleDesc": "Delete only the remote session created by this request.",
445
  "autoDeleteAllDesc": "Delete every remote session for the account after the request completes.",
446
  "autoDeleteWarning": "This mode deletes remote chat records. Use with caution.",
447
+ "platformTitle": "API Platform",
448
+ "platformDescription": "Choose whether to use the Android or Web endpoint to communicate with DeepSeek. Requires service restart after switching.",
449
+ "platformAndroid": "Android (Default)",
450
+ "platformWeb": "Web (Chrome Browser)",
451
+ "platformWebNotice": "The Web endpoint uses Chrome browser fingerprint and TLS characteristics, requiring additional device ID generation and HIF header polling.",
452
  "backupTitle": "Backup & Restore",
453
  "loadExport": "Load current export",
454
  "downloadExport": "Download backup file",
webui/src/locales/zh.json CHANGED
@@ -444,6 +444,11 @@
444
  "autoDeleteSingleDesc": "请求结束后只删除本次请求创建的远端会话。",
445
  "autoDeleteAllDesc": "请求结束后清空该账号的全部远端会话。",
446
  "autoDeleteWarning": "当前模式会删除远端聊天记录,请谨慎使用。",
 
 
 
 
 
447
  "backupTitle": "备份与恢复",
448
  "loadExport": "加载当前导出",
449
  "downloadExport": "下载备份文件",
 
444
  "autoDeleteSingleDesc": "请求结束后只删除本次请求创建的远端会话。",
445
  "autoDeleteAllDesc": "请求结束后清空该账号的全部远端会话。",
446
  "autoDeleteWarning": "当前模式会删除远端聊天记录,请谨慎使用。",
447
+ "platformTitle": "接口平台",
448
+ "platformDescription": "选择使用 Android 端还是 Web 端接口与 DeepSeek 通信。切换后需重启服务生效。",
449
+ "platformAndroid": "Android 端(默认)",
450
+ "platformWeb": "Web 端(Chrome 浏览器)",
451
+ "platformWebNotice": "Web 端接口使用 Chrome 浏览器指纹和 TLS 特征,需要额外的设备ID生成和HIF头部轮询。",
452
  "backupTitle": "备份与恢复",
453
  "loadExport": "加载当前导出",
454
  "downloadExport": "下载备份文件",