From fb43bd92f58266e63aa901bdfeef041dc186e55c Mon Sep 17 00:00:00 2001 From: CJACK Date: Mon, 27 Apr 2026 20:12:33 +0800 Subject: [PATCH] =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8F=B7=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/prompt-compatibility.md | 2 + internal/deepseek/protocol/constants.go | 86 ++++++++++--- .../deepseek/protocol/constants_shared.json | 12 +- internal/deepseek/protocol/constants_test.go | 43 ++++++- internal/js/shared/deepseek-constants.js | 119 +++++++++++++----- tests/node/js_compat_test.js | 10 ++ 6 files changed, 223 insertions(+), 49 deletions(-) diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index 2b7dd2d..2c5b7b3 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -98,6 +98,8 @@ DS2API 当前的核心思路,不是把客户端传来的 `messages`、`tools` - `prompt` 才是对话上下文主载体。 - `ref_file_ids` 只承载文件引用,不承载普通文本消息。 - `tools` 不会作为“原生工具 schema”直接下发给下游,而是被改写进 `prompt`。 +- 当前 `/v1/chat/completions` 业务路径仍是“每次请求新建一个远端 `chat_session_id`,并默认发送 `parent_message_id: null`”;因此 DS2API 对外默认表现为“新会话 + prompt 拼历史”,而不是复用 DeepSeek 原生会话树。 +- 但 DeepSeek 远端本身支持同一 `chat_session_id` 的跨轮次持续对话。2026-04-27 已用项目内现有 DeepSeek client 做过一次不改业务代码的双轮实测:同一 `chat_session_id` 下,第 1 轮返回 `request_message_id=1` / `response_message_id=2` / 文本 `SESSION_TEST_ONE`;第 2 轮重新获取一次 PoW,并发送 `parent_message_id=2` 后,成功返回 `request_message_id=3` / `response_message_id=4` / 文本 `SESSION_TEST_TWO`。这说明“同远端会话持续聊天”能力存在,且每轮需要携带正确的 parent/message 链接信息,同时重新获取对应轮次可用的 PoW。 - OpenAI Chat / Responses 原生走统一 OpenAI 标准化与 DeepSeek payload 组装;Claude / Gemini 会尽量复用 OpenAI prompt/tool 语义,其中 Gemini 直接复用 `promptcompat.BuildOpenAIPromptForAdapter`,Claude 消息接口在可代理场景会转换为 OpenAI chat 形态再执行。 - 客户端传入的 thinking / reasoning 开关会被归一到下游 `thinking_enabled`。Gemini `generationConfig.thinkingConfig.thinkingBudget` 会翻译成同一套 thinking 开关;关闭时即使上游返回 `response/thinking_content`,兼容层也不会把它当作可见正文输出。若最终解析出的模型名带 `-nothinking` 后缀,则会无条件强制关闭 thinking,优先级高于请求体中的 `thinking` / `reasoning` / `reasoning_effort`。Claude surface 在流式请求且未显式声明 `thinking` 时,仍按 Anthropic 语义默认关闭;但在非流式代理场景,兼容层会内部开启一次下游 thinking,用于捕获“正文为空、工具调用落在 thinking 里”的情况,随后在回包前剥离用户不可见的 thinking block。 - 对 OpenAI Chat / Responses 的非流式收尾,如果最终可见正文为空,兼容层会优先尝试把思维链中的独立 DSML / XML 工具块当作真实工具调用解析出来。流式链路也会在收尾阶段做同样的 fallback 检测,但不会因为思维链内容去中途拦截或改写流式输出;thinking / reasoning 增量仍按原样先发,只有在结束收尾时才可能补发最终工具调用结果。补发结果会作为本轮 assistant 的结构化 `tool_calls` / `function_call` 输出返回,而不是塞进 `content` 文本;如果客户端没有开启 thinking / reasoning,思维链只用于检测,不会作为 `reasoning_content` 或可见正文暴露。只有正文为空且思维链里也没有可执行工具调用时,才继续按空回复错误处理。 diff --git a/internal/deepseek/protocol/constants.go b/internal/deepseek/protocol/constants.go index 79e218e..3cb6c4d 100644 --- a/internal/deepseek/protocol/constants.go +++ b/internal/deepseek/protocol/constants.go @@ -3,6 +3,7 @@ package protocol import ( _ "embed" "encoding/json" + "fmt" ) const ( @@ -21,15 +22,11 @@ const ( DeepSeekUploadTargetPath = "/api/v0/file/upload_file" ) -var defaultBaseHeaders = map[string]string{ - "Host": "chat.deepseek.com", - "User-Agent": "DeepSeek/1.8.0 Android/35", - "Accept": "application/json", - "Content-Type": "application/json", - "x-client-platform": "android", - "x-client-version": "1.8.0", - "x-client-locale": "zh_CN", - "accept-charset": "UTF-8", +var defaultStaticBaseHeaders = map[string]string{ + "Host": "chat.deepseek.com", + "Accept": "application/json", + "Content-Type": "application/json", + "accept-charset": "UTF-8", } var defaultSkipContainsPatterns = []string{ @@ -47,11 +44,21 @@ var defaultSkipExactPaths = []string{ "response/search_status", } -var BaseHeaders = cloneStringMap(defaultBaseHeaders) +var ClientVersion string +var BaseHeaders = map[string]string{} var SkipContainsPatterns = cloneStringSlice(defaultSkipContainsPatterns) var SkipExactPathSet = toStringSet(defaultSkipExactPaths) +type clientConstants struct { + Name string `json:"name"` + Platform string `json:"platform"` + Version string `json:"version"` + AndroidAPILevel string `json:"android_api_level"` + Locale string `json:"locale"` +} + type sharedConstants struct { + Client clientConstants `json:"client"` BaseHeaders map[string]string `json:"base_headers"` SkipContainsPattern []string `json:"skip_contains_patterns"` SkipExactPaths []string `json:"skip_exact_paths"` @@ -63,19 +70,68 @@ var sharedConstantsJSON []byte func init() { cfg := sharedConstants{} if err := json.Unmarshal(sharedConstantsJSON, &cfg); err != nil { - return - } - if len(cfg.BaseHeaders) > 0 { - BaseHeaders = cloneStringMap(cfg.BaseHeaders) + panic(fmt.Errorf("load DeepSeek shared constants: %w", err)) } + applySharedConstants(cfg) +} + +func applySharedConstants(cfg sharedConstants) { + client := normalizeClientConstants(cfg.Client) + ClientVersion = client.Version + BaseHeaders = buildBaseHeaders(client, cfg.BaseHeaders) + SkipContainsPatterns = cloneStringSlice(defaultSkipContainsPatterns) if len(cfg.SkipContainsPattern) > 0 { SkipContainsPatterns = cloneStringSlice(cfg.SkipContainsPattern) } + SkipExactPathSet = toStringSet(defaultSkipExactPaths) if len(cfg.SkipExactPaths) > 0 { SkipExactPathSet = toStringSet(cfg.SkipExactPaths) } } +func normalizeClientConstants(in clientConstants) clientConstants { + if in.Name == "" { + in.Name = "DeepSeek" + } + if in.Platform == "" { + in.Platform = "android" + } + if in.AndroidAPILevel == "" { + in.AndroidAPILevel = "35" + } + if in.Locale == "" { + in.Locale = "zh_CN" + } + return in +} + +func buildBaseHeaders(client clientConstants, overrides map[string]string) map[string]string { + out := cloneStringMap(defaultStaticBaseHeaders) + for k, v := range overrides { + if k == "" || v == "" { + continue + } + out[k] = v + } + if client.Name != "" && client.Version != "" { + userAgent := client.Name + "/" + client.Version + if client.Platform == "android" && client.AndroidAPILevel != "" { + userAgent += " Android/" + client.AndroidAPILevel + } + out["User-Agent"] = userAgent + } + if client.Platform != "" { + out["x-client-platform"] = client.Platform + } + if client.Version != "" { + out["x-client-version"] = client.Version + } + if client.Locale != "" { + out["x-client-locale"] = client.Locale + } + return out +} + func cloneStringMap(in map[string]string) map[string]string { out := make(map[string]string, len(in)) for k, v := range in { @@ -103,6 +159,6 @@ func toStringSet(in []string) map[string]struct{} { const ( KeepAliveTimeout = 5 - StreamIdleTimeout = 30 + StreamIdleTimeout = 90 MaxKeepaliveCount = 10 ) diff --git a/internal/deepseek/protocol/constants_shared.json b/internal/deepseek/protocol/constants_shared.json index fb58d0e..353f03d 100644 --- a/internal/deepseek/protocol/constants_shared.json +++ b/internal/deepseek/protocol/constants_shared.json @@ -1,11 +1,15 @@ { + "client": { + "name": "DeepSeek", + "platform": "android", + "version": "2.0.1", + "android_api_level": "35", + "locale": "zh_CN" + }, "base_headers": { "Host": "chat.deepseek.com", - "User-Agent": "DeepSeek/1.8.0 Android/35", "Accept": "application/json", - "x-client-platform": "android", - "x-client-version": "1.8.0", - "x-client-locale": "zh_CN", + "Content-Type": "application/json", "accept-charset": "UTF-8" }, "skip_contains_patterns": [ diff --git a/internal/deepseek/protocol/constants_test.go b/internal/deepseek/protocol/constants_test.go index b64e579..1f278f1 100644 --- a/internal/deepseek/protocol/constants_test.go +++ b/internal/deepseek/protocol/constants_test.go @@ -1,11 +1,32 @@ package protocol -import "testing" +import ( + "encoding/json" + "testing" +) func TestSharedConstantsLoaded(t *testing.T) { + cfg := sharedConstants{} + if err := json.Unmarshal(sharedConstantsJSON, &cfg); err != nil { + t.Fatalf("failed to parse shared constants: %v", err) + } + client := normalizeClientConstants(cfg.Client) + if ClientVersion != client.Version { + t.Fatalf("unexpected client version=%q", ClientVersion) + } + wantUserAgent := client.Name + "/" + client.Version + " Android/" + client.AndroidAPILevel + if BaseHeaders["User-Agent"] != wantUserAgent { + t.Fatalf("unexpected user agent=%q", BaseHeaders["User-Agent"]) + } if BaseHeaders["x-client-platform"] != "android" { t.Fatalf("unexpected base header x-client-platform=%q", BaseHeaders["x-client-platform"]) } + if BaseHeaders["x-client-version"] != ClientVersion { + t.Fatalf("unexpected base header x-client-version=%q", BaseHeaders["x-client-version"]) + } + if BaseHeaders["Content-Type"] != "application/json" { + t.Fatalf("unexpected base header Content-Type=%q", BaseHeaders["Content-Type"]) + } if len(SkipContainsPatterns) == 0 { t.Fatal("expected skip contains patterns to be loaded") } @@ -13,3 +34,23 @@ func TestSharedConstantsLoaded(t *testing.T) { t.Fatal("expected response/search_status in exact skip path set") } } + +func TestClientHeadersDerivedFromSharedVersion(t *testing.T) { + client := normalizeClientConstants(clientConstants{ + Name: "DeepSeek", + Platform: "android", + Version: "9.8.7", + AndroidAPILevel: "35", + Locale: "zh_CN", + }) + headers := buildBaseHeaders(client, map[string]string{ + "User-Agent": "stale", + "x-client-version": "stale", + }) + if headers["User-Agent"] != "DeepSeek/9.8.7 Android/35" { + t.Fatalf("unexpected derived user agent=%q", headers["User-Agent"]) + } + if headers["x-client-version"] != "9.8.7" { + t.Fatalf("unexpected derived client version=%q", headers["x-client-version"]) + } +} diff --git a/internal/js/shared/deepseek-constants.js b/internal/js/shared/deepseek-constants.js index e24cfb1..b142c9e 100644 --- a/internal/js/shared/deepseek-constants.js +++ b/internal/js/shared/deepseek-constants.js @@ -3,14 +3,17 @@ const fs = require('fs'); const path = require('path'); +const DEFAULT_CLIENT = Object.freeze({ + name: 'DeepSeek', + platform: 'android', + androidApiLevel: '35', + locale: 'zh_CN', +}); + const DEFAULT_BASE_HEADERS = Object.freeze({ Host: 'chat.deepseek.com', - 'User-Agent': 'DeepSeek/1.8.0 Android/35', Accept: 'application/json', 'Content-Type': 'application/json', - 'x-client-platform': 'android', - 'x-client-version': '1.8.0', - 'x-client-locale': 'zh_CN', 'accept-charset': 'UTF-8', }); @@ -29,38 +32,96 @@ const DEFAULT_SKIP_EXACT_PATHS = Object.freeze([ 'response/search_status', ]); -function loadSharedConstants() { - const sharedPath = path.resolve(__dirname, '../../internal/deepseek/constants_shared.json'); - try { - const raw = fs.readFileSync(sharedPath, 'utf8'); - const parsed = JSON.parse(raw); - const baseHeaders = parsed && typeof parsed.base_headers === 'object' && !Array.isArray(parsed.base_headers) - ? { ...DEFAULT_BASE_HEADERS, ...parsed.base_headers } - : { ...DEFAULT_BASE_HEADERS }; - const skipPatterns = Array.isArray(parsed && parsed.skip_contains_patterns) - ? parsed.skip_contains_patterns.filter((v) => typeof v === 'string' && v !== '') - : [...DEFAULT_SKIP_PATTERNS]; - const skipExactPaths = Array.isArray(parsed && parsed.skip_exact_paths) - ? parsed.skip_exact_paths.filter((v) => typeof v === 'string' && v !== '') - : [...DEFAULT_SKIP_EXACT_PATHS]; - return { - baseHeaders, - skipPatterns, - skipExactPaths, - }; - } catch (_err) { - return { - baseHeaders: { ...DEFAULT_BASE_HEADERS }, - skipPatterns: [...DEFAULT_SKIP_PATTERNS], - skipExactPaths: [...DEFAULT_SKIP_EXACT_PATHS], - }; +function asNonEmptyString(value) { + return typeof value === 'string' && value !== '' ? value : ''; +} + +function normalizeClient(raw) { + const client = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; + return { + name: asNonEmptyString(client.name) || DEFAULT_CLIENT.name, + platform: asNonEmptyString(client.platform) || DEFAULT_CLIENT.platform, + version: asNonEmptyString(client.version), + androidApiLevel: asNonEmptyString(client.android_api_level) || DEFAULT_CLIENT.androidApiLevel, + locale: asNonEmptyString(client.locale) || DEFAULT_CLIENT.locale, + }; +} + +function buildBaseHeaders(parsed, client) { + const rawBaseHeaders = parsed && typeof parsed.base_headers === 'object' && !Array.isArray(parsed.base_headers) + ? parsed.base_headers + : {}; + const baseHeaders = { ...DEFAULT_BASE_HEADERS, ...rawBaseHeaders }; + if (client.name && client.version) { + const androidSuffix = client.platform === 'android' && client.androidApiLevel + ? ` Android/${client.androidApiLevel}` + : ''; + baseHeaders['User-Agent'] = `${client.name}/${client.version}${androidSuffix}`; } + if (client.platform) { + baseHeaders['x-client-platform'] = client.platform; + } + if (client.version) { + baseHeaders['x-client-version'] = client.version; + } + if (client.locale) { + baseHeaders['x-client-locale'] = client.locale; + } + return baseHeaders; +} + +function sharedConstantsPaths() { + return [ + path.resolve(__dirname, '../../deepseek/protocol/constants_shared.json'), + path.resolve(process.cwd(), 'internal/deepseek/protocol/constants_shared.json'), + ]; +} + +function readSharedConstants() { + try { + return require('../../deepseek/protocol/constants_shared.json'); + } catch (_err) { + // Fall through to filesystem candidates for test and local execution variants. + } + for (const sharedPath of sharedConstantsPaths()) { + try { + const raw = fs.readFileSync(sharedPath, 'utf8'); + return JSON.parse(raw); + } catch (_err) { + // Try the next candidate path; fall back to in-file structural defaults below. + } + } + return {}; +} + +function loadSharedConstants() { + const parsed = readSharedConstants(); + const client = normalizeClient(parsed && parsed.client); + const skipPatterns = Array.isArray(parsed && parsed.skip_contains_patterns) + ? parsed.skip_contains_patterns.filter((v) => typeof v === 'string' && v !== '') + : [...DEFAULT_SKIP_PATTERNS]; + const skipExactPaths = Array.isArray(parsed && parsed.skip_exact_paths) + ? parsed.skip_exact_paths.filter((v) => typeof v === 'string' && v !== '') + : [...DEFAULT_SKIP_EXACT_PATHS]; + return { + client, + baseHeaders: buildBaseHeaders(parsed, client), + skipPatterns, + skipExactPaths, + }; } const shared = loadSharedConstants(); module.exports = { + CLIENT: Object.freeze({ ...shared.client }), + CLIENT_VERSION: shared.client.version, BASE_HEADERS: Object.freeze(shared.baseHeaders), SKIP_PATTERNS: Object.freeze(shared.skipPatterns), SKIP_EXACT_PATHS: new Set(shared.skipExactPaths), + __test: { + buildBaseHeaders, + normalizeClient, + sharedConstantsPaths, + }, }; diff --git a/tests/node/js_compat_test.js b/tests/node/js_compat_test.js index ba35d55..01305dc 100644 --- a/tests/node/js_compat_test.js +++ b/tests/node/js_compat_test.js @@ -6,6 +6,7 @@ const fs = require('node:fs'); const path = require('node:path'); const chatStream = require('../../api/chat-stream.js'); +const deepseekConstants = require('../../internal/js/shared/deepseek-constants.js'); const { parseToolCallsDetailed, parseStandaloneToolCallsDetailed } = require('../../internal/js/helpers/stream-tool-sieve.js'); const { parseChunkForContent, estimateTokens } = chatStream.__test; @@ -16,6 +17,15 @@ function readJSON(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } +test('js shared constants derive client headers from shared json', () => { + const shared = readJSON(path.resolve(__dirname, '../../internal/deepseek/protocol/constants_shared.json')); + const client = shared.client; + assert.equal(deepseekConstants.CLIENT_VERSION, client.version); + assert.equal(deepseekConstants.BASE_HEADERS['x-client-version'], client.version); + assert.equal(deepseekConstants.BASE_HEADERS['User-Agent'], `${client.name}/${client.version} Android/${client.android_api_level}`); + assert.equal(deepseekConstants.BASE_HEADERS['Content-Type'], 'application/json'); +}); + test('js compat: sse fixtures', () => { const fixtureDir = path.join(compatRoot, 'fixtures', 'sse_chunks'); const expectedDir = path.join(compatRoot, 'expected');