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.
- .gitignore +3 -1
- internal/config/codec.go +8 -0
- internal/config/config.go +25 -1
- internal/config/store_accessors.go +14 -0
- internal/config/validation.go +13 -0
- internal/deepseek/client/client_auth.go +26 -3
- internal/deepseek/client/client_completion.go +7 -0
- internal/deepseek/client/client_continue.go +5 -0
- internal/deepseek/client/client_core.go +57 -5
- internal/deepseek/client/proxy.go +5 -4
- internal/deepseek/hif/hif.go +183 -0
- internal/deepseek/hif/hif_test.go +291 -0
- internal/deepseek/protocol/constants.go +69 -7
- internal/deepseek/protocol/constants_shared.json +1 -1
- internal/deepseek/protocol/constants_web.json +32 -0
- internal/deepseek/transport/transport.go +50 -3
- internal/httpapi/admin/accounts/handler_accounts_testing_test.go +4 -0
- internal/httpapi/admin/proxies/test_http_helpers_test.go +1 -0
- internal/httpapi/admin/settings/handler_settings_parse.go +27 -14
- internal/httpapi/admin/settings/handler_settings_read.go +5 -1
- internal/httpapi/admin/settings/handler_settings_runtime.go +7 -0
- internal/httpapi/admin/settings/handler_settings_write.go +5 -1
- internal/httpapi/admin/shared/deps.go +2 -0
- internal/httpapi/admin/test_bridge_test.go +2 -0
- internal/shumei/api.go +94 -0
- internal/shumei/confusion.go +51 -0
- internal/shumei/crypto.go +124 -0
- internal/shumei/device_id.go +121 -0
- webui/src/features/settings/PlatformSection.jsx +43 -0
- webui/src/features/settings/SettingsContainer.jsx +3 -0
- webui/src/features/settings/useSettingsForm.js +6 -0
- webui/src/locales/en.json +5 -0
- webui/src/locales/zh.json +5 -0
.gitignore
CHANGED
|
@@ -70,4 +70,6 @@ data/
|
|
| 70 |
.roomodes
|
| 71 |
|
| 72 |
deepseek2api旧版/
|
| 73 |
-
chat.deepseek.
|
|
|
|
|
|
|
|
|
| 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"`
|
| 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":
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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.
|
| 162 |
-
stream: trans.
|
| 163 |
-
fallback: trans.
|
| 164 |
-
fallbackS: trans.
|
| 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 |
-
|
| 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.
|
| 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:
|
| 35 |
MaxIdleConns: 200,
|
| 36 |
MaxIdleConnsPerHost: 100,
|
| 37 |
IdleConnTimeout: 90 * time.Second,
|
| 38 |
DialContext: dialContext,
|
| 39 |
-
DialTLSContext:
|
| 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:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":
|
|
|
|
|
|
|
|
|
|
| 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": "下载备份文件",
|