@mathew-macharia/black-mamba 1.0.4 → 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.
package/.env.example CHANGED
@@ -1,12 +1,13 @@
1
- # Port for Black Mamba central hub server
2
1
  PORT=3000
3
2
 
4
- # Single API key to authenticate all requests from Pi and other apps
5
- API_KEY=bm-secret-key-12345
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
6
6
 
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
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
10
11
 
11
- # Verbose debug logging (optional)
12
+ # Verbose mapping/decoder logging
12
13
  # BM_DEBUG=1
@@ -0,0 +1,37 @@
1
+ # Accounts
2
+
3
+ Each subdirectory of `accounts/` is one DeepSeek login. Folder names are read in sorted order. Only directories are accounts. `bm` reads these files from the working directory where you started it.
4
+
5
+ ```
6
+ accounts/<name>/
7
+ api-key.txt bearer key the app sends to Black Mamba
8
+ curl.txt DeepSeek bearer token and cookies, from a browser copy
9
+ logs.txt this account's log, created on the first write
10
+ .session/ one JSON file per Pi session id
11
+ ```
12
+
13
+ `api-key.txt` and `curl.txt` are required before startup. `logs.txt` and `.session/` are created by the process.
14
+
15
+ `api-key.txt` is a single trimmed line. The HTTP server compares `Authorization: Bearer <key>` with that line. This key is the key you choose for the app. It is separate from the DeepSeek token inside `curl.txt`.
16
+
17
+ `curl.txt` is parsed once at startup. Accepted input is a cURL command, a PowerShell `Invoke-WebRequest` command, or a `fetch()` snippet. The parser keeps the bearer token, the cookie header, and these client headers when they are present: `user-agent`, `x-device-id`, `x-client-version`, `x-client-locale`, `x-client-timezone-offset`, `x-client-platform`, `x-client-bundle-id`. The token and cookies are kept in memory and updated from `Set-Cookie`. `curl.txt` is not rewritten. Replacing it takes effect on the next start of `bm`.
18
+
19
+ Two account folders cannot contain the same `api-key.txt` value. Startup throws `Duplicate api key in accounts/<first> and accounts/<second>`.
20
+
21
+ ## Terminal and logs.txt
22
+
23
+ The terminal shows the `Server` log: the listen line, `<name> logged in successfully`, `<name> log in failed`, and `<name> logged out`. Those lines are not copied into `logs.txt`.
24
+
25
+ `accounts/<name>/logs.txt` is this account only. Lines are appended. Each line starts with an ISO timestamp and `INFO`, `WARN`, or `ERROR`, then `[<name>]`. Startup writes either `DeepSeek login verified` or the verification error. Later lines include the mapping plan for each completion, pacing waits, recorded session nodes, health checks (`Health check OK ...` every 30 minutes while the login is verified), and request errors for this account.
26
+
27
+ `<name> logged out` is printed on the terminal only after startup verification succeeded. The same failure text is written to `logs.txt`. If startup verification failed, the 30-minute timer still fires, and its callback returns without calling DeepSeek until a later successful DeepSeek response marks the login verified.
28
+
29
+ Error text that is sent to clients, and the account errors that are passed through the redactor, replace the account key, the DeepSeek bearer token, and the cookie string with `[REDACTED]`.
30
+
31
+ ## .session/
32
+
33
+ `.session/` holds the map for this account. One JSON file per Pi session id (schema version 2). The file name is a sanitized prefix of the id plus a short hash, not the raw id alone.
34
+
35
+ A file is dropped when it has not been accessed for 14 days. The process also prunes about once an hour. Up to 1000 sessions are kept in memory; older ones are dropped from memory and reloaded from disk if the file is still inside the 14-day window.
36
+
37
+ `DELETE /v1/sessions/<id>` removes that session's file. It does not delete the chat on DeepSeek. The next request for that Pi session starts a new DeepSeek chat.
package/docs/config.md ADDED
@@ -0,0 +1,40 @@
1
+ # Config
2
+
3
+ Settings are environment variables. A `.env` file in the working directory (the directory where you run `bm`) supplies variables that are not already set in the process environment. There is no `API_KEY` in `.env`. The bearer key for each account is `accounts/<name>/api-key.txt`.
4
+
5
+ A non-numeric value for a numeric variable stops startup with `Expected a numeric environment value, got "<value>"`. An empty value uses the default.
6
+
7
+ ## PORT
8
+
9
+ TCP port. Default `3000`. The process logs `http://localhost:<port>`.
10
+
11
+ ## MIN_TURN_DELAY_MS
12
+
13
+ Shortest pause between the end of one DeepSeek turn and the start of the next turn on the same account. Default `800`. The first turn of a process is not delayed. Values below 0 are treated as 0.
14
+
15
+ ## MAX_TURN_DELAY_MS
16
+
17
+ Longest pause for that same gap. Default `2000`. Each wait picks a random target between the minimum and the maximum, then sleeps only for the part of that target still remaining. If this value is lower than the minimum, the minimum is used as both ends.
18
+
19
+ ## DEEPSEEK_STALE_CONTEXT_BIZ_CODES
20
+
21
+ Comma-separated DeepSeek `biz_code` numbers. Whitespace around each code is ignored. Unset means an empty list.
22
+
23
+ On a completion response that is JSON rather than an event stream, with HTTP status 400, 404, or 200, the chat context is treated as gone when either of these is true:
24
+
25
+ - `data.biz_code` or `code` is a number in this list, or
26
+ - the message mentions a chat session, parent message, or message id, and also says that it was not found, invalid, expired, deleted, or does not exist.
27
+
28
+ On a `delta` turn that rejection is turned into `reseed_upstream_rejected` (one new chat, full history). Every other upstream error is returned as HTTP 502.
29
+
30
+ Login failures are classified first and are not re-seeds. Those are HTTP 401, an HTTP 202 or `x-amzn-waf-action: challenge` WAF response, biz codes `401`, `40001`, `40002`, and `40003`, and messages that contain `CHALLENGE_REQUIRED` or `UNAUTHORIZED`. A code that is both in this list and in that auth set is handled as a login failure.
31
+
32
+ ## BM_DEBUG
33
+
34
+ Set to `1` or `true` to enable the logger's debug method. Any other value leaves it off. Debug lines use the same destination as the other lines for that logger: the terminal for the server log, and `accounts/<name>/logs.txt` for an account.
35
+
36
+ ## DEEPSEEK_BASE_URL
37
+
38
+ DeepSeek web origin. Default `https://chat.deepseek.com`. Trailing slashes are removed. Login, chat create, the proof-of-work challenge, and completion are called under this origin at `/api/v0/users/current`, `/api/v0/chat_session/create`, `/api/v0/chat/create_pow_challenge`, and `/api/v0/chat/completion`.
39
+
40
+ The proof-of-work file `sha3_wasm_bg.wasm` lives in `src/deepseek/` and is published with `src`. If that installed file is missing, startup downloads it from DeepSeek's static file host and saves it there. That download URL does not follow `DEEPSEEK_BASE_URL`.
package/docs/http.md ADDED
@@ -0,0 +1,171 @@
1
+ # HTTP
2
+
3
+ Base URL: `http://localhost:<PORT>` (`PORT` defaults to 3000).
4
+
5
+ Every route below requires:
6
+
7
+ ```
8
+ Authorization: Bearer <key>
9
+ ```
10
+
11
+ `<key>` is the line in one account's `api-key.txt`. The comparison is exact, including length. A missing or unknown key returns HTTP 401:
12
+
13
+ ```json
14
+ { "error": { "message": "Invalid API key", "code": "invalid_api_key" } }
15
+ ```
16
+
17
+ `OPTIONS` on any path returns 204. CORS allows origin `*`, methods `GET`, `POST`, `DELETE`, and `OPTIONS`, and headers `Content-Type`, `Authorization`, `X-Session-Id`, `X-Entry-Id`, `X-Parent-Id`, `X-Parent-Session-Id`.
18
+
19
+ The JSON body limit on `POST /v1/chat/completions` is 10 MiB. Errors use `{ "error": { "message", "code", "details" } }`. `details` is omitted when there are none. If the failure happens after an SSE response has started, the same object is a `data:` event, followed by `data: [DONE]`.
20
+
21
+ ## GET /
22
+
23
+ Returns the selected account's login summary.
24
+
25
+ ```json
26
+ {
27
+ "status": "ok",
28
+ "authenticated": true,
29
+ "session_age": "<duration since curl.txt was loaded>",
30
+ "last_verified_at": "<ISO time>",
31
+ "auth_cookies": { "<cookie-name>": "<remaining>" },
32
+ "next_due_expiry": { "cookie": "<cookie-name>", "remaining": "<remaining>" }
33
+ }
34
+ ```
35
+
36
+ `session_age`, `last_verified_at`, `auth_cookies`, and `next_due_expiry` appear only when there is something to report. A remaining time that starts with `~` is an estimate. `authenticated` is false when the DeepSeek check has failed.
37
+
38
+ ## GET /v1/models
39
+
40
+ The body is `{ "object": "list", "data": [ ... ] }`. `data` has one object per id: `deepseek-chat`, `deepseek-reasoner`, `deepseek-chat-search`, `deepseek-reasoner-search`. Each object is `{ "id", "object": "model", "created", "owned_by": "deepseek" }`. `created` is the current Unix time in seconds.
41
+
42
+ ## POST /v1/chat/completions
43
+
44
+ Body fields that are read:
45
+
46
+ | Field | Behavior |
47
+ | --- | --- |
48
+ | `messages` | Required non-empty array. Each item needs `role`. `content` is a string or an array of parts, unless an assistant message has `tool_calls`. A `tool` message needs `tool_call_id`. |
49
+ | `model` | String. Default `deepseek-chat`. Any other string is accepted. |
50
+ | `stream` | `true` returns SSE. Any other value returns one JSON object. |
51
+ | `stream_options.include_usage` | When `stream` is true and this value is truthy, a usage event is sent before `[DONE]`. |
52
+ | `tools` | Array of tools. When non-empty, DSML blocks in the reply become `tool_calls`. |
53
+ | `thinking` | `{ "type": "enabled" }` or `{ "type": "disabled" }`. `false` turns thinking off. |
54
+ | `thinking_enabled` | Boolean override. |
55
+ | `reasoning` | `false` turns thinking off. |
56
+
57
+ Other JSON fields are ignored.
58
+
59
+ Thinking is on when the lowercased model contains `reasoner`, `r1`, or `think`, unless a field above turns it off. Search is on when the lowercased model contains `search` or ends with `:online`. Of the four listed models, `deepseek-chat` is neither, `deepseek-reasoner` is thinking, `deepseek-chat-search` is search, and `deepseek-reasoner-search` is both. The completion body sent to DeepSeek sets `model_type` to `default`.
60
+
61
+ ### Stateless calls and Pi headers
62
+
63
+ No `x-session-id`: the call is stateless. The full `messages` array is sent to a new DeepSeek chat. Nothing is written to `.session/`. The strategy is `stateless_seed`.
64
+
65
+ `x-session-id` without `x-entry-id`: HTTP 400, code `missing_entry_id`.
66
+
67
+ With both, the call is stored and later turns can continue the same DeepSeek chat:
68
+
69
+ | Header | Role |
70
+ | --- | --- |
71
+ | `x-session-id` | Pi session id. The session file key. |
72
+ | `x-entry-id` | Pi entry id of this request. The node is stored under this id. |
73
+ | `x-parent-id` | Entry id of the previous successful assistant request on this branch. Omitted on the first turn. |
74
+ | `x-parent-session-id` | When this session was forked, the Pi session it was forked from. If the parent entry is not in the current session, nodes are copied from that session. |
75
+
76
+ Header names are case-insensitive.
77
+
78
+ ### stream false
79
+
80
+ HTTP 200, `Content-Type: application/json`, header `x-black-mamba-strategy: <strategy>`.
81
+
82
+ Read these fields:
83
+
84
+ - `choices[0].message.content` — assistant text. `null` when the message is only tool calls and the text is empty.
85
+ - `choices[0].message.tool_calls` — present when tools were requested and the reply contained DSML calls. Each item has `id`, `type` (`function`), and `function.name` plus `function.arguments` (a JSON string).
86
+ - `choices[0].finish_reason` — `stop`, or `tool_calls` when `tool_calls` is present.
87
+ - `usage.prompt_tokens`, `usage.completion_tokens`, `usage.total_tokens`.
88
+ - `usage.prompt_tokens_details.cached_tokens` — present when this DeepSeek branch already has a token total from earlier turns. It is the sum of those turn totals, not a field DeepSeek returns on this response.
89
+ - `black_mamba.strategy` — one of the names in [mapping.md](mapping.md).
90
+ - `black_mamba.reason` — why that strategy was chosen, or `null`.
91
+ - `black_mamba.delta_messages` — how many messages were rendered into the prompt.
92
+ - `black_mamba.usage_source` — `deepseek` when the token total came from DeepSeek's stream, `estimated` when it was counted from the prompt and reply text.
93
+ - `black_mamba.deepseek_parent_message_id` — parent message id sent to DeepSeek, or `null` for a new chat.
94
+ - `deepseek_session_id`, `deepseek_message_id` — ids from this turn. `deepseek_message_id` is null if DeepSeek sent none (a stateless turn can still return text in that case).
95
+ - `chat_session_id` — the Pi session id, or the DeepSeek chat id when the call was stateless.
96
+
97
+ `id` is `chatcmpl-<milliseconds>`, `object` is `chat.completion`, `created` is Unix seconds, `model` echoes the request model.
98
+
99
+ Thinking text is only on the SSE stream, as `reasoning_content`.
100
+
101
+ ### stream true
102
+
103
+ HTTP 200, `Content-Type: text/event-stream`, same `x-black-mamba-strategy` header. Comment lines `: keep-alive` are sent about every 10 seconds while DeepSeek is still streaming.
104
+
105
+ Read the `data:` JSON events:
106
+
107
+ - First event: `choices[0].delta.role` is `assistant`.
108
+ - Reply text: concatenate `choices[0].delta.content`.
109
+ - Thinking text: concatenate `choices[0].delta.reasoning_content`.
110
+ - Tool calls: `choices[0].delta.tool_calls[]` with `index`, `id`, `type`, `function.name`, then `function.arguments`.
111
+ - Finish event: `choices[0].finish_reason` is `stop` or `tool_calls`, and the mapping fields listed above sit on that event next to `id` and `choices`.
112
+ - Usage event, only when `stream_options.include_usage` is true: top-level `usage` with the same token fields, and an empty `choices` array.
113
+ - The stream ends with `data: [DONE]`.
114
+
115
+ ## GET /v1/sessions/<id>
116
+
117
+ `<id>` is the Pi session id (`x-session-id`), URL-decoded. Unknown id: HTTP 404, code `session_not_found`.
118
+
119
+ A known session returns:
120
+
121
+ ```json
122
+ {
123
+ "piSessionId": "<id>",
124
+ "createdAt": "<ISO time>",
125
+ "lastAccessedAt": "<ISO time>",
126
+ "forkedFrom": null,
127
+ "nodeCount": 1,
128
+ "nodes": [
129
+ {
130
+ "piEntryId": "<entry>",
131
+ "piParentEntryId": "<parent entry or null>",
132
+ "strategy": "<strategy>",
133
+ "reason": "<reason or null>",
134
+ "deepseek": {
135
+ "sessionId": "<deepseek chat id>",
136
+ "messageId": "<deepseek message id>",
137
+ "parentMessageId": null
138
+ },
139
+ "messageCount": 0,
140
+ "turnTokens": 0,
141
+ "createdAt": "<ISO time>"
142
+ }
143
+ ]
144
+ }
145
+ ```
146
+
147
+ `forkedFrom` is `{ "piSessionId", "adoptedAt" }` when this session copied nodes from another Pi session. `nodes` are ordered by `createdAt`.
148
+
149
+ ## DELETE /v1/sessions/<id>
150
+
151
+ Removes the local session file and the in-memory copy. The DeepSeek chat is left as it is. HTTP 200:
152
+
153
+ ```json
154
+ {
155
+ "deleted": true,
156
+ "piSessionId": "<id>",
157
+ "note": "The next request for this session will seed a fresh DeepSeek chat."
158
+ }
159
+ ```
160
+
161
+ `deleted` is true when that id was loaded in memory. The file is removed either way. The next completion for this `x-session-id` uses a new DeepSeek chat (`root_seed` or another seed strategy).
162
+
163
+ ## Status codes on completions
164
+
165
+ | HTTP | When |
166
+ | --- | --- |
167
+ | 400 | Body or headers fail validation, including `missing_entry_id`. |
168
+ | 401 | Bad account key, or DeepSeek rejected the saved login (`deepseek_auth`). |
169
+ | 409 | `no_new_messages`: a `delta` plan found the parent, and there is no message after that parent's reply. |
170
+ | 502 | DeepSeek failed, or a sessioned turn ended without `response_message_id` (`deepseek_missing_message_id`). |
171
+ | 500 | Anything else, including a proof-of-work solve failure. |
@@ -0,0 +1,68 @@
1
+ # Install
2
+
3
+ Requires Node.js 18 or newer.
4
+
5
+ ```
6
+ npm install -g @mathew-macharia/black-mamba
7
+ ```
8
+
9
+ The command is `bm`. Run it in the directory that holds your data. That working directory (`process.cwd()`) is where `bm` reads `accounts/` and `.env`. The proof-of-work file `sha3_wasm_bg.wasm` is installed with the package.
10
+
11
+ ```
12
+ <data-directory>/
13
+ .env optional
14
+ accounts/
15
+ <name>/
16
+ api-key.txt
17
+ curl.txt
18
+ ```
19
+
20
+ `<name>` is the account folder name. It is the name printed in the terminal lines below. One folder is one DeepSeek login. Every account folder must contain both files. See [accounts.md](accounts.md) for the other files the process creates, and [config.md](config.md) for `.env`.
21
+
22
+ `accounts/<name>/api-key.txt` is one line: the bearer key your app sends as `Authorization: Bearer <key>`. The line is trimmed. An empty file, or the same key in two account folders, stops startup.
23
+
24
+ `accounts/<name>/curl.txt` is a Chrome DevTools copy of a `chat.deepseek.com` request: "Copy as cURL", "Copy as PowerShell", or a `fetch()` snippet. It must contain an `Authorization: Bearer <deepseek-bearer>` header or a `Cookie` header (or both). A placeholder shape:
25
+
26
+ ```
27
+ curl "<deepseek-url>" -H "authorization: Bearer <deepseek-bearer>" -H "cookie: <cookie>"
28
+ ```
29
+
30
+ Do not commit `accounts/` or `.env`.
31
+
32
+ From the data directory:
33
+
34
+ ```
35
+ bm
36
+ ```
37
+
38
+ When the server is up, stdout has:
39
+
40
+ ```
41
+ [Server] 🚀 Black Mamba listening on http://localhost:3000
42
+ ```
43
+
44
+ The port is `PORT` (default 3000). Each account then prints one line. Stdout on success:
45
+
46
+ ```
47
+ [Server] <name> logged in successfully
48
+ ```
49
+
50
+ Stderr when the DeepSeek check failed:
51
+
52
+ ```
53
+ [Server] <name> log in failed
54
+ ```
55
+
56
+ The failure reason is appended to `accounts/<name>/logs.txt`, not to that terminal line.
57
+
58
+ If a login that had succeeded later fails (the 30-minute check, or a DeepSeek auth error on a request), stderr prints:
59
+
60
+ ```
61
+ [Server] <name> logged out
62
+ ```
63
+
64
+ The reason is written to that account's `logs.txt` before the terminal line.
65
+
66
+ If `accounts/` is missing or has no account folders, stderr is `No accounts found!` and the process exits with status 1. A missing `api-key.txt` or `curl.txt`, or a `curl.txt` that has neither a bearer token nor a cookie, prints `[Startup] Fatal error:` and exits with status 1.
67
+
68
+ `SIGINT` and `SIGTERM` stop the process. The terminal prints `Received <signal>. Shutting down gracefully...` and the process exits with status 0.
@@ -0,0 +1,11 @@
1
+ # Mapping
2
+
3
+ `stateless_seed`: A request with no `x-session-id` sends the full history into a new DeepSeek chat and records nothing.
4
+
5
+ `root_seed`: A sessioned request with no stored parent node (first turn, unknown session, or unknown parent entry) sends the full history into a new DeepSeek chat and records the node.
6
+
7
+ `delta`: The parent node exists and this request's history is that node's context plus its reply plus new messages, so only those new messages are sent on the stored DeepSeek chat and parent message.
8
+
9
+ `reseed_context_diverged`: The parent node exists, but the history no longer extends it (shorter context, a changed prefix, a missing or edited assistant reply, or a compaction summary), so the full history is sent into a new DeepSeek chat.
10
+
11
+ `reseed_upstream_rejected`: DeepSeek rejected the chat or parent message a `delta` turn referenced, so the full history is sent into a new DeepSeek chat and that result is the one returned.
@@ -0,0 +1,25 @@
1
+ # Overview
2
+
3
+ Black Mamba is an HTTP server. It accepts OpenAI-style chat completion requests and sends each turn to DeepSeek web chat. A client chooses an account with `Authorization: Bearer <key>`, where `<key>` is the text in that account's `api-key.txt`.
4
+
5
+ The process keeps a local map from Pi session entries to DeepSeek chat ids and message ids. A later turn on the same branch continues that DeepSeek chat and sends only the new messages. A request with no `x-session-id` is stateless: the full history goes to a new DeepSeek chat and nothing is stored.
6
+
7
+ Credentials come from `accounts/<name>/curl.txt` (DeepSeek bearer token and cookies). They stay in memory. `Set-Cookie` on DeepSeek responses updates the cookie jar. The files are not rewritten.
8
+
9
+ At startup each account is checked with `GET <DEEPSEEK_BASE_URL>/api/v0/users/current`. A verified account is checked again every 30 minutes.
10
+
11
+ ## Request path
12
+
13
+ 1. The app sends `POST /v1/chat/completions` with the account bearer key and a JSON body whose `messages` array is the conversation so far.
14
+ 2. The key selects the account. The body is validated. If `x-session-id` is set, `x-entry-id` is required, and the session graph under `accounts/<name>/.session/` is loaded.
15
+ 3. The planner chooses one strategy and renders a single text prompt. A seed strategy sends the full history. `delta` sends only the messages after the stored parent reply. See [mapping.md](mapping.md).
16
+ 4. The account waits, when a previous turn has just finished, until the gap since that finish is a random time between `MIN_TURN_DELAY_MS` and `MAX_TURN_DELAY_MS`. The first turn of a process does not wait.
17
+ 5. If the plan has no DeepSeek chat id, Black Mamba creates one with `POST <DEEPSEEK_BASE_URL>/api/v0/chat_session/create`.
18
+ 6. It requests a proof-of-work challenge from `POST <DEEPSEEK_BASE_URL>/api/v0/chat/create_pow_challenge`, solves it with `sha3_wasm_bg.wasm`, and posts the prompt to `POST <DEEPSEEK_BASE_URL>/api/v0/chat/completion`. The JSON body includes the chat id, the parent message id (`null` for a new chat), `thinking_enabled`, and `search_enabled`. The proof-of-work answer is the `x-ds-pow-response` header.
19
+ 7. DeepSeek replies with a server-sent event stream. Black Mamba reads the reply text, any thinking text, `response_message_id`, and the change in accumulated token usage. Citation markers of the form `[reference:N]` and `[citation:N]` are removed from the text.
20
+ 8. If the request included `tools`, DSML tool-call blocks in the reply are converted to OpenAI `tool_calls` and removed from the assistant text. Image parts in the request are not forwarded; they are replaced with the text `[image omitted: Black Mamba forwards text only]`.
21
+ 9. For a request that carried `x-session-id`, the Pi entry id, the DeepSeek chat id, the DeepSeek message id, and a fingerprint of the context are written under `accounts/<name>/.session/`. A stateless request is not written.
22
+ 10. The app receives one JSON chat completion, or an SSE stream when `stream` is true. The response header `x-black-mamba-strategy` names the strategy that was used.
23
+ 11. If DeepSeek rejects the stored chat or parent message on a `delta` turn, Black Mamba opens a new chat, sends the full history (`reseed_upstream_rejected`), and answers from that second stream. If that second call fails, or if any other upstream call fails, the error is returned to the app. Upstream failures use HTTP 502.
24
+
25
+ If the app closes the connection before the response is finished, the DeepSeek request is aborted and the turn is not recorded.
package/index.js CHANGED
@@ -1,39 +1,31 @@
1
1
  #!/usr/bin/env node
2
- // Black Mamba — Central rotational proxy hub for DeepSeek.
3
- // Converges all added accounts into a single OpenAI-compatible API key.
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.
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 { createAccountPool, listAccountDirs, loadAccounts } from "./src/accounts.js";
9
+ import { findAccount, listAccountDirs, loadAccounts } from "./src/accounts.js";
10
10
  import { CONFIG } from "./src/config.js";
11
- import { createCompletionHandler } from "./src/completions.js";
12
- import { createPowSolver, loadWasm } from "./src/deepseek/pow.js";
13
- import { createRequestHandler } from "./src/http.js";
14
11
  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
- ]);
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";
22
15
 
23
- const handleChatCompletions = createCompletionHandler({ pool, logger });
16
+ export function createAccountsApp({ slots, logger = createLogger("Server") }) {
17
+ const sanitize = createSanitizer(() => slots.flatMap((slot) => [slot.apiKey, ...slot.auth.secrets()]));
24
18
  const handleRequest = createRequestHandler({
25
- handleChatCompletions,
26
- getStatus: () => pool.statusSummary(),
19
+ resolveAccount: (authorization) => findAccount(slots, authorization),
27
20
  sanitize,
28
- apiKey,
29
21
  logger,
30
22
  });
31
-
32
- return { pool, handleRequest, sanitize, log: logger };
23
+ return { slots, handleRequest, sanitize, log: logger };
33
24
  }
34
25
 
35
26
  function stopApp(app) {
36
- for (const slot of app.pool.slots) {
27
+ for (const slot of app.slots) {
28
+ slot.graph.stopPeriodicPrune();
37
29
  slot.stopHealthMonitor();
38
30
  }
39
31
  }
@@ -43,38 +35,27 @@ async function main() {
43
35
  try {
44
36
  const accountNames = await listAccountDirs();
45
37
  if (accountNames.length === 0) {
46
- console.error("❌ No accounts found! Add at least one account in accounts/<name>/curl.txt");
38
+ console.error("No accounts found!");
47
39
  process.exit(1);
48
40
  }
49
41
 
50
42
  const log = createLogger("Server");
51
43
  const wasm = await loadWasm({ logger: createLogger("WASM") });
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
-
44
+ const slots = await loadAccounts({ powSolver: createPowSolver(wasm), onLoggedOut: (name) => log.error(`${name} logged out`) });
45
+ app = createAccountsApp({ slots, logger: log });
60
46
  for (const slot of slots) {
47
+ slot.graph.startPeriodicPrune();
61
48
  slot.startHealthMonitor();
62
49
  }
63
50
 
64
51
  const server = http.createServer((req, res) => {
65
52
  app.handleRequest(req, res);
66
53
  });
67
-
68
54
  server.listen(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
- }
55
+ app.log.info(`🚀 Black Mamba listening on http://localhost:${CONFIG.port}`);
75
56
  for (const slot of slots) {
76
- if (slot.auth.verified) app.log.info(`✅ Account [${slot.name}] verified and ready`);
77
- else app.log.error(`❌ Account [${slot.name}] verification failed`);
57
+ if (slot.auth.verified) app.log.info(`${slot.name} logged in successfully`);
58
+ else app.log.error(`${slot.name} log in failed`);
78
59
  }
79
60
  });
80
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mathew-macharia/black-mamba",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
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
- // Multi-account management and rotational routing pool ("Wi-Fi router" hub).
1
+ // One folder per DeepSeek login: app key, cURL, log file, and session store.
2
2
 
3
3
  import path from "node:path";
4
4
  import { existsSync } from "node:fs";
@@ -9,8 +9,11 @@ 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 { createPacer } from "./pacing.js";
13
- import { createSanitizer } from "./sanitize.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";
14
17
 
15
18
  function accountsDir(rootDir) {
16
19
  return path.join(rootDir, "accounts");
@@ -23,33 +26,31 @@ export async function listAccountDirs(rootDir = CONFIG.rootDir) {
23
26
  return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
24
27
  }
25
28
 
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
- } = {}) {
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 = () => {} } = {}) {
37
35
  const names = await listAccountDirs(rootDir);
38
36
  if (names.length === 0) return [];
39
37
 
40
38
  const specs = [];
39
+ const seen = new Map();
41
40
  for (const name of names) {
42
41
  const dir = path.join(accountsDir(rootDir), name);
43
- const curlPath = path.join(dir, "curl.txt");
44
- if (!existsSync(curlPath)) continue;
45
- const curl = await readFile(curlPath, "utf8");
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);
46
47
  let parsed;
47
48
  try {
48
49
  parsed = parseCurlCommand(curl);
49
50
  } catch (err) {
50
51
  throw new Error(`accounts/${name}/curl.txt: ${err.message}`);
51
52
  }
52
- specs.push({ name, dir, parsed });
53
+ specs.push({ name, dir, apiKey, parsed });
53
54
  }
54
55
 
55
56
  const slots = [];
@@ -57,9 +58,10 @@ export async function loadAccounts({
57
58
  const logger = createLogger(spec.name, { sink: createFileSink(path.join(spec.dir, "logs.txt")) });
58
59
  const auth = new DeepSeekAuth();
59
60
  auth.applyParsedSession(spec.parsed);
60
- const sanitize = createSanitizer(() => auth.secrets());
61
+ const sanitize = createSanitizer(() => [spec.apiKey, ...auth.secrets()]);
62
+ const graph = new SessionGraph({ sessionsDir: path.join(spec.dir, ".session"), logger });
63
+ await graph.init();
61
64
  const pacer = createPacer({ logger, minDelayMs, maxDelayMs });
62
-
63
65
  let connected = false;
64
66
  const noteDisconnect = (reason) => {
65
67
  logger.error(sanitize(reason));
@@ -67,8 +69,8 @@ export async function loadAccounts({
67
69
  connected = false;
68
70
  onLoggedOut(spec.name);
69
71
  };
70
-
71
72
  const deepseek = createDeepSeekClient({ auth, powSolver, fetchImpl, onAuthFailure: noteDisconnect });
73
+ const handleChatCompletions = createCompletionHandler({ deepseek, graph, pacer, logger });
72
74
 
73
75
  let healthTimer = null;
74
76
  const stopHealthMonitor = () => {
@@ -99,68 +101,7 @@ export async function loadAccounts({
99
101
  logger.error(sanitize(err));
100
102
  }
101
103
 
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
- );
104
+ slots.push(Object.freeze({ name: spec.name, apiKey: spec.apiKey, auth, graph, logger, handleChatCompletions, startHealthMonitor, stopHealthMonitor }));
114
105
  }
115
106
  return slots;
116
107
  }
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
- }