@mathew-macharia/black-mamba 1.0.3 → 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.
@@ -1,17 +1,18 @@
1
1
  // HTTP plumbing: routing, body parsing, API-key validation, JSON/SSE writers, error rendering.
2
2
 
3
3
  import crypto from "node:crypto";
4
- import { CONFIG } from "../config.js";
5
- import { BadRequestError, HttpError } from "../errors.js";
6
- import { createLogger } from "../logger.js";
7
- import { formatStreamErrorEvent } from "../pi/response.js";
4
+ import { CONFIG } from "./config.js";
5
+ import { BadRequestError, HttpError } from "./errors.js";
6
+ import { createLogger } from "./logger.js";
7
+ import { formatStreamErrorEvent } from "./response.js";
8
8
 
9
9
  const SUPPORTED_MODEL_IDS = Object.freeze(["deepseek-chat", "deepseek-reasoner", "deepseek-chat-search", "deepseek-reasoner-search"]);
10
- const CORS_HEADERS = "Content-Type, Authorization, X-Session-Id, X-Entry-Id, X-Parent-Id, X-Parent-Session-Id";
10
+ const CORS_HEADERS = "Content-Type, Authorization";
11
11
 
12
12
  export function validateApiKey(req, apiKey) {
13
+ if (!apiKey) return true; // if no apiKey configured, allow all
13
14
  const authHeader = req.headers["authorization"];
14
- if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ") || !apiKey) return false;
15
+ if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) return false;
15
16
  const expected = Buffer.from(apiKey, "utf8");
16
17
  const provided = Buffer.from(authHeader.slice("Bearer ".length), "utf8");
17
18
  return expected.length === provided.length && crypto.timingSafeEqual(expected, provided);
@@ -68,7 +69,7 @@ export function errorBody(err, sanitize) {
68
69
  return { message: sanitize(err instanceof Error ? err.message : "Internal server error"), code: "internal_error" };
69
70
  }
70
71
 
71
- async function routeAuthed(req, res, pathname, { handleChatCompletions, graph }) {
72
+ async function routeAuthed(req, res, pathname, { handleChatCompletions }) {
72
73
  if (req.method === "GET" && pathname === "/v1/models") {
73
74
  const created = Math.floor(Date.now() / 1000);
74
75
  sendJson(res, 200, { object: "list", data: SUPPORTED_MODEL_IDS.map((id) => ({ id, object: "model", created, owned_by: "deepseek" })) });
@@ -79,41 +80,22 @@ async function routeAuthed(req, res, pathname, { handleChatCompletions, graph })
79
80
  return;
80
81
  }
81
82
 
82
- const sessionMatch = pathname.match(/^\/v1\/sessions\/([^/]+)$/);
83
- if (sessionMatch) {
84
- const piSessionId = decodeURIComponent(sessionMatch[1]);
85
- if (req.method === "GET") {
86
- const session = await graph.getSession(piSessionId);
87
- if (!session) {
88
- sendJson(res, 404, { error: { message: `No mapping for pi session ${piSessionId}`, code: "session_not_found" } });
89
- } else {
90
- sendJson(res, 200, graph.describeSession(session));
91
- }
92
- return;
93
- }
94
- if (req.method === "DELETE") {
95
- const existed = await graph.deleteSession(piSessionId);
96
- sendJson(res, 200, { deleted: existed, piSessionId, note: "The next request for this session will seed a fresh DeepSeek chat." });
97
- return;
98
- }
99
- }
100
-
101
83
  sendJson(res, 404, { error: { message: "Route not found", code: "not_found" } });
102
84
  }
103
85
 
104
86
  /**
105
87
  * @param {object} deps
106
- * @param {(req, res) => Promise<void>} [deps.handleChatCompletions]
88
+ * @param {(req, res) => Promise<void>} deps.handleChatCompletions
107
89
  * @param {() => object} [deps.getStatus]
108
- * @param {import("../session/graph.js").SessionGraph} [deps.graph]
109
90
  * @param {(err: unknown) => string} deps.sanitize
110
- * @param {(authorizationHeader: string) => object|null} [deps.resolveAccount]
91
+ * @param {string} [deps.apiKey]
92
+ * @param {object} [deps.logger]
111
93
  */
112
- export function createRequestHandler({ handleChatCompletions, getStatus, graph, sanitize, apiKey = "", logger = createLogger("Server"), resolveAccount = null }) {
94
+ export function createRequestHandler({ handleChatCompletions, getStatus = () => ({ status: "ok" }), sanitize, apiKey = CONFIG.apiKey, logger = createLogger("Server") }) {
113
95
  return async function handleRequest(req, res) {
114
96
  res.setHeader("Access-Control-Allow-Origin", "*");
115
97
  res.setHeader("Access-Control-Allow-Headers", CORS_HEADERS);
116
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
98
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
117
99
 
118
100
  if (req.method === "OPTIONS") {
119
101
  res.writeHead(204);
@@ -122,24 +104,8 @@ export function createRequestHandler({ handleChatCompletions, getStatus, graph,
122
104
  }
123
105
 
124
106
  const pathname = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`).pathname;
125
- let requestLog = logger;
126
107
 
127
108
  try {
128
- if (resolveAccount) {
129
- const account = resolveAccount(req.headers.authorization);
130
- if (!account) {
131
- sendJson(res, 401, { error: { message: "Invalid API key", code: "invalid_api_key" } });
132
- return;
133
- }
134
- requestLog = account.logger;
135
- if (req.method === "GET" && pathname === "/") {
136
- sendJson(res, 200, account.auth.statusSummary());
137
- return;
138
- }
139
- await routeAuthed(req, res, pathname, { handleChatCompletions: account.handleChatCompletions, graph: account.graph });
140
- return;
141
- }
142
-
143
109
  if (req.method === "GET" && pathname === "/") {
144
110
  sendJson(res, 200, getStatus());
145
111
  return;
@@ -148,12 +114,12 @@ export function createRequestHandler({ handleChatCompletions, getStatus, graph,
148
114
  sendJson(res, 401, { error: { message: "Invalid API key", code: "invalid_api_key" } });
149
115
  return;
150
116
  }
151
- await routeAuthed(req, res, pathname, { handleChatCompletions, graph });
117
+ await routeAuthed(req, res, pathname, { handleChatCompletions });
152
118
  } catch (err) {
153
119
  const statusCode = err instanceof HttpError ? err.statusCode : 500;
154
120
  const body = errorBody(err, sanitize);
155
- if (statusCode >= 500) requestLog.error(`Request error (${req.method} ${pathname}):`, body.message, body.details ? JSON.stringify(body.details) : "");
156
- else requestLog.warn(`Request rejected (${req.method} ${pathname}) ${statusCode}:`, body.message);
121
+ if (statusCode >= 500) logger.error(`Request error (${req.method} ${pathname}):`, body.message, body.details ? JSON.stringify(body.details) : "");
122
+ else logger.warn(`Request rejected (${req.method} ${pathname}) ${statusCode}:`, body.message);
157
123
 
158
124
  if (!res.headersSent) {
159
125
  sendJson(res, statusCode, { error: body });
@@ -1,6 +1,6 @@
1
1
  // Human-like pacing between consecutive DeepSeek turns (DeepSeek web challenges rapid-fire automation).
2
2
 
3
- import { CONFIG } from "../config.js";
3
+ import { CONFIG } from "./config.js";
4
4
 
5
5
  export function calculateTurnPacingDelay({ lastFinishedAt, now = Date.now(), minDelayMs = CONFIG.pacing.minTurnDelayMs, maxDelayMs = CONFIG.pacing.maxTurnDelayMs, randomFn = Math.random }) {
6
6
  if (!lastFinishedAt || lastFinishedAt <= 0) return 0;
@@ -1,11 +1,56 @@
1
1
  // Renders OpenAI-format messages into the single text prompt DeepSeek web accepts.
2
- // Two shapes: a *seed* (full history, starts a DeepSeek chat) and a *delta* (only what DeepSeek has not seen yet).
2
+ // Stateless: dumps the entire conversation history into a fresh DeepSeek chat.
3
3
 
4
- import { contentToText, normalizeRole, parseToolArguments, toolCallArguments, toolCallName } from "./messages.js";
4
+ import { BadRequestError } from "./errors.js";
5
5
 
6
6
  const ASSISTANT_LABEL_WITH_TOOLS = "M3GAN";
7
7
  const ASSISTANT_LABEL_PLAIN = "ASSISTANT";
8
8
 
9
+ export function normalizeRole(role) {
10
+ return typeof role === "string" ? role.trim().toLowerCase() : "";
11
+ }
12
+
13
+ /**
14
+ * Renders message content to text. Non-text parts are made visible as explicit placeholders instead of being dropped.
15
+ */
16
+ export function contentToText(content) {
17
+ if (content === null || content === undefined) return "";
18
+ if (typeof content === "string") return content;
19
+ if (Array.isArray(content)) {
20
+ return content
21
+ .map((part) => {
22
+ if (!part || typeof part !== "object") return "[unsupported content part]";
23
+ if (part.type === "text") return String(part.text ?? "");
24
+ if (part.type === "image_url" || part.type === "image") return "[image omitted: Black Mamba forwards text only]";
25
+ return `[unsupported content part: ${String(part.type)}]`;
26
+ })
27
+ .join("\n");
28
+ }
29
+ throw new BadRequestError("Message 'content' must be a string or an array of content parts", "invalid_message");
30
+ }
31
+
32
+ /** Parses tool-call arguments; returns { value, raw } where raw is kept only when the string is not valid JSON. */
33
+ export function parseToolArguments(args) {
34
+ if (args === undefined || args === null) return { value: {} };
35
+ if (typeof args === "object") return { value: args };
36
+ if (typeof args === "string") {
37
+ try {
38
+ return { value: JSON.parse(args) };
39
+ } catch {
40
+ return { value: null, raw: args };
41
+ }
42
+ }
43
+ return { value: null, raw: String(args) };
44
+ }
45
+
46
+ export function toolCallName(tc) {
47
+ return tc?.function?.name ?? tc?.name ?? "";
48
+ }
49
+
50
+ export function toolCallArguments(tc) {
51
+ return tc?.function?.arguments ?? tc?.arguments;
52
+ }
53
+
9
54
  function toolNames(tools) {
10
55
  return (Array.isArray(tools) ? tools : []).map((t) => t.function?.name || t.name).filter(Boolean);
11
56
  }
@@ -113,7 +158,6 @@ function renderDsmlToolBlock(tc) {
113
158
  return `<|DSML|invoke name="${name}">\n${params}\n</|DSML|invoke>`;
114
159
  }
115
160
 
116
- /** Assistant text followed by its tool calls rendered as native DSML blocks. */
117
161
  export function renderAssistantContent(msg) {
118
162
  let content = contentToText(msg.content);
119
163
  if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
@@ -141,8 +185,9 @@ function trailingDirective(lastMessage, tools) {
141
185
  }
142
186
 
143
187
  /**
144
- * Full-history prompt used to start (or restart) a DeepSeek chat session.
145
- * With tools: persona system prompt + labelled transcript. Without tools: plain labelled transcript.
188
+ * Full-history prompt dumped to start a fresh DeepSeek chat session.
189
+ * 1. With tools (e.g. Pi): M3GAN persona + tool definitions + history + trailing directive.
190
+ * 2. Without tools (e.g. other apps): Plain transcript (SYSTEM: ..., USER: ..., ASSISTANT: ...).
146
191
  */
147
192
  export function renderSeedPrompt(messages, tools = []) {
148
193
  const hasTools = Array.isArray(tools) && tools.length > 0;
@@ -181,42 +226,3 @@ export function renderSeedPrompt(messages, tools = []) {
181
226
  if (directive) prompt += `\n${directive}`;
182
227
  return prompt;
183
228
  }
184
-
185
- /**
186
- * Prompt for one DeepSeek turn containing only the messages DeepSeek has not seen.
187
- * @param {object[]} deltaMessages Messages after the last DeepSeek reply (never empty).
188
- * @param {object[]} tools
189
- * @param {{ systemUpdate?: string|null }} [options] New full system prompt when it changed since the parent node.
190
- */
191
- export function renderDeltaPrompt(deltaMessages, tools = [], { systemUpdate = null } = {}) {
192
- if (!Array.isArray(deltaMessages) || deltaMessages.length === 0) {
193
- throw new Error("renderDeltaPrompt requires at least one message");
194
- }
195
- const multiple = deltaMessages.length > 1;
196
- const parts = [];
197
-
198
- if (systemUpdate) {
199
- parts.push(`<<System prompt update>>\nThe system prompt changed. The complete current system prompt follows:\n${systemUpdate}`);
200
- }
201
-
202
- deltaMessages.forEach((msg, i) => {
203
- const role = normalizeRole(msg.role);
204
- if (role === "tool") {
205
- const label = multiple ? `Result for tool call ${i + 1}:` : null;
206
- parts.push(renderToolResultBlock(msg, label));
207
- } else if (role === "user") {
208
- const text = contentToText(msg.content);
209
- parts.push(multiple ? `USER: ${text}` : text);
210
- } else if (role === "assistant") {
211
- parts.push(`${ASSISTANT_LABEL_WITH_TOOLS}: ${renderAssistantContent(msg)}`);
212
- } else if (role === "system" || role === "developer") {
213
- parts.push(`<<System update>>\n${contentToText(msg.content)}`);
214
- } else {
215
- parts.push(`${role.toUpperCase()}: ${contentToText(msg.content)}`);
216
- }
217
- });
218
-
219
- const directive = trailingDirective(deltaMessages[deltaMessages.length - 1], tools);
220
- if (directive) parts.push(directive);
221
- return parts.join("\n\n");
222
- }
@@ -1,29 +1,9 @@
1
- // Validates an OpenAI-compatible chat completion request and extracts the pi routing contract.
2
- //
3
- // Routing contract (sent by the pi extension in pi-extension/black-mamba.ts):
4
- // x-session-id pi session id (also sent natively by pi-ai when sessionAffinityFormat = "openrouter")
5
- // x-entry-id pi session entry id at which this request is made (the future parent of the assistant entry)
6
- // x-parent-id entry id at which the previous *successful* assistant on this branch was requested
7
- // x-parent-session-id when the session was created by /fork: the pi session id it was forked from
8
- //
9
- // Without x-session-id the request is treated as stateless. With x-session-id but without x-entry-id the request is
10
- // rejected: there is no way to map it onto the DeepSeek tree precisely, and silently degrading would hide the problem.
1
+ // Validates an OpenAI-compatible chat completion request.
11
2
 
12
- import { BadRequestError } from "../errors.js";
3
+ import { BadRequestError } from "./errors.js";
13
4
 
14
5
  const DEFAULT_MODEL = "deepseek-chat";
15
6
 
16
- function getHeader(headers, name) {
17
- if (!headers || typeof headers !== "object") return "";
18
- const lower = name.toLowerCase();
19
- for (const [key, value] of Object.entries(headers)) {
20
- if (key.toLowerCase() !== lower) continue;
21
- const str = Array.isArray(value) ? value[0] : value;
22
- return typeof str === "string" ? str.trim() : str != null ? String(str).trim() : "";
23
- }
24
- return "";
25
- }
26
-
27
7
  function validateToolCall(tc, index) {
28
8
  if (!tc || typeof tc !== "object") throw new BadRequestError(`tool_calls[${index}] must be an object`, "invalid_tool_call");
29
9
  const fn = tc.function;
@@ -79,27 +59,10 @@ function resolveThinking(body, lowerModel) {
79
59
  return lowerModel.includes("reasoner") || lowerModel.includes("r1") || lowerModel.includes("think");
80
60
  }
81
61
 
82
- function extractRouting(headers) {
83
- const sessionId = getHeader(headers, "x-session-id");
84
- if (!sessionId) return null;
85
-
86
- const entryId = getHeader(headers, "x-entry-id");
87
- if (!entryId) {
88
- throw new BadRequestError(
89
- "x-session-id was sent without x-entry-id. Black Mamba maps pi's session tree onto DeepSeek precisely and needs the pi extension " +
90
- "(pi-extension/black-mamba.ts -> ~/.pi/agent/extensions/) to send x-entry-id and x-parent-id. Remove x-session-id for stateless use.",
91
- "missing_entry_id"
92
- );
93
- }
94
- const parentEntryId = getHeader(headers, "x-parent-id") || null;
95
- const parentSessionId = getHeader(headers, "x-parent-session-id") || null;
96
- return Object.freeze({ sessionId, entryId, parentEntryId, parentSessionId });
97
- }
98
-
99
62
  /**
100
- * @returns {{ messages: object[], tools: object[], model: string, stream: boolean, includeUsage: boolean, thinkingEnabled: boolean, searchEnabled: boolean, routing: {sessionId:string, entryId:string, parentEntryId:string|null, parentSessionId:string|null}|null }}
63
+ * @returns {{ messages: object[], tools: object[], model: string, stream: boolean, includeUsage: boolean, thinkingEnabled: boolean, searchEnabled: boolean }}
101
64
  */
102
- export function parseChatRequest(body, headers = {}) {
65
+ export function parseChatRequest(body) {
103
66
  if (!body || typeof body !== "object" || Array.isArray(body)) {
104
67
  throw new BadRequestError("Request body must be a JSON object", "invalid_body");
105
68
  }
@@ -119,6 +82,5 @@ export function parseChatRequest(body, headers = {}) {
119
82
  includeUsage: Boolean(body.stream_options && body.stream_options.include_usage),
120
83
  thinkingEnabled: resolveThinking(body, lowerModel),
121
84
  searchEnabled: lowerModel.includes("search") || lowerModel.endsWith(":online"),
122
- routing: extractRouting(headers),
123
85
  });
124
86
  }
@@ -1,6 +1,6 @@
1
1
  // OpenAI-compatible response payloads (JSON and SSE chunks) and usage accounting.
2
2
 
3
- import { estimateTokenCount } from "../utils/tokens.js";
3
+ import { estimateTokenCount } from "./tokens.js";
4
4
 
5
5
  export function createCompletionMetadata(nowMs = Date.now()) {
6
6
  return { completionId: `chatcmpl-${nowMs}`, created: Math.floor(nowMs / 1000) };
@@ -34,27 +34,11 @@ export function buildUsageStats({ prompt, reply, turnTokens = 0, cachedTokens =
34
34
  return usage;
35
35
  }
36
36
 
37
- /** Extra, non-OpenAI fields that make every mapping decision visible to the caller. */
38
- export function buildMappingInfo({ sessionId, deepseekSessionId, messageId, parentMessageId, strategy, reason, deltaMessageCount }) {
39
- const info = {
40
- chat_session_id: sessionId,
41
- deepseek_session_id: deepseekSessionId ?? null,
42
- deepseek_message_id: messageId !== null && messageId !== undefined ? String(messageId) : null,
43
- black_mamba: {
44
- strategy,
45
- reason: reason ?? null,
46
- deepseek_parent_message_id: parentMessageId !== null && parentMessageId !== undefined ? String(parentMessageId) : null,
47
- },
48
- };
49
- if (deltaMessageCount !== undefined) info.black_mamba.delta_messages = deltaMessageCount;
50
- return info;
51
- }
52
-
53
37
  function baseChunk({ completionId, created, model }, choices, extra = {}) {
54
38
  return { id: completionId, object: "chat.completion.chunk", created, model, choices, ...extra };
55
39
  }
56
40
 
57
- export function formatCompletionResponse({ completionId, created, model, content, toolCalls = [], usage, mapping }) {
41
+ export function formatCompletionResponse({ completionId, created, model, content, toolCalls = [], usage }) {
58
42
  const hasTools = toolCalls.length > 0;
59
43
  const message = { role: "assistant", content: hasTools ? content || null : content };
60
44
  if (hasTools) message.tool_calls = toolCalls;
@@ -65,7 +49,6 @@ export function formatCompletionResponse({ completionId, created, model, content
65
49
  model,
66
50
  choices: [{ index: 0, message, finish_reason: hasTools ? "tool_calls" : "stop" }],
67
51
  usage,
68
- ...(mapping ?? {}),
69
52
  };
70
53
  }
71
54
 
@@ -97,8 +80,8 @@ export function formatStreamToolCallArgsEvent(meta, { toolCallIndex, argumentsCh
97
80
  return sseFrame(baseChunk(meta, [{ index: 0, delta: { tool_calls: [{ index: toolCallIndex, function: { arguments: argumentsChunk } }] }, finish_reason: null }]));
98
81
  }
99
82
 
100
- export function formatStreamFinishEvent(meta, { finishReason = "stop", mapping } = {}) {
101
- return sseFrame(baseChunk(meta, [{ index: 0, delta: {}, finish_reason: finishReason }], mapping ?? {}));
83
+ export function formatStreamFinishEvent(meta, { finishReason = "stop" } = {}) {
84
+ return sseFrame(baseChunk(meta, [{ index: 0, delta: {}, finish_reason: finishReason }]));
102
85
  }
103
86
 
104
87
  export function formatStreamUsageEvent(meta, usage) {
package/docs/accounts.md DELETED
@@ -1,37 +0,0 @@
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 DELETED
@@ -1,40 +0,0 @@
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 DELETED
@@ -1,171 +0,0 @@
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. |