@mathew-macharia/black-mamba 0.0.0-stage → 1.0.0
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 +13 -0
- package/README.md +23 -2
- package/docs/accounts.md +37 -0
- package/docs/config.md +40 -0
- package/docs/http.md +171 -0
- package/docs/install.md +71 -0
- package/docs/mapping.md +11 -0
- package/docs/overview.md +25 -0
- package/index.js +76 -0
- package/package.json +25 -4
- package/src/accounts.js +107 -0
- package/src/config.js +69 -0
- package/src/deepseek/auth-cli.js +119 -0
- package/src/deepseek/auth.js +163 -0
- package/src/deepseek/classify.js +37 -0
- package/src/deepseek/client.js +151 -0
- package/src/deepseek/curl-parser.js +163 -0
- package/src/deepseek/pow.js +77 -0
- package/src/deepseek/sse.js +202 -0
- package/src/errors.js +58 -0
- package/src/logger.js +30 -0
- package/src/pi/messages.js +130 -0
- package/src/pi/prompt.js +207 -0
- package/src/pi/request.js +124 -0
- package/src/pi/response.js +110 -0
- package/src/pi/tool-calls.js +201 -0
- package/src/server/completions.js +214 -0
- package/src/server/http.js +167 -0
- package/src/server/pacing.js +40 -0
- package/src/session/graph.js +182 -0
- package/src/session/planner.js +177 -0
- package/src/session/store.js +100 -0
- package/src/utils/hash.js +27 -0
- package/src/utils/sanitize.js +10 -0
- package/src/utils/tokens.js +22 -0
package/.env.example
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
PORT=3000
|
|
2
|
+
|
|
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
|
+
|
|
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
|
|
11
|
+
|
|
12
|
+
# Verbose mapping/decoder logging
|
|
13
|
+
# BM_DEBUG=1
|
package/README.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Black Mamba
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OpenAI-compatible proxy. One process, one URL. Each DeepSeek login is a folder under `accounts/`.
|
|
4
|
+
|
|
5
|
+
Requires Node.js 18 or newer.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npm install -g @mathew-macharia/black-mamba
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Run `bm` in the folder that holds your data. That folder is where it reads `accounts/`, `.env`, and `sha3_wasm_bg.wasm`.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
accounts/<name>/api-key.txt
|
|
15
|
+
accounts/<name>/curl.txt
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`api-key.txt` is the bearer key your app sends. `curl.txt` is a copied DeepSeek web request (bearer token and cookies). The process creates `logs.txt` and `.session/` in that same folder.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
bm
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
If there are no account folders, the process prints `No accounts found!` and exits. Details are in `docs/`.
|
package/docs/accounts.md
ADDED
|
@@ -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 wasm file is separate. If `sha3_wasm_bg.wasm` is missing from the working directory, it is downloaded from DeepSeek's static file host and saved 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. |
|
package/docs/install.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
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/`, `.env`, and `sha3_wasm_bg.wasm`.
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
<data-directory>/
|
|
13
|
+
.env optional
|
|
14
|
+
sha3_wasm_bg.wasm created on first start if missing
|
|
15
|
+
accounts/
|
|
16
|
+
<name>/
|
|
17
|
+
api-key.txt
|
|
18
|
+
curl.txt
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`<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`.
|
|
22
|
+
|
|
23
|
+
`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.
|
|
24
|
+
|
|
25
|
+
`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:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
curl "<deepseek-url>" -H "authorization: Bearer <deepseek-bearer>" -H "cookie: <cookie>"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Do not commit `accounts/` or `.env`.
|
|
32
|
+
|
|
33
|
+
From the data directory:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
bm
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If `sha3_wasm_bg.wasm` is missing from that directory, startup downloads it and saves it there. The terminal shows two `[WASM]` lines: one when the download starts, and one when the file is saved.
|
|
40
|
+
|
|
41
|
+
When the server is up, stdout has:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
[Server] 🚀 Black Mamba listening on http://localhost:3000
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The port is `PORT` (default 3000). Each account then prints one line. Stdout on success:
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
[Server] <name> logged in successfully
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Stderr when the DeepSeek check failed:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
[Server] <name> log in failed
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The failure reason is appended to `accounts/<name>/logs.txt`, not to that terminal line.
|
|
60
|
+
|
|
61
|
+
If a login that had succeeded later fails (the 30-minute check, or a DeepSeek auth error on a request), stderr prints:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
[Server] <name> logged out
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The reason is written to that account's `logs.txt` before the terminal line.
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
`SIGINT` and `SIGTERM` stop the process. The terminal prints `Received <signal>. Shutting down gracefully...` and the process exits with status 0.
|
package/docs/mapping.md
ADDED
|
@@ -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.
|
package/docs/overview.md
ADDED
|
@@ -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
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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.
|
|
4
|
+
|
|
5
|
+
import http from "node:http";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
import { findAccount, listAccountDirs, loadAccounts } from "./src/accounts.js";
|
|
10
|
+
import { CONFIG } from "./src/config.js";
|
|
11
|
+
import { createLogger } from "./src/logger.js";
|
|
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";
|
|
15
|
+
|
|
16
|
+
export function createAccountsApp({ slots, logger = createLogger("Server") }) {
|
|
17
|
+
const sanitize = createSanitizer(() => slots.flatMap((slot) => [slot.apiKey, ...slot.auth.secrets()]));
|
|
18
|
+
const handleRequest = createRequestHandler({
|
|
19
|
+
resolveAccount: (authorization) => findAccount(slots, authorization),
|
|
20
|
+
sanitize,
|
|
21
|
+
logger,
|
|
22
|
+
});
|
|
23
|
+
return { slots, handleRequest, sanitize, log: logger };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stopApp(app) {
|
|
27
|
+
for (const slot of app.slots) {
|
|
28
|
+
slot.graph.stopPeriodicPrune();
|
|
29
|
+
slot.stopHealthMonitor();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function main() {
|
|
34
|
+
let app;
|
|
35
|
+
try {
|
|
36
|
+
const accountNames = await listAccountDirs();
|
|
37
|
+
if (accountNames.length === 0) {
|
|
38
|
+
console.error("No accounts found!");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const log = createLogger("Server");
|
|
43
|
+
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 });
|
|
46
|
+
for (const slot of slots) {
|
|
47
|
+
slot.graph.startPeriodicPrune();
|
|
48
|
+
slot.startHealthMonitor();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const server = http.createServer((req, res) => {
|
|
52
|
+
app.handleRequest(req, res);
|
|
53
|
+
});
|
|
54
|
+
server.listen(CONFIG.port, () => {
|
|
55
|
+
app.log.info(`🚀 Black Mamba listening on http://localhost:${CONFIG.port}`);
|
|
56
|
+
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`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const shutdown = (signal) => {
|
|
63
|
+
app.log.info(`Received ${signal}. Shutting down gracefully...`);
|
|
64
|
+
stopApp(app);
|
|
65
|
+
server.close(() => process.exit(0));
|
|
66
|
+
};
|
|
67
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
68
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
69
|
+
} catch (err) {
|
|
70
|
+
console.error("[Startup] Fatal error:", app ? app.sanitize(err) : err instanceof Error ? err.message : err);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isMainModule = Boolean(process.argv[1]) && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
76
|
+
if (isMainModule) main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mathew-macharia/black-mamba",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenAI-compatible proxy that maps chat sessions onto DeepSeek web chat",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"bm": "index.js"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=18"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"src",
|
|
16
|
+
"docs",
|
|
17
|
+
".env.example",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"start": "node index.js",
|
|
22
|
+
"test": "node --test --test-concurrency=1 \"test/**/*.test.js\""
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"dotenv": "^16.4.7"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/accounts.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// One folder per DeepSeek login: app key, cURL, log file, and session store.
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { readFile, readdir } from "node:fs/promises";
|
|
6
|
+
|
|
7
|
+
import { CONFIG } from "./config.js";
|
|
8
|
+
import { createDeepSeekClient } from "./deepseek/client.js";
|
|
9
|
+
import { DeepSeekAuth } from "./deepseek/auth.js";
|
|
10
|
+
import { parseCurlCommand } from "./deepseek/curl-parser.js";
|
|
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";
|
|
17
|
+
|
|
18
|
+
function accountsDir(rootDir) {
|
|
19
|
+
return path.join(rootDir, "accounts");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function listAccountDirs(rootDir = CONFIG.rootDir) {
|
|
23
|
+
const dir = accountsDir(rootDir);
|
|
24
|
+
if (!existsSync(dir)) return [];
|
|
25
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
26
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
27
|
+
}
|
|
28
|
+
|
|
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 = () => {} } = {}) {
|
|
35
|
+
const names = await listAccountDirs(rootDir);
|
|
36
|
+
if (names.length === 0) return [];
|
|
37
|
+
|
|
38
|
+
const specs = [];
|
|
39
|
+
const seen = new Map();
|
|
40
|
+
for (const name of names) {
|
|
41
|
+
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);
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = parseCurlCommand(curl);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
throw new Error(`accounts/${name}/curl.txt: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
specs.push({ name, dir, apiKey, parsed });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const slots = [];
|
|
57
|
+
for (const spec of specs) {
|
|
58
|
+
const logger = createLogger(spec.name, { sink: createFileSink(path.join(spec.dir, "logs.txt")) });
|
|
59
|
+
const auth = new DeepSeekAuth();
|
|
60
|
+
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();
|
|
64
|
+
const pacer = createPacer({ logger, minDelayMs, maxDelayMs });
|
|
65
|
+
let connected = false;
|
|
66
|
+
const noteDisconnect = (reason) => {
|
|
67
|
+
logger.error(sanitize(reason));
|
|
68
|
+
if (!connected) return;
|
|
69
|
+
connected = false;
|
|
70
|
+
onLoggedOut(spec.name);
|
|
71
|
+
};
|
|
72
|
+
const deepseek = createDeepSeekClient({ auth, powSolver, fetchImpl, onAuthFailure: noteDisconnect });
|
|
73
|
+
const handleChatCompletions = createCompletionHandler({ deepseek, graph, pacer, logger });
|
|
74
|
+
|
|
75
|
+
let healthTimer = null;
|
|
76
|
+
const stopHealthMonitor = () => {
|
|
77
|
+
if (healthTimer) clearInterval(healthTimer);
|
|
78
|
+
healthTimer = null;
|
|
79
|
+
};
|
|
80
|
+
const startHealthMonitor = () => {
|
|
81
|
+
stopHealthMonitor();
|
|
82
|
+
healthTimer = setInterval(async () => {
|
|
83
|
+
if (!auth.verified) return;
|
|
84
|
+
try {
|
|
85
|
+
await auth.verify(fetchImpl);
|
|
86
|
+
const summary = auth.statusSummary();
|
|
87
|
+
const expiry = summary.next_due_expiry ? `${summary.next_due_expiry.cookie} due in ${summary.next_due_expiry.remaining}` : "active";
|
|
88
|
+
logger.info(`Health check OK (age: ${summary.session_age}, ${expiry})`);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
noteDisconnect(err);
|
|
91
|
+
}
|
|
92
|
+
}, CONFIG.deepseek.healthCheckIntervalMs);
|
|
93
|
+
healthTimer.unref?.();
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await auth.verify(fetchImpl);
|
|
98
|
+
connected = true;
|
|
99
|
+
logger.info("DeepSeek login verified");
|
|
100
|
+
} catch (err) {
|
|
101
|
+
logger.error(sanitize(err));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
slots.push(Object.freeze({ name: spec.name, apiKey: spec.apiKey, auth, graph, logger, handleChatCompletions, startHealthMonitor, stopHealthMonitor }));
|
|
105
|
+
}
|
|
106
|
+
return slots;
|
|
107
|
+
}
|