@mathew-macharia/black-mamba 1.0.2 → 1.0.4

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.
package/.env.example CHANGED
@@ -1,13 +1,12 @@
1
+ # Port for Black Mamba central hub server
1
2
  PORT=3000
2
3
 
3
- # Human pacing delay between turns (800ms - 2000ms with jitter to mimic human reading pace and avoid Cloudflare CHALLENGE_REQUIRED)
4
- # MIN_TURN_DELAY_MS=800
5
- # MAX_TURN_DELAY_MS=2000
4
+ # Single API key to authenticate all requests from Pi and other apps
5
+ API_KEY=bm-secret-key-12345
6
6
 
7
- # DeepSeek biz codes that mean "the chat session / parent message you referenced no longer exists".
8
- # Only these (plus a narrow "chat_session|parent_message ... not found|invalid|expired" message match) trigger an
9
- # explicit re-seed of a branch (strategy reseed_upstream_rejected). Every other upstream error surfaces as 502.
10
- # DEEPSEEK_STALE_CONTEXT_BIZ_CODES=40401,40002
7
+ # Human-like pacing delay between turns per account (800ms - 2000ms with jitter to mimic human pace and avoid Cloudflare challenges)
8
+ MIN_TURN_DELAY_MS=800
9
+ MAX_TURN_DELAY_MS=2000
11
10
 
12
- # Verbose mapping/decoder logging
11
+ # Verbose debug logging (optional)
13
12
  # BM_DEBUG=1
package/index.js CHANGED
@@ -1,31 +1,39 @@
1
1
  #!/usr/bin/env node
2
- // Black Mamba — OpenAI-compatible proxy that maps pi's session tree onto DeepSeek web chat precisely.
3
- // This file only wires the modules together; see src/ for the implementation.
2
+ // Black Mamba — Central rotational proxy hub for DeepSeek.
3
+ // Converges all added accounts into a single OpenAI-compatible API key.
4
4
 
5
5
  import http from "node:http";
6
6
  import { realpathSync } from "node:fs";
7
7
  import { fileURLToPath } from "node:url";
8
8
 
9
- import { findAccount, listAccountDirs, loadAccounts } from "./src/accounts.js";
9
+ import { createAccountPool, listAccountDirs, loadAccounts } from "./src/accounts.js";
10
10
  import { CONFIG } from "./src/config.js";
11
- import { createLogger } from "./src/logger.js";
11
+ import { createCompletionHandler } from "./src/completions.js";
12
12
  import { createPowSolver, loadWasm } from "./src/deepseek/pow.js";
13
- import { createRequestHandler } from "./src/server/http.js";
14
- import { createSanitizer } from "./src/utils/sanitize.js";
13
+ import { createRequestHandler } from "./src/http.js";
14
+ import { createLogger } from "./src/logger.js";
15
+ import { createSanitizer } from "./src/sanitize.js";
16
+
17
+ export function createHubApp({ pool, apiKey = CONFIG.apiKey, logger = createLogger("Server") }) {
18
+ const sanitize = createSanitizer(() => [
19
+ apiKey,
20
+ ...pool.slots.flatMap((slot) => slot.auth.secrets()),
21
+ ]);
15
22
 
16
- export function createAccountsApp({ slots, logger = createLogger("Server") }) {
17
- const sanitize = createSanitizer(() => slots.flatMap((slot) => [slot.apiKey, ...slot.auth.secrets()]));
23
+ const handleChatCompletions = createCompletionHandler({ pool, logger });
18
24
  const handleRequest = createRequestHandler({
19
- resolveAccount: (authorization) => findAccount(slots, authorization),
25
+ handleChatCompletions,
26
+ getStatus: () => pool.statusSummary(),
20
27
  sanitize,
28
+ apiKey,
21
29
  logger,
22
30
  });
23
- return { slots, handleRequest, sanitize, log: logger };
31
+
32
+ return { pool, handleRequest, sanitize, log: logger };
24
33
  }
25
34
 
26
35
  function stopApp(app) {
27
- for (const slot of app.slots) {
28
- slot.graph.stopPeriodicPrune();
36
+ for (const slot of app.pool.slots) {
29
37
  slot.stopHealthMonitor();
30
38
  }
31
39
  }
@@ -35,27 +43,38 @@ async function main() {
35
43
  try {
36
44
  const accountNames = await listAccountDirs();
37
45
  if (accountNames.length === 0) {
38
- console.error("No accounts found!");
46
+ console.error("❌ No accounts found! Add at least one account in accounts/<name>/curl.txt");
39
47
  process.exit(1);
40
48
  }
41
49
 
42
50
  const log = createLogger("Server");
43
51
  const wasm = await loadWasm({ logger: createLogger("WASM") });
44
- const slots = await loadAccounts({ powSolver: createPowSolver(wasm), onLoggedOut: (name) => log.error(`${name} logged out`) });
45
- app = createAccountsApp({ slots, logger: log });
52
+ const slots = await loadAccounts({
53
+ powSolver: createPowSolver(wasm),
54
+ onLoggedOut: (name) => log.error(`Account [${name}] logged out`),
55
+ });
56
+
57
+ const pool = createAccountPool(slots, { logger: createLogger("Router") });
58
+ app = createHubApp({ pool, apiKey: CONFIG.apiKey, logger: log });
59
+
46
60
  for (const slot of slots) {
47
- slot.graph.startPeriodicPrune();
48
61
  slot.startHealthMonitor();
49
62
  }
50
63
 
51
64
  const server = http.createServer((req, res) => {
52
65
  app.handleRequest(req, res);
53
66
  });
67
+
54
68
  server.listen(CONFIG.port, () => {
55
- app.log.info(`🚀 Black Mamba listening on http://localhost:${CONFIG.port}`);
69
+ app.log.info(`🚀 Black Mamba Hub listening on http://localhost:${CONFIG.port}`);
70
+ if (CONFIG.apiKey) {
71
+ app.log.info(`🔑 API Key protection enabled (configured via environment)`);
72
+ } else {
73
+ app.log.warn(`⚠️ No API_KEY configured in environment; proxy will accept unauthenticated requests`);
74
+ }
56
75
  for (const slot of slots) {
57
- if (slot.auth.verified) app.log.info(`${slot.name} logged in successfully`);
58
- else app.log.error(`${slot.name} log in failed`);
76
+ if (slot.auth.verified) app.log.info(`✅ Account [${slot.name}] verified and ready`);
77
+ else app.log.error(`❌ Account [${slot.name}] verification failed`);
59
78
  }
60
79
  });
61
80
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mathew-macharia/black-mamba",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "OpenAI-compatible proxy that maps chat sessions onto DeepSeek web chat",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/accounts.js CHANGED
@@ -1,4 +1,4 @@
1
- // One folder per DeepSeek login: app key, cURL, log file, and session store.
1
+ // Multi-account management and rotational routing pool ("Wi-Fi router" hub).
2
2
 
3
3
  import path from "node:path";
4
4
  import { existsSync } from "node:fs";
@@ -9,11 +9,8 @@ import { createDeepSeekClient } from "./deepseek/client.js";
9
9
  import { DeepSeekAuth } from "./deepseek/auth.js";
10
10
  import { parseCurlCommand } from "./deepseek/curl-parser.js";
11
11
  import { createLogger, createFileSink } from "./logger.js";
12
- import { createCompletionHandler } from "./server/completions.js";
13
- import { createPacer } from "./server/pacing.js";
14
- import { validateApiKey } from "./server/http.js";
15
- import { SessionGraph } from "./session/graph.js";
16
- import { createSanitizer } from "./utils/sanitize.js";
12
+ import { createPacer } from "./pacing.js";
13
+ import { createSanitizer } from "./sanitize.js";
17
14
 
18
15
  function accountsDir(rootDir) {
19
16
  return path.join(rootDir, "accounts");
@@ -26,31 +23,33 @@ export async function listAccountDirs(rootDir = CONFIG.rootDir) {
26
23
  return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
27
24
  }
28
25
 
29
- export function findAccount(slots, authorizationHeader) {
30
- const req = { headers: { authorization: authorizationHeader } };
31
- return slots.find((slot) => validateApiKey(req, slot.apiKey)) ?? null;
32
- }
33
-
34
- export async function loadAccounts({ rootDir = CONFIG.rootDir, powSolver, fetchImpl = fetch, minDelayMs, maxDelayMs, onLoggedOut = () => {} } = {}) {
26
+ /**
27
+ * Loads all account slots from accounts/<name>/curl.txt
28
+ */
29
+ export async function loadAccounts({
30
+ rootDir = CONFIG.rootDir,
31
+ powSolver,
32
+ fetchImpl = fetch,
33
+ minDelayMs = CONFIG.pacing.minTurnDelayMs,
34
+ maxDelayMs = CONFIG.pacing.maxTurnDelayMs,
35
+ onLoggedOut = () => {},
36
+ } = {}) {
35
37
  const names = await listAccountDirs(rootDir);
36
38
  if (names.length === 0) return [];
37
39
 
38
40
  const specs = [];
39
- const seen = new Map();
40
41
  for (const name of names) {
41
42
  const dir = path.join(accountsDir(rootDir), name);
42
- const apiKey = (await readFile(path.join(dir, "api-key.txt"), "utf8")).trim();
43
- const curl = await readFile(path.join(dir, "curl.txt"), "utf8");
44
- if (!apiKey) throw new Error(`accounts/${name}/api-key.txt is empty`);
45
- if (seen.has(apiKey)) throw new Error(`Duplicate api key in accounts/${seen.get(apiKey)} and accounts/${name}`);
46
- seen.set(apiKey, name);
43
+ const curlPath = path.join(dir, "curl.txt");
44
+ if (!existsSync(curlPath)) continue;
45
+ const curl = await readFile(curlPath, "utf8");
47
46
  let parsed;
48
47
  try {
49
48
  parsed = parseCurlCommand(curl);
50
49
  } catch (err) {
51
50
  throw new Error(`accounts/${name}/curl.txt: ${err.message}`);
52
51
  }
53
- specs.push({ name, dir, apiKey, parsed });
52
+ specs.push({ name, dir, parsed });
54
53
  }
55
54
 
56
55
  const slots = [];
@@ -58,10 +57,9 @@ export async function loadAccounts({ rootDir = CONFIG.rootDir, powSolver, fetchI
58
57
  const logger = createLogger(spec.name, { sink: createFileSink(path.join(spec.dir, "logs.txt")) });
59
58
  const auth = new DeepSeekAuth();
60
59
  auth.applyParsedSession(spec.parsed);
61
- const sanitize = createSanitizer(() => [spec.apiKey, ...auth.secrets()]);
62
- const graph = new SessionGraph({ sessionsDir: path.join(spec.dir, ".session"), logger });
63
- await graph.init();
60
+ const sanitize = createSanitizer(() => auth.secrets());
64
61
  const pacer = createPacer({ logger, minDelayMs, maxDelayMs });
62
+
65
63
  let connected = false;
66
64
  const noteDisconnect = (reason) => {
67
65
  logger.error(sanitize(reason));
@@ -69,8 +67,8 @@ export async function loadAccounts({ rootDir = CONFIG.rootDir, powSolver, fetchI
69
67
  connected = false;
70
68
  onLoggedOut(spec.name);
71
69
  };
70
+
72
71
  const deepseek = createDeepSeekClient({ auth, powSolver, fetchImpl, onAuthFailure: noteDisconnect });
73
- const handleChatCompletions = createCompletionHandler({ deepseek, graph, pacer, logger });
74
72
 
75
73
  let healthTimer = null;
76
74
  const stopHealthMonitor = () => {
@@ -101,7 +99,68 @@ export async function loadAccounts({ rootDir = CONFIG.rootDir, powSolver, fetchI
101
99
  logger.error(sanitize(err));
102
100
  }
103
101
 
104
- slots.push(Object.freeze({ name: spec.name, apiKey: spec.apiKey, auth, graph, logger, handleChatCompletions, startHealthMonitor, stopHealthMonitor }));
102
+ slots.push(
103
+ Object.freeze({
104
+ name: spec.name,
105
+ dir: spec.dir,
106
+ auth,
107
+ deepseek,
108
+ pacer,
109
+ logger,
110
+ startHealthMonitor,
111
+ stopHealthMonitor,
112
+ })
113
+ );
105
114
  }
106
115
  return slots;
107
116
  }
117
+
118
+ /**
119
+ * Creates a round-robin rotational account pool.
120
+ * Acts like a Wi-Fi router / hub: converges all accounts into a pool and rotates requests across them.
121
+ */
122
+ export function createAccountPool(slots, { logger = createLogger("Router") } = {}) {
123
+ let currentIndex = 0;
124
+
125
+ function nextAccount() {
126
+ if (slots.length === 0) {
127
+ throw new Error("No DeepSeek accounts available in pool. Please add an account in accounts/<name>/curl.txt");
128
+ }
129
+
130
+ // Try finding the next verified account starting at currentIndex
131
+ for (let i = 0; i < slots.length; i++) {
132
+ const idx = (currentIndex + i) % slots.length;
133
+ const candidate = slots[idx];
134
+ if (candidate.auth.verified) {
135
+ currentIndex = (idx + 1) % slots.length;
136
+ logger.info(`🔄 Routed request to account [${candidate.name}] (index ${idx + 1}/${slots.length})`);
137
+ return candidate;
138
+ }
139
+ }
140
+
141
+ // If none verified, return next slot anyway
142
+ const fallback = slots[currentIndex % slots.length];
143
+ currentIndex = (currentIndex + 1) % slots.length;
144
+ logger.warn(`⚠️ Routed request to unverified account [${fallback.name}]`);
145
+ return fallback;
146
+ }
147
+
148
+ function statusSummary() {
149
+ return {
150
+ status: "ok",
151
+ total_accounts: slots.length,
152
+ verified_accounts: slots.filter((s) => s.auth.verified).length,
153
+ accounts: slots.map((s) => ({
154
+ name: s.name,
155
+ verified: s.auth.verified,
156
+ ...s.auth.statusSummary(),
157
+ })),
158
+ };
159
+ }
160
+
161
+ return Object.freeze({
162
+ slots,
163
+ nextAccount,
164
+ statusSummary,
165
+ });
166
+ }
@@ -0,0 +1,149 @@
1
+ // POST /v1/chat/completions handler:
2
+ // Stateless prompt dump -> rotational account selection -> human pacing -> DeepSeek turn -> OpenAI stream/JSON.
3
+
4
+ import { CONFIG } from "./config.js";
5
+ import { createLogger } from "./logger.js";
6
+ import { consumeDeepSeekSseStream } from "./deepseek/sse.js";
7
+ import { renderSeedPrompt } from "./prompt.js";
8
+ import { parseChatRequest } from "./request.js";
9
+ import {
10
+ buildUsageStats,
11
+ createCompletionMetadata,
12
+ formatCompletionResponse,
13
+ formatStreamDeltaEvent,
14
+ formatStreamFinishEvent,
15
+ formatStreamReasoningEvent,
16
+ formatStreamRoleEvent,
17
+ formatStreamToolCallArgsEvent,
18
+ formatStreamToolCallStartEvent,
19
+ formatStreamUsageEvent,
20
+ } from "./response.js";
21
+ import { createToolCallStreamProcessor, parseToolCallsFromText } from "./tool-calls.js";
22
+ import { estimateTokenCount } from "./tokens.js";
23
+ import { readJsonBody, sendJson, startSse, writeSse } from "./http.js";
24
+
25
+ /**
26
+ * @param {object} deps
27
+ * @param {ReturnType<import("./accounts.js").createAccountPool>} deps.pool
28
+ * @param {object} [deps.logger]
29
+ */
30
+ export function createCompletionHandler({ pool, logger = createLogger("Completions"), keepAliveIntervalMs = CONFIG.server.keepAliveIntervalMs }) {
31
+ async function respondJson({ res, request, account, streamBody, prompt, meta }) {
32
+ const result = await consumeDeepSeekSseStream(streamBody);
33
+ let text = result.reply;
34
+ let toolCalls = [];
35
+
36
+ if (request.tools.length > 0) {
37
+ const parsed = parseToolCallsFromText(result.reply);
38
+ text = parsed.cleanText;
39
+ toolCalls = parsed.toolCalls;
40
+ }
41
+
42
+ const turnTokens = result.turnTokens > 0
43
+ ? result.turnTokens
44
+ : estimateTokenCount(prompt) + estimateTokenCount(result.reply);
45
+
46
+ const usage = buildUsageStats({ prompt, reply: result.reply, turnTokens });
47
+ const payload = formatCompletionResponse({
48
+ ...meta,
49
+ model: request.model,
50
+ content: text,
51
+ toolCalls,
52
+ usage,
53
+ });
54
+ sendJson(res, 200, payload);
55
+ }
56
+
57
+ async function respondStream({ req, res, request, account, streamBody, prompt, meta }) {
58
+ startSse(res, req);
59
+ writeSse(res, formatStreamRoleEvent(meta));
60
+ const keepAlive = setInterval(() => writeSse(res, ": keep-alive\n\n"), keepAliveIntervalMs);
61
+ keepAlive.unref?.();
62
+
63
+ let emittedText = "";
64
+ const emittedToolCalls = [];
65
+ const emitText = (content) => {
66
+ emittedText += content;
67
+ writeSse(res, formatStreamDeltaEvent(meta, content));
68
+ };
69
+ const emitToolCall = ({ index, id, name, argumentsString }) => {
70
+ emittedToolCalls.push({ name, argumentsString });
71
+ writeSse(res, formatStreamToolCallStartEvent(meta, { toolCallIndex: index, id, name }));
72
+ writeSse(res, formatStreamToolCallArgsEvent(meta, { toolCallIndex: index, argumentsChunk: argumentsString }));
73
+ };
74
+
75
+ const processor = request.tools.length > 0
76
+ ? createToolCallStreamProcessor({ onTextDelta: emitText, onToolCall: emitToolCall })
77
+ : null;
78
+
79
+ let result;
80
+ try {
81
+ result = await consumeDeepSeekSseStream(streamBody, (chunk) => {
82
+ if (chunk.isThinking) writeSse(res, formatStreamReasoningEvent(meta, chunk.text));
83
+ else if (processor) processor.processChunk(chunk.text);
84
+ else emitText(chunk.text);
85
+ });
86
+ processor?.flush();
87
+ } finally {
88
+ clearInterval(keepAlive);
89
+ }
90
+
91
+ const turnTokens = result.turnTokens > 0
92
+ ? result.turnTokens
93
+ : estimateTokenCount(prompt) + estimateTokenCount(result.reply);
94
+
95
+ writeSse(
96
+ res,
97
+ formatStreamFinishEvent(meta, {
98
+ finishReason: emittedToolCalls.length > 0 ? "tool_calls" : "stop",
99
+ })
100
+ );
101
+
102
+ if (request.includeUsage) {
103
+ writeSse(res, formatStreamUsageEvent(meta, buildUsageStats({ prompt, reply: result.reply, turnTokens })));
104
+ }
105
+ writeSse(res, "data: [DONE]\n\n");
106
+ res.end();
107
+ }
108
+
109
+ return async function handleChatCompletions(req, res) {
110
+ const body = await readJsonBody(req);
111
+ const request = parseChatRequest(body);
112
+ const prompt = renderSeedPrompt(request.messages, request.tools);
113
+
114
+ // Rotational account selection ("Wi-Fi router" hub)
115
+ const account = pool.nextAccount();
116
+ logger.info(`Request [${request.model}] -> [${account.name}] (prompt: ${prompt.length} chars, tools: ${request.tools.length})`);
117
+
118
+ const abortController = new AbortController();
119
+ let finished = false;
120
+ if (typeof res.on === "function") {
121
+ res.on("close", () => {
122
+ if (!finished && !res.writableFinished) abortController.abort();
123
+ });
124
+ }
125
+
126
+ // Apply per-account human pacing
127
+ await account.pacer.apply();
128
+
129
+ try {
130
+ const { streamBody } = await account.deepseek.openCompletionStream({
131
+ prompt,
132
+ thinkingEnabled: request.thinkingEnabled,
133
+ searchEnabled: request.searchEnabled,
134
+ signal: abortController.signal,
135
+ });
136
+
137
+ const meta = { ...createCompletionMetadata(), model: request.model };
138
+ if (request.stream) {
139
+ await respondStream({ req, res, request, account, streamBody, prompt, meta });
140
+ } else {
141
+ await respondJson({ res, request, account, streamBody, prompt, meta });
142
+ }
143
+ } finally {
144
+ finished = true;
145
+ account.pacer.recordCompletionFinished();
146
+ }
147
+ };
148
+ }
149
+
package/src/config.js CHANGED
@@ -30,6 +30,7 @@ export function loadConfig(env = process.env, rootDir = ROOT_DIR) {
30
30
  return Object.freeze({
31
31
  rootDir,
32
32
  port: readNumber(env.PORT, 3000),
33
+ apiKey: (env.API_KEY || env.BM_API_KEY || "").trim(),
33
34
  debug: env.BM_DEBUG === "1" || env.BM_DEBUG === "true",
34
35
 
35
36
  deepseek: Object.freeze({
@@ -44,9 +45,6 @@ export function loadConfig(env = process.env, rootDir = ROOT_DIR) {
44
45
  healthCheckIntervalMs: 30 * 60 * 1000,
45
46
  estimatedWafTtlMs: 4 * 24 * 60 * 60 * 1000,
46
47
  estimatedSessionTtlMs: 30 * 24 * 60 * 60 * 1000,
47
- // DeepSeek biz codes that mean "the chat session / parent message you referenced no longer exists".
48
- // Only these (plus a narrow message pattern) trigger an explicit re-seed of a stateful branch.
49
- staleContextBizCodes: readList(env.DEEPSEEK_STALE_CONTEXT_BIZ_CODES).map(Number),
50
48
  }),
51
49
 
52
50
  server: Object.freeze({
@@ -54,12 +52,6 @@ export function loadConfig(env = process.env, rootDir = ROOT_DIR) {
54
52
  keepAliveIntervalMs: 10_000,
55
53
  }),
56
54
 
57
- sessions: Object.freeze({
58
- dir: path.join(rootDir, ".sessions"),
59
- ttlMs: 14 * 24 * 60 * 60 * 1000,
60
- maxEntries: 1000,
61
- }),
62
-
63
55
  pacing: Object.freeze({
64
56
  minTurnDelayMs: readNumber(env.MIN_TURN_DELAY_MS, 800),
65
57
  maxTurnDelayMs: readNumber(env.MAX_TURN_DELAY_MS, 2000),
@@ -21,17 +21,17 @@ export function upstreamReason(jsonBody, rawText, status) {
21
21
  return jsonBody?.data?.biz_msg || jsonBody?.msg || (rawText ? rawText.slice(0, 200) : "") || `HTTP ${status}`;
22
22
  }
23
23
 
24
- /**
25
- * True only when DeepSeek tells us the chat session or parent message we referenced is gone.
26
- * Deliberately narrow: anything else is a real failure that must surface to the client.
27
- */
28
- export function isStaleContextRejection(res, jsonBody, staleBizCodes = []) {
29
- if (!(res.status === 400 || res.status === 404 || res.status === 200)) return false;
30
- const bizCode = jsonBody?.data?.biz_code ?? jsonBody?.code;
31
- if (typeof bizCode === "number" && staleBizCodes.includes(bizCode)) return true;
32
- const msg = String(jsonBody?.data?.biz_msg || jsonBody?.msg || "").toLowerCase();
33
- if (!msg) return false;
34
- const mentionsContext = /(chat[_ ]?session|parent[_ ]?message|message[_ ]?id)/.test(msg);
35
- const mentionsGone = /(not\s*found|invalid|expired|deleted|does not exist|not exist)/.test(msg);
36
- return mentionsContext && mentionsGone;
24
+ /** Safe fields from an auth-failure response for account logs. */
25
+ export function deepSeekAuthFacts(res, jsonBody = null) {
26
+ const facts = { http: res?.status ?? null };
27
+ const waf = res?.headers?.get?.("x-amzn-waf-action");
28
+ if (waf) facts.waf = waf;
29
+ if (jsonBody && typeof jsonBody === "object") {
30
+ if (jsonBody.code != null) facts.code = jsonBody.code;
31
+ if (jsonBody.msg != null) facts.msg = String(jsonBody.msg).slice(0, 300);
32
+ const data = jsonBody.data;
33
+ if (data?.biz_code != null) facts.biz_code = data.biz_code;
34
+ if (data?.biz_msg != null) facts.biz_msg = String(data.biz_msg).slice(0, 300);
35
+ }
36
+ return facts;
37
37
  }
@@ -1,11 +1,11 @@
1
1
  // HTTP client for the DeepSeek web API. Every failure is classified and thrown; nothing is papered over.
2
2
 
3
3
  import { CONFIG } from "../config.js";
4
- import { DeepSeekAuthError, UpstreamContextRejectedError, UpstreamError } from "../errors.js";
4
+ import { DeepSeekAuthError, UpstreamError } from "../errors.js";
5
5
  import {
6
+ deepSeekAuthFacts,
6
7
  isDeepSeekAuthFailure,
7
8
  isHumanChallengeRequired,
8
- isStaleContextRejection,
9
9
  isWafChallengeResponse,
10
10
  upstreamReason,
11
11
  } from "./classify.js";
@@ -22,10 +22,12 @@ const SESSION_EXPIRED_NOTICE = "DeepSeek session expired. Paste a fresh cURL in
22
22
  * @param {(reason: string) => void} [deps.onAuthFailure] Called once per detected credential failure.
23
23
  */
24
24
  export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, config = CONFIG.deepseek, onAuthFailure = () => {} }) {
25
- function authFailure(notice) {
25
+ function authFailure(notice, res, jsonBody = null) {
26
26
  auth.markInvalid();
27
- onAuthFailure(notice);
28
- return new DeepSeekAuthError(notice);
27
+ const facts = res ? deepSeekAuthFacts(res, jsonBody) : null;
28
+ if (facts) onAuthFailure(`${notice} ${JSON.stringify(facts)}`);
29
+ else onAuthFailure(notice);
30
+ return new DeepSeekAuthError(notice, facts ?? undefined);
29
31
  }
30
32
 
31
33
  function ensureCredentials() {
@@ -51,7 +53,7 @@ export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, confi
51
53
  const json = await res.json().catch(() => null);
52
54
 
53
55
  if (isDeepSeekAuthFailure(res, json)) {
54
- throw authFailure("DeepSeek session expired while creating chat session. " + SESSION_EXPIRED_NOTICE);
56
+ throw authFailure("DeepSeek session expired while creating chat session. " + SESSION_EXPIRED_NOTICE, res, json);
55
57
  }
56
58
  const remoteId = json?.data?.biz_data?.id || json?.data?.biz_data?.chat_session?.id;
57
59
  if (!res.ok || typeof remoteId !== "string" || !remoteId) {
@@ -67,7 +69,7 @@ export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, confi
67
69
  const res = await postJson(config.powChallengeUrl, { target_path: config.powTargetPath });
68
70
 
69
71
  if (isWafChallengeResponse(res)) {
70
- throw authFailure("AWS WAF cookie expired during PoW challenge. " + SESSION_EXPIRED_NOTICE);
72
+ throw authFailure("AWS WAF cookie expired during PoW challenge. " + SESSION_EXPIRED_NOTICE, res);
71
73
  }
72
74
  let json;
73
75
  try {
@@ -76,7 +78,11 @@ export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, confi
76
78
  throw new UpstreamError(`Invalid PoW challenge response (HTTP ${res.status})`, { code: "pow_challenge_invalid" });
77
79
  }
78
80
  if (isDeepSeekAuthFailure(res, json)) {
79
- throw authFailure(isHumanChallengeRequired(json) ? HUMAN_CHALLENGE_NOTICE : "DeepSeek session expired during PoW challenge. " + SESSION_EXPIRED_NOTICE);
81
+ throw authFailure(
82
+ isHumanChallengeRequired(json) ? HUMAN_CHALLENGE_NOTICE : "DeepSeek session expired during PoW challenge. " + SESSION_EXPIRED_NOTICE,
83
+ res,
84
+ json
85
+ );
80
86
  }
81
87
  const challenge = json?.data?.biz_data?.challenge;
82
88
  if (!res.ok || !challenge) {
@@ -89,21 +95,19 @@ export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, confi
89
95
  }
90
96
 
91
97
  /**
92
- * Opens a completion stream. Returns the raw SSE body; decoding lives in sse.js.
93
- * @param {{ chatSessionId: string, parentMessageId: number|null, prompt: string, thinkingEnabled: boolean, searchEnabled: boolean, signal?: AbortSignal }} params
98
+ * Opens a completion stream on a fresh DeepSeek chat session. Returns the raw SSE body; decoding lives in sse.js.
99
+ * @param {{ prompt: string, thinkingEnabled: boolean, searchEnabled: boolean, chatSessionId?: string, signal?: AbortSignal }} params
94
100
  */
95
- async function openCompletionStream({ chatSessionId, parentMessageId, prompt, thinkingEnabled, searchEnabled, signal }) {
101
+ async function openCompletionStream({ prompt, thinkingEnabled, searchEnabled, chatSessionId, signal }) {
96
102
  ensureCredentials();
97
- if (typeof chatSessionId !== "string" || !chatSessionId) {
98
- throw new Error("openCompletionStream requires a DeepSeek chat session id");
99
- }
103
+ const sessionId = chatSessionId || (await createChatSession());
100
104
 
101
105
  const powHeader = powSolver.solve(await fetchPowChallenge());
102
106
  const res = await postJson(
103
107
  config.chatCompletionUrl,
104
108
  {
105
- chat_session_id: chatSessionId,
106
- parent_message_id: parentMessageId,
109
+ chat_session_id: sessionId,
110
+ parent_message_id: null,
107
111
  model_type: "default",
108
112
  prompt,
109
113
  ref_file_ids: [],
@@ -117,7 +121,7 @@ export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, confi
117
121
  );
118
122
 
119
123
  if (isWafChallengeResponse(res) || res.status === 401) {
120
- throw authFailure("DeepSeek returned unauthorized / WAF challenge on chat completion. " + SESSION_EXPIRED_NOTICE);
124
+ throw authFailure("DeepSeek returned unauthorized / WAF challenge on chat completion. " + SESSION_EXPIRED_NOTICE, res);
121
125
  }
122
126
 
123
127
  const contentType = res.headers.get("content-type") || "";
@@ -130,12 +134,9 @@ export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, confi
130
134
  // non-JSON body
131
135
  }
132
136
  if (isDeepSeekAuthFailure(res, errJson)) {
133
- throw authFailure(isHumanChallengeRequired(errJson) ? HUMAN_CHALLENGE_NOTICE : SESSION_EXPIRED_NOTICE);
134
- }
135
- const details = { status: res.status, body: errJson ?? rawText.slice(0, 500), chat_session_id: chatSessionId, parent_message_id: parentMessageId };
136
- if (isStaleContextRejection(res, errJson, config.staleContextBizCodes)) {
137
- throw new UpstreamContextRejectedError(`DeepSeek rejected the referenced chat context: ${upstreamReason(errJson, rawText, res.status)}`, details);
137
+ throw authFailure(isHumanChallengeRequired(errJson) ? HUMAN_CHALLENGE_NOTICE : SESSION_EXPIRED_NOTICE, res, errJson);
138
138
  }
139
+ const details = { status: res.status, body: errJson ?? rawText.slice(0, 500), chat_session_id: sessionId };
139
140
  throw new UpstreamError(`DeepSeek completion failed: ${upstreamReason(errJson, rawText, res.status)}`, { details });
140
141
  }
141
142
 
@@ -144,7 +145,7 @@ export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, confi
144
145
  }
145
146
 
146
147
  auth.markVerified();
147
- return { streamBody: res.body };
148
+ return { streamBody: res.body, chatSessionId: sessionId };
148
149
  }
149
150
 
150
151
  return Object.freeze({ createChatSession, openCompletionStream, fetchPowChallenge });
package/src/errors.js CHANGED
@@ -45,14 +45,3 @@ export class UpstreamError extends HttpError {
45
45
  this.name = "UpstreamError";
46
46
  }
47
47
  }
48
-
49
- /**
50
- * Raised when DeepSeek explicitly rejected the chat session / parent message we referenced.
51
- * The completion handler converts this into an explicit, logged re-seed for stateful branches.
52
- */
53
- export class UpstreamContextRejectedError extends UpstreamError {
54
- constructor(message, details) {
55
- super(message, { statusCode: 502, code: "deepseek_context_rejected", details });
56
- this.name = "UpstreamContextRejectedError";
57
- }
58
- }