@mathew-macharia/black-mamba 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -8
- package/index.js +38 -19
- package/package.json +1 -1
- package/src/accounts.js +83 -24
- package/src/completions.js +149 -0
- package/src/config.js +1 -9
- package/src/deepseek/classify.js +13 -13
- package/src/deepseek/client.js +24 -23
- package/src/errors.js +0 -11
- package/src/{server/http.js → http.js} +16 -50
- package/src/{server/pacing.js → pacing.js} +1 -1
- package/src/{pi/prompt.js → prompt.js} +67 -46
- package/src/{pi/request.js → request.js} +4 -42
- package/src/{pi/response.js → response.js} +4 -21
- package/docs/accounts.md +0 -37
- package/docs/config.md +0 -40
- package/docs/http.md +0 -171
- package/docs/install.md +0 -68
- package/docs/mapping.md +0 -11
- package/docs/overview.md +0 -25
- package/src/deepseek/auth-cli.js +0 -119
- package/src/pi/messages.js +0 -130
- package/src/server/completions.js +0 -214
- package/src/session/graph.js +0 -182
- package/src/session/planner.js +0 -177
- package/src/session/store.js +0 -100
- package/src/utils/hash.js +0 -27
- /package/src/{utils/sanitize.js → sanitize.js} +0 -0
- /package/src/{utils/tokens.js → tokens.js} +0 -0
- /package/src/{pi/tool-calls.js → tool-calls.js} +0 -0
|
@@ -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 "
|
|
5
|
-
import { BadRequestError, HttpError } from "
|
|
6
|
-
import { createLogger } from "
|
|
7
|
-
import { formatStreamErrorEvent } from "
|
|
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
|
|
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 ")
|
|
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
|
|
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>}
|
|
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 {
|
|
91
|
+
* @param {string} [deps.apiKey]
|
|
92
|
+
* @param {object} [deps.logger]
|
|
111
93
|
*/
|
|
112
|
-
export function createRequestHandler({ handleChatCompletions, getStatus
|
|
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,
|
|
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
|
|
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)
|
|
156
|
-
else
|
|
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 "
|
|
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
|
-
//
|
|
2
|
+
// Stateless: dumps the entire conversation history into a fresh DeepSeek chat.
|
|
3
3
|
|
|
4
|
-
import {
|
|
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
|
}
|
|
@@ -19,12 +64,19 @@ function primaryToolExample(tools) {
|
|
|
19
64
|
|
|
20
65
|
function toolCallRules(tools) {
|
|
21
66
|
const { primaryTool, exampleParam } = primaryToolExample(tools);
|
|
22
|
-
return `1. You do not have direct terminal, shell, or file system access. To inspect files, run commands, or edit code, you request execution by emitting a DSML block:
|
|
67
|
+
return `1. You do not have direct terminal, shell, or file system access. To inspect files, run commands, or edit code, you request execution by emitting a DSML block. The format must match exactly like this:
|
|
23
68
|
<|DSML|tool_calls>
|
|
24
69
|
<|DSML|invoke name="${primaryTool}">
|
|
25
70
|
<|DSML|parameter ${exampleParam}</|DSML|parameter>
|
|
26
71
|
</|DSML|invoke>
|
|
27
72
|
</|DSML|tool_calls>
|
|
73
|
+
|
|
74
|
+
Explanation:
|
|
75
|
+
<|DSML|tool_calls> opens the block.
|
|
76
|
+
<|DSML|invoke name="${primaryTool}"> names the tool. ${primaryTool} is the tool name.
|
|
77
|
+
<|DSML|parameter name="..." string="true"> holds the value, and </|DSML|parameter> closes it.
|
|
78
|
+
</|DSML|invoke> then </|DSML|tool_calls> close the block.
|
|
79
|
+
|
|
28
80
|
2. Output EXACTLY ONE DSML block at the end of your response and end your turn immediately. Pi will execute it locally on the user's machine and return the result in a DSML result block.
|
|
29
81
|
3. Tool requests must strictly use the DSML block. Do not format tool calls with any other syntax.
|
|
30
82
|
4. If the user request does not require any tools, respond directly in normal text.`;
|
|
@@ -78,7 +130,15 @@ Pi is connected to chat.deepseek and sends and listens to https://chat.deepseek.
|
|
|
78
130
|
}
|
|
79
131
|
|
|
80
132
|
export function formatToolTurnDirective(_tools = []) {
|
|
81
|
-
return
|
|
133
|
+
return `[Tool output received from user's machine. To take another action, output a DSML block and end turn. If complete, respond directly to the user.]
|
|
134
|
+
|
|
135
|
+
REMINDER: When calling a tool, four tags must be there, in this order:
|
|
136
|
+
|
|
137
|
+
<|DSML|tool_calls>
|
|
138
|
+
<|DSML|invoke name="TOOL">
|
|
139
|
+
<|DSML|parameter name="PARAM" string="true">VALUE</|DSML|parameter>
|
|
140
|
+
</|DSML|invoke>
|
|
141
|
+
</|DSML|tool_calls>`;
|
|
82
142
|
}
|
|
83
143
|
|
|
84
144
|
export const USER_TOOL_REMINDER =
|
|
@@ -98,7 +158,6 @@ function renderDsmlToolBlock(tc) {
|
|
|
98
158
|
return `<|DSML|invoke name="${name}">\n${params}\n</|DSML|invoke>`;
|
|
99
159
|
}
|
|
100
160
|
|
|
101
|
-
/** Assistant text followed by its tool calls rendered as native DSML blocks. */
|
|
102
161
|
export function renderAssistantContent(msg) {
|
|
103
162
|
let content = contentToText(msg.content);
|
|
104
163
|
if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
|
|
@@ -126,8 +185,9 @@ function trailingDirective(lastMessage, tools) {
|
|
|
126
185
|
}
|
|
127
186
|
|
|
128
187
|
/**
|
|
129
|
-
* Full-history prompt
|
|
130
|
-
* With tools: persona
|
|
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: ...).
|
|
131
191
|
*/
|
|
132
192
|
export function renderSeedPrompt(messages, tools = []) {
|
|
133
193
|
const hasTools = Array.isArray(tools) && tools.length > 0;
|
|
@@ -166,42 +226,3 @@ export function renderSeedPrompt(messages, tools = []) {
|
|
|
166
226
|
if (directive) prompt += `\n${directive}`;
|
|
167
227
|
return prompt;
|
|
168
228
|
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Prompt for one DeepSeek turn containing only the messages DeepSeek has not seen.
|
|
172
|
-
* @param {object[]} deltaMessages Messages after the last DeepSeek reply (never empty).
|
|
173
|
-
* @param {object[]} tools
|
|
174
|
-
* @param {{ systemUpdate?: string|null }} [options] New full system prompt when it changed since the parent node.
|
|
175
|
-
*/
|
|
176
|
-
export function renderDeltaPrompt(deltaMessages, tools = [], { systemUpdate = null } = {}) {
|
|
177
|
-
if (!Array.isArray(deltaMessages) || deltaMessages.length === 0) {
|
|
178
|
-
throw new Error("renderDeltaPrompt requires at least one message");
|
|
179
|
-
}
|
|
180
|
-
const multiple = deltaMessages.length > 1;
|
|
181
|
-
const parts = [];
|
|
182
|
-
|
|
183
|
-
if (systemUpdate) {
|
|
184
|
-
parts.push(`<<System prompt update>>\nThe system prompt changed. The complete current system prompt follows:\n${systemUpdate}`);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
deltaMessages.forEach((msg, i) => {
|
|
188
|
-
const role = normalizeRole(msg.role);
|
|
189
|
-
if (role === "tool") {
|
|
190
|
-
const label = multiple ? `Result for tool call ${i + 1}:` : null;
|
|
191
|
-
parts.push(renderToolResultBlock(msg, label));
|
|
192
|
-
} else if (role === "user") {
|
|
193
|
-
const text = contentToText(msg.content);
|
|
194
|
-
parts.push(multiple ? `USER: ${text}` : text);
|
|
195
|
-
} else if (role === "assistant") {
|
|
196
|
-
parts.push(`${ASSISTANT_LABEL_WITH_TOOLS}: ${renderAssistantContent(msg)}`);
|
|
197
|
-
} else if (role === "system" || role === "developer") {
|
|
198
|
-
parts.push(`<<System update>>\n${contentToText(msg.content)}`);
|
|
199
|
-
} else {
|
|
200
|
-
parts.push(`${role.toUpperCase()}: ${contentToText(msg.content)}`);
|
|
201
|
-
}
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
const directive = trailingDirective(deltaMessages[deltaMessages.length - 1], tools);
|
|
205
|
-
if (directive) parts.push(directive);
|
|
206
|
-
return parts.join("\n\n");
|
|
207
|
-
}
|
|
@@ -1,29 +1,9 @@
|
|
|
1
|
-
// Validates an OpenAI-compatible chat completion request
|
|
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 "
|
|
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
|
|
63
|
+
* @returns {{ messages: object[], tools: object[], model: string, stream: boolean, includeUsage: boolean, thinkingEnabled: boolean, searchEnabled: boolean }}
|
|
101
64
|
*/
|
|
102
|
-
export function parseChatRequest(body
|
|
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 "
|
|
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
|
|
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"
|
|
101
|
-
return sseFrame(baseChunk(meta, [{ index: 0, delta: {}, finish_reason: finishReason }]
|
|
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`.
|