@wwkit/llmproxy 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,278 @@
1
+ // providers/deepseek/auth.js — DeepSeek Web 浏览器登录(手动 + CDP 捕获)
2
+ //
3
+ // DeepSeek web 没有面向第三方的 OAuth 回调接口,session token 在内存中,
4
+ // 以 Authorization: Bearer 头携带、cookie 里含 ds_session_id。
5
+ //
6
+ // 登录流程(手动,需手机验证码):
7
+ // 1. 用 Chrome(优先 @wwkit/cft)带 --remote-debugging-port 打开 chat.deepseek.com
8
+ // 2. 用户在浏览器里手动完成登录(手机号 + 验证码)
9
+ // 3. 通过 CDP Network 拦截,捕获首个携带 Authorization 的 /api/v0/* 请求
10
+ // —— 登录后页面会自动预计算 PoW(create_guest_challenge),无需用户发消息
11
+ // 4. 凭证存入 ~/.config/llmproxy/.creds.json 的 providers.deepseek 子树
12
+ //
13
+ // TODO(limiting): 上游 x-hif-dliq / x-hif-leim 高频限流头暂未处理。
14
+
15
+ import { spawn } from "node:child_process";
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { getXdgConfigDir } from "@wwkit/shared";
19
+ import { status as cftStatus } from "@wwkit/cft";
20
+ import { AuthProvider } from "../../core/auth.js";
21
+ import { log, sleep } from "../../util.js";
22
+
23
+ const CREDS_FILE = path.join(getXdgConfigDir("llmproxy"), ".creds.json");
24
+ const LOGIN_URL = "https://chat.deepseek.com";
25
+ // 捕获目标:/api/v0/ 下的任意请求都会携带 Authorization + Cookie
26
+ const CAPTURE_REGEX = /chat\.deepseek\.com\/api\/v0\//;
27
+ const CAPTURE_TIMEOUT_MS = 5 * 60 * 1000; // 5 分钟
28
+
29
+ function getChromePath() {
30
+ try {
31
+ const info = cftStatus();
32
+ if (info?.chrome_path && fs.existsSync(info.chrome_path)) return info.chrome_path;
33
+ } catch {}
34
+ return null;
35
+ }
36
+
37
+ export default class DeepseekAuth extends AuthProvider {
38
+ constructor(opts) {
39
+ super(opts);
40
+ this._profileDir = path.join(
41
+ getXdgConfigDir("llmproxy"),
42
+ "chrome-profiles",
43
+ this.id.replace(/\//g, "-")
44
+ );
45
+ }
46
+
47
+ async getCredentials(opts = {}) {
48
+ const autoLogin = opts.autoLogin !== false;
49
+ if (this._cred && this._cred.expiresAt > Date.now()) {
50
+ return this._cred;
51
+ }
52
+ if (!this._cred) await this._loadFromFile();
53
+ if (this._cred) {
54
+ // 有缓存凭证直接返回;token 是否失效由 upstream 401 暴露
55
+ return this._cred;
56
+ }
57
+ if (!autoLogin) {
58
+ throw new Error(`auth:${this.id} 无凭证,请先执行 llmproxy login --provider ${this.id}`);
59
+ }
60
+ await this.login();
61
+ return this._cred;
62
+ }
63
+
64
+ async login() {
65
+ log(`[auth:${this.id}] 启动浏览器手动登录 DeepSeek...`);
66
+ const chromePath = getChromePath();
67
+ if (!chromePath) {
68
+ throw new Error("未找到 Chrome(可先执行 pnpm --filter cft install)");
69
+ }
70
+
71
+ const cdp = await this._launchChrome(chromePath);
72
+ try {
73
+ const cred = await this._captureCredentials(cdp);
74
+ this._cred = { ...cred, expiresAt: Date.now() + 7 * 24 * 3600 * 1000 };
75
+ await this._saveToFile();
76
+ log(`[auth:${this.id}] 登录成功,凭证已保存(token=${this._cred.token?.slice(0, 6)}...)`);
77
+ } finally {
78
+ try { cdp.close(); } catch {}
79
+ }
80
+ }
81
+
82
+ async _launchChrome(chromePath) {
83
+ fs.mkdirSync(this._profileDir, { recursive: true });
84
+ const port = 9300 + Math.floor(Math.random() * 200);
85
+ const child = spawn(
86
+ chromePath,
87
+ [
88
+ `--remote-debugging-port=${port}`,
89
+ `--user-data-dir=${this._profileDir}`,
90
+ "--no-first-run",
91
+ "--no-default-browser-check",
92
+ "--disable-background-networking",
93
+ "about:blank",
94
+ ],
95
+ { stdio: "ignore", detached: true }
96
+ );
97
+ child.unref();
98
+
99
+ // 等待 CDP 就绪
100
+ const versionUrl = `http://127.0.0.1:${port}/json/version`;
101
+ for (let i = 0; i < 60; i++) {
102
+ try {
103
+ const res = await fetch(versionUrl);
104
+ if (res.ok) break;
105
+ } catch {}
106
+ await sleep(200);
107
+ if (i === 59) throw new Error("Chrome CDP 启动超时");
108
+ }
109
+ this._chromePort = port;
110
+ log(`[auth:${this.id}] Chrome 已启动 (cdp=:${port}),请在浏览器中登录`);
111
+
112
+ const targets = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json();
113
+ const page = targets.find((t) => t.type === "page");
114
+ if (!page) throw new Error("未找到 Chrome page target");
115
+
116
+ const ws = new WebSocket(page.webSocketDebuggerUrl);
117
+ await new Promise((resolve, reject) => {
118
+ ws.onopen = resolve;
119
+ ws.onerror = reject;
120
+ });
121
+ return new CdpClient(ws, port);
122
+ }
123
+
124
+ async _captureCredentials(cdp) {
125
+ await cdp.send("Network.enable");
126
+ await cdp.send("Page.enable");
127
+
128
+ // Chrome 将 Cookie 等头放在 requestWillBeSentExtraInfo 中(按 requestId 关联)。
129
+ // Authorization 在 requestWillBeSent 中携带。二者凑齐即捕获。
130
+ const urlByReqId = new Map();
131
+ const partial = new Map();
132
+ const capturedPromise = new Promise((resolve) => {
133
+ const tryResolve = (url, auth, cookie) => {
134
+ if (!url || !CAPTURE_REGEX.test(url) || !auth) return;
135
+ const cur = partial.get(url) || {};
136
+ const merged = { ...cur, auth, cookie: cookie || cur.cookie };
137
+ partial.set(url, merged);
138
+ if (merged.auth && merged.cookie) {
139
+ resolve({
140
+ token: String(merged.auth).replace(/^Bearer\s+/i, ""),
141
+ cookie: merged.cookie,
142
+ url,
143
+ });
144
+ }
145
+ };
146
+ cdp.on("Network.requestWillBeSent", (p) => {
147
+ const url = p?.request?.url;
148
+ if (url) urlByReqId.set(p.requestId, url);
149
+ const h = p?.request?.headers || {};
150
+ tryResolve(url, h.Authorization || h.authorization, h.Cookie || h.cookie);
151
+ });
152
+ cdp.on("Network.requestWillBeSentExtraInfo", (p) => {
153
+ const h = p?.headers || {};
154
+ tryResolve(urlByReqId.get(p.requestId), h.Authorization || h.authorization, h.Cookie || h.cookie);
155
+ });
156
+ });
157
+
158
+ log(`[auth:${this.id}] 打开 ${LOGIN_URL},请完成登录(手机号 + 验证码)...`);
159
+ await cdp.send("Page.navigate", { url: LOGIN_URL });
160
+
161
+ const cred = await Promise.race([
162
+ capturedPromise,
163
+ new Promise((_, reject) =>
164
+ setTimeout(() => reject(new Error("捕获超时,请确认已完成登录")), CAPTURE_TIMEOUT_MS)
165
+ ),
166
+ ]);
167
+
168
+ const headers = {
169
+ Authorization: `Bearer ${cred.token}`,
170
+ Cookie: cred.cookie,
171
+ ...deepseekClientHeaders(),
172
+ };
173
+ log(`[auth:${this.id}] 已捕获(cred.url) cookie keys=${Object.keys(parseCookieKeys(cred.cookie)).join(",")}`);
174
+ return { token: cred.token, cookie: cred.cookie, headers };
175
+ }
176
+
177
+ async _saveToFile() {
178
+ let all = {};
179
+ if (fs.existsSync(CREDS_FILE)) {
180
+ try { all = JSON.parse(fs.readFileSync(CREDS_FILE, "utf8")); } catch {}
181
+ }
182
+ if (!all.providers) all.providers = {};
183
+ all.providers[this.id] = {
184
+ token: this._cred.token,
185
+ cookie: this._cred.cookie,
186
+ headers: this._cred.headers,
187
+ expiresAt: this._cred.expiresAt,
188
+ loginAt: Date.now(),
189
+ };
190
+ fs.writeFileSync(CREDS_FILE, JSON.stringify(all, null, 2), { mode: 0o600 });
191
+ }
192
+
193
+ async _loadFromFile() {
194
+ if (!fs.existsSync(CREDS_FILE)) return;
195
+ try {
196
+ const all = JSON.parse(fs.readFileSync(CREDS_FILE, "utf8"));
197
+ const data = all.providers?.[this.id];
198
+ if (!data) return;
199
+ this._cred = {
200
+ token: data.token,
201
+ cookie: data.cookie,
202
+ headers: data.headers || {},
203
+ expiresAt: data.expiresAt || 0,
204
+ };
205
+ if (!this._cred.headers?.Authorization || !this._cred.headers?.Cookie) {
206
+ log(`[auth:${this.id}] 凭证文件缺少 Authorization/Cookie,请重新登录`);
207
+ this._cred = null;
208
+ return;
209
+ }
210
+ log(`[auth:${this.id}] 从 .creds.json 加载凭证 token=${this._cred.token?.slice(0, 6)}...`);
211
+ } catch (e) {
212
+ log(`[auth:${this.id}] 加载 .creds.json 失败: ${e.message}`);
213
+ }
214
+ }
215
+ }
216
+
217
+ // DeepSeek web 请求的固定客户端头(offset 单位为秒,如 UTC+8 → 28800)
218
+ function deepseekClientHeaders() {
219
+ return {
220
+ "x-client-bundle-id": "com.deepseek.chat",
221
+ "x-client-locale": "en_US",
222
+ "x-client-platform": "web",
223
+ "x-client-timezone-offset": String(-new Date().getTimezoneOffset() * 60),
224
+ "x-client-version": "2.4.0",
225
+ };
226
+ }
227
+
228
+ // 解析 Cookie 字符串为 key→value 映射(仅用于日志)
229
+ function parseCookieKeys(cookie) {
230
+ const pairs = {};
231
+ for (const part of cookie.split(";")) {
232
+ const idx = part.indexOf("=");
233
+ if (idx > 0) pairs[part.slice(0, idx).trim()] = part.slice(idx + 1).trim();
234
+ }
235
+ return pairs;
236
+ }
237
+
238
+ // ---- 最小 CDP 客户端(基于全局 WebSocket,Node 22+)----
239
+ class CdpClient {
240
+ constructor(ws, port) {
241
+ this.ws = ws;
242
+ this.port = port;
243
+ this.seq = 0;
244
+ this.pending = new Map();
245
+ this.eventHandlers = new Map();
246
+ ws.onmessage = (ev) => {
247
+ const msg = JSON.parse(ev.data);
248
+ if (msg.id) {
249
+ const p = this.pending.get(msg.id);
250
+ if (!p) return;
251
+ this.pending.delete(msg.id);
252
+ if (msg.error) p.reject(new Error(msg.error.message));
253
+ else p.resolve(msg.result || {});
254
+ } else if (msg.method) {
255
+ const cbs = this.eventHandlers.get(msg.method);
256
+ if (cbs) for (const cb of cbs) cb(msg.params || {});
257
+ }
258
+ };
259
+ }
260
+
261
+ send(method, params = {}) {
262
+ return new Promise((resolve, reject) => {
263
+ const id = ++this.seq;
264
+ this.pending.set(id, { resolve, reject });
265
+ this.ws.send(JSON.stringify({ id, method, params }));
266
+ });
267
+ }
268
+
269
+ on(method, cb) {
270
+ const arr = this.eventHandlers.get(method) || [];
271
+ arr.push(cb);
272
+ this.eventHandlers.set(method, arr);
273
+ }
274
+
275
+ close() {
276
+ try { this.ws.close(); } catch {}
277
+ }
278
+ }
@@ -0,0 +1,171 @@
1
+ // providers/deepseek-web/client.js — DeepSeek Web 推理客户端
2
+ //
3
+ // 完整管线:
4
+ // ① auth.getCredentials() 拿 token + cookie
5
+ // ② PoW: fetch challenge + DeepSeekHashV1 暴力解 → X-DS-PoW-Response 头
6
+ // ③ POST /api/v0/chat_session/create → 拿 chat_session_id
7
+ // ④ OpenAI body → DeepSeek web body(chat_session_id / prompt 等)
8
+ // ⑤ fetch POST /api/v0/chat/completion
9
+ // ⑥ DeepSeek 自定义 SSE → OpenAI SSE(createSseTransformStream)
10
+ //
11
+ // 会话策略:无状态 —— 每次请求新建 chat_session(单轮对话)。
12
+ // Tool 注入/解析由 openai-tool-bridge 通用层处理,Provider 只负责消息格式化。
13
+
14
+ import crypto from "node:crypto";
15
+ import { fetch } from "undici";
16
+ import { log } from "../../util.js";
17
+ import { SIGN_CONFIG, MODEL_MAP } from "./config.js";
18
+ import { getPowHeader } from "./pow.js";
19
+ import { createSseTransformStream } from "./sse.js";
20
+ import { buildSimplePrompt } from "./tools.js";
21
+
22
+ const CHAT_ENDPOINT = "/chat/completion";
23
+ const SESSION_ENDPOINT = "/chat_session/create";
24
+ const TARGET_PATH = "/api/v0/chat/completion";
25
+ const MAX_PROMPT_CHARS = 30_000;
26
+ const USER_AGENT =
27
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.2 Safari/605.1.15";
28
+
29
+ export default class DeepseekClient {
30
+ constructor({ id, signProvider, authProvider, host, basePath }) {
31
+ this.id = id;
32
+ this.signProvider = signProvider;
33
+ this.authProvider = authProvider;
34
+ this.host = host || SIGN_CONFIG.host;
35
+ this.basePath = basePath || SIGN_CONFIG.basePath;
36
+ this.powState = {};
37
+ }
38
+
39
+ /**
40
+ * POST /chat/completion(返回 OpenAI 风格 Response)
41
+ */
42
+ async chatCompletions(body, opts = {}) {
43
+ const cred = await this.authProvider.getCredentials({ autoLogin: false });
44
+
45
+ // ① PoW 头
46
+ const { headerValue } = await getPowHeader(cred, TARGET_PATH, this.powState);
47
+
48
+ // ② 创建 chat session
49
+ const chatSessionId = await this._createSession(cred);
50
+
51
+ // ③ 请求体转换
52
+ const dsBody = this._convertRequest(body, chatSessionId);
53
+
54
+ // ④ 发请求
55
+ const url = `https://${this.host}${this.basePath}${CHAT_ENDPOINT}`;
56
+ log(`[ds:${this.id}] POST ${url} session=${chatSessionId.slice(0, 8)} model=${body.model} stream=${body.stream === true}`);
57
+
58
+ const upstream = await fetch(url, {
59
+ method: "POST",
60
+ headers: this._buildHeaders(cred, {
61
+ "X-DS-PoW-Response": headerValue,
62
+ "Referer": `https://chat.deepseek.com/a/chat/s/${chatSessionId}`,
63
+ }),
64
+ body: JSON.stringify(dsBody),
65
+ signal: opts.signal || undefined,
66
+ });
67
+
68
+ if (!upstream.ok) {
69
+ const text = await upstream.text();
70
+ log(`[ds:${this.id}] 上游错误 ${upstream.status}: ${text.slice(0, 300)}`);
71
+ return new Response(
72
+ JSON.stringify({ error: { message: text.slice(0, 500), type: "upstream_error", code: upstream.status } }),
73
+ { status: upstream.status, headers: { "Content-Type": "application/json" } },
74
+ );
75
+ }
76
+
77
+ // ⑤ 响应转换:DeepSeek SSE → OpenAI SSE
78
+ const converted = createSseTransformStream(upstream.body, body.model);
79
+ return new Response(converted, {
80
+ status: 200,
81
+ headers: { "Content-Type": "text/event-stream; charset=utf-8" },
82
+ });
83
+ }
84
+
85
+ /**
86
+ * 创建 chat session(DeepSeek 要求 session 先经服务端注册)
87
+ */
88
+ async _createSession(cred) {
89
+ const url = `https://${this.host}${this.basePath}${SESSION_ENDPOINT}`;
90
+ const res = await fetch(url, {
91
+ method: "POST",
92
+ headers: this._buildHeaders(cred),
93
+ body: "{}",
94
+ });
95
+ if (!res.ok) {
96
+ const text = await res.text();
97
+ throw new Error(`创建 chat session 失败 ${res.status}: ${text.slice(0, 200)}`);
98
+ }
99
+ const json = await res.json();
100
+ const sessionId = json?.data?.biz_data?.chat_session?.id;
101
+ if (!sessionId) {
102
+ throw new Error(`chat session id 缺失: ${JSON.stringify(json).slice(0, 300)}`);
103
+ }
104
+ return sessionId;
105
+ }
106
+
107
+ /**
108
+ * 组装请求头(cred.headers + 固定头 + 额外头)
109
+ */
110
+ _buildHeaders(cred, extra = {}) {
111
+ return {
112
+ "Content-Type": "application/json",
113
+ "Origin": "https://chat.deepseek.com",
114
+ "User-Agent": USER_AGENT,
115
+ ...(cred.headers || {}),
116
+ ...extra,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * OpenAI 请求体 → DeepSeek web 请求体
122
+ * 无状态:每次新 session;prompt 由全部 messages 拼接(含 bridge 注入的 tool 指令)
123
+ */
124
+ _convertRequest(body, chatSessionId) {
125
+ const model = body.model || "deepseek-chat";
126
+ const mapping = MODEL_MAP[model] || MODEL_MAP["deepseek-chat"];
127
+
128
+ const msgInfo = (body.messages || []).map((m) => {
129
+ const c = typeof m.content === "string" ? m.content : m.content?.map?.((p) => p?.text || "").filter(Boolean).join("|") ?? "";
130
+ return `${m.role}${c ? ":" + c.slice(0, 40) : ""}`;
131
+ });
132
+ log(`[ds:${this.id}] msgs=${JSON.stringify(msgInfo)}`);
133
+
134
+ const prompt = buildSimplePrompt(body.messages || []);
135
+ const truncated = prompt.length > MAX_PROMPT_CHARS;
136
+ if (truncated) {
137
+ log(`[ds:${this.id}] prompt 截断: ${prompt.length} -> ${MAX_PROMPT_CHARS}`);
138
+ const keep = MAX_PROMPT_CHARS;
139
+ const head = prompt.slice(0, Math.floor(keep / 3));
140
+ const tail = prompt.slice(-Math.floor((keep * 2) / 3));
141
+ return {
142
+ chat_session_id: chatSessionId,
143
+ parent_message_id: null,
144
+ model_type: mapping.modelType,
145
+ prompt: head + "\n\n[历史消息过多,已截断]\n\n" + tail,
146
+ ref_file_ids: [],
147
+ thinking_enabled: mapping.thinkingEnabled,
148
+ search_enabled: true,
149
+ action: null,
150
+ preempt: false,
151
+ };
152
+ }
153
+ log(`[ds:${this.id}] prompt 长度=${prompt.length} msgs=${(body.messages || []).length} lastRole=${(body.messages || []).at(-1)?.role}`);
154
+
155
+ return {
156
+ chat_session_id: chatSessionId,
157
+ parent_message_id: null,
158
+ model_type: mapping.modelType,
159
+ prompt,
160
+ ref_file_ids: [],
161
+ thinking_enabled: mapping.thinkingEnabled,
162
+ search_enabled: true,
163
+ action: null,
164
+ preempt: false,
165
+ };
166
+ }
167
+
168
+ models() {
169
+ return Object.keys(MODEL_MAP);
170
+ }
171
+ }
@@ -0,0 +1,22 @@
1
+ // providers/deepseek/config.js — DeepSeek Web provider 固定配置
2
+ //
3
+ // 端点基于对 chat.deepseek.com web 接口的逆向分析(见 docs/pow-analysis.md)。
4
+
5
+ // 服务端点(固定,无需用户配置)
6
+ export const SIGN_CONFIG = {
7
+ host: "chat.deepseek.com",
8
+ basePath: "/api/v0",
9
+ };
10
+
11
+ // OpenAI 模型名 → DeepSeek web 请求参数映射
12
+ // web 端 model_type 始终为 "default",thinking 由 thinking_enabled 控制
13
+ export const MODEL_MAP = {
14
+ "deepseek-chat": { modelType: "default", thinkingEnabled: false },
15
+ "deepseek-reasoner": { modelType: "default", thinkingEnabled: true },
16
+ };
17
+
18
+ // 可通过 GET /<provider>/v1/models 暴露的模型列表
19
+ export const MODELS = ["deepseek-chat", "deepseek-reasoner"];
20
+
21
+ // benefit 模型(DeepSeek web 无每日额度白名单,无常量配置)
22
+ export const BENEFIT_MODELS = [];
@@ -0,0 +1,28 @@
1
+ // providers/deepseek/error.js — DeepSeek Web 错误模式识别
2
+ //
3
+ // DeepSeek web 返回的错误:
4
+ // - 40300 POW_HEADER_ERROR — PoW 头缺失/错误
5
+ // - 40301 INVALID_POW_RESPONSE — PoW 响应无效
6
+ // - 429 限流(可能响应 x-hif-dliq/x-hif-leim 头)
7
+ //
8
+ // sessionLimit 语义:DeepSeek web 并发以 429 或服务繁忙体现;
9
+ // 429 + 文本含 rate/session/busy → 视作会话超限,走长重试。
10
+ import { ErrorPatterns } from "../../core/error.js";
11
+
12
+ export const ERROR_CONFIG = {
13
+ sessionLimit: "session", // 429 文本含 session → 会话超限
14
+ powErrors: ["40300", "40301"],
15
+ };
16
+
17
+ export default class DeepseekError extends ErrorPatterns {
18
+ isSessionLimit(status, bodyText) {
19
+ // 429 且文本含限流/繁忙信号 → 长重试
20
+ if (status === 429 && bodyText) {
21
+ if (/session|busy|rate|frequenc|limit|繁忙|频繁/i.test(bodyText)) return true;
22
+ }
23
+ // PoW 错误不重试(重试无法解决,需重新计算 PoW)
24
+ if (/40300|40301/.test(bodyText || "")) return false;
25
+ const marker = this.config.sessionLimit || ERROR_CONFIG.sessionLimit;
26
+ return Boolean(bodyText && bodyText.includes(marker));
27
+ }
28
+ }