@mathew-macharia/black-mamba 1.0.4 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -7
- package/docs/accounts.md +37 -0
- package/docs/config.md +40 -0
- package/docs/http.md +171 -0
- package/docs/install.md +68 -0
- package/docs/mapping.md +11 -0
- package/docs/overview.md +25 -0
- package/index.js +19 -38
- package/package.json +1 -1
- package/src/accounts.js +24 -83
- package/src/config.js +9 -1
- package/src/deepseek/auth-cli.js +119 -0
- package/src/deepseek/classify.js +15 -0
- package/src/deepseek/client.js +15 -9
- package/src/errors.js +11 -0
- package/src/pi/messages.js +130 -0
- package/src/{prompt.js → pi/prompt.js} +44 -50
- package/src/{request.js → pi/request.js} +42 -4
- package/src/{response.js → pi/response.js} +21 -4
- package/src/server/completions.js +214 -0
- package/src/{http.js → server/http.js} +50 -16
- package/src/{pacing.js → server/pacing.js} +1 -1
- 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/completions.js +0 -149
- /package/src/{tool-calls.js → pi/tool-calls.js} +0 -0
- /package/src/{sanitize.js → utils/sanitize.js} +0 -0
- /package/src/{tokens.js → utils/tokens.js} +0 -0
package/src/config.js
CHANGED
|
@@ -30,7 +30,6 @@ export function loadConfig(env = process.env, rootDir = ROOT_DIR) {
|
|
|
30
30
|
return Object.freeze({
|
|
31
31
|
rootDir,
|
|
32
32
|
port: readNumber(env.PORT, 3000),
|
|
33
|
-
apiKey: (env.API_KEY || env.BM_API_KEY || "").trim(),
|
|
34
33
|
debug: env.BM_DEBUG === "1" || env.BM_DEBUG === "true",
|
|
35
34
|
|
|
36
35
|
deepseek: Object.freeze({
|
|
@@ -45,6 +44,9 @@ export function loadConfig(env = process.env, rootDir = ROOT_DIR) {
|
|
|
45
44
|
healthCheckIntervalMs: 30 * 60 * 1000,
|
|
46
45
|
estimatedWafTtlMs: 4 * 24 * 60 * 60 * 1000,
|
|
47
46
|
estimatedSessionTtlMs: 30 * 24 * 60 * 60 * 1000,
|
|
47
|
+
// DeepSeek biz codes that mean "the chat session / parent message you referenced no longer exists".
|
|
48
|
+
// Only these (plus a narrow message pattern) trigger an explicit re-seed of a stateful branch.
|
|
49
|
+
staleContextBizCodes: readList(env.DEEPSEEK_STALE_CONTEXT_BIZ_CODES).map(Number),
|
|
48
50
|
}),
|
|
49
51
|
|
|
50
52
|
server: Object.freeze({
|
|
@@ -52,6 +54,12 @@ export function loadConfig(env = process.env, rootDir = ROOT_DIR) {
|
|
|
52
54
|
keepAliveIntervalMs: 10_000,
|
|
53
55
|
}),
|
|
54
56
|
|
|
57
|
+
sessions: Object.freeze({
|
|
58
|
+
dir: path.join(rootDir, ".sessions"),
|
|
59
|
+
ttlMs: 14 * 24 * 60 * 60 * 1000,
|
|
60
|
+
maxEntries: 1000,
|
|
61
|
+
}),
|
|
62
|
+
|
|
55
63
|
pacing: Object.freeze({
|
|
56
64
|
minTurnDelayMs: readNumber(env.MIN_TURN_DELAY_MS, 800),
|
|
57
65
|
maxTurnDelayMs: readNumber(env.MAX_TURN_DELAY_MS, 2000),
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Terminal interaction for loading DeepSeek credentials and keeping them healthy.
|
|
2
|
+
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import { CONFIG } from "../config.js";
|
|
5
|
+
import { createLogger } from "../logger.js";
|
|
6
|
+
import { parseCurlCommand } from "./curl-parser.js";
|
|
7
|
+
|
|
8
|
+
const log = createLogger("Auth");
|
|
9
|
+
|
|
10
|
+
export function readCurlFromTerminal(promptLabel, { input = process.stdin, output = process.stdout } = {}) {
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
const rl = readline.createInterface({ input, output, terminal: Boolean(input.isTTY) });
|
|
13
|
+
console.log(`\n${promptLabel}`);
|
|
14
|
+
console.log(" (Chrome DevTools -> Network -> right-click 'completion' -> Copy as cURL or Copy as PowerShell, paste below, then press Enter)\n");
|
|
15
|
+
|
|
16
|
+
const lines = [];
|
|
17
|
+
let resolved = false;
|
|
18
|
+
const finish = () => {
|
|
19
|
+
if (resolved) return;
|
|
20
|
+
resolved = true;
|
|
21
|
+
rl.close();
|
|
22
|
+
resolve(lines.join("\n"));
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
rl.on("line", (line) => {
|
|
26
|
+
const trimmed = line.trim();
|
|
27
|
+
if (trimmed === "" && lines.length > 0) {
|
|
28
|
+
const prev = lines[lines.length - 1].trim();
|
|
29
|
+
if (!prev.endsWith("^") && !prev.endsWith("\\") && !prev.endsWith("`")) {
|
|
30
|
+
finish();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (trimmed !== "" || lines.length > 0) lines.push(line);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
rl.on("close", () => {
|
|
38
|
+
if (resolved) return;
|
|
39
|
+
resolved = true;
|
|
40
|
+
if (lines.length === 0) reject(new Error("Input stream closed before receiving cURL command"));
|
|
41
|
+
else resolve(lines.join("\n"));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Owns the interactive credential lifecycle: initial paste, re-prompt on expiry, periodic health checks.
|
|
48
|
+
* @param {{ auth: import("./auth.js").DeepSeekAuth, fetchImpl?: typeof fetch, sanitize: (err: unknown) => string, port?: number }} deps
|
|
49
|
+
*/
|
|
50
|
+
export function createAuthSupervisor({ auth, fetchImpl = fetch, sanitize, port = CONFIG.port, config = CONFIG.deepseek }) {
|
|
51
|
+
let promptInProgress = false;
|
|
52
|
+
let healthTimer = null;
|
|
53
|
+
|
|
54
|
+
function printSummary() {
|
|
55
|
+
const summary = auth.statusSummary();
|
|
56
|
+
log.info(`Session active | Bearer token: ${auth.token ? "yes" : "no"} | Cookies in RAM: ${auth.cookies.size}`);
|
|
57
|
+
if (summary.next_due_expiry) {
|
|
58
|
+
log.info(`Earliest cookie due: "${summary.next_due_expiry.cookie}" in ${summary.next_due_expiry.remaining}`);
|
|
59
|
+
}
|
|
60
|
+
log.info(`Live expiry details available anytime at GET http://localhost:${port}/`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function promptAndLoad(promptLabel = "📋 Paste your DeepSeek cURL command:") {
|
|
64
|
+
if (promptInProgress) return;
|
|
65
|
+
promptInProgress = true;
|
|
66
|
+
try {
|
|
67
|
+
while (true) {
|
|
68
|
+
const rawCurl = await readCurlFromTerminal(promptLabel);
|
|
69
|
+
try {
|
|
70
|
+
auth.applyParsedSession(parseCurlCommand(rawCurl));
|
|
71
|
+
await auth.verify(fetchImpl);
|
|
72
|
+
printSummary();
|
|
73
|
+
return;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
log.error(`❌ ${sanitize(err)}`);
|
|
76
|
+
if (!process.stdin.isTTY) throw err;
|
|
77
|
+
promptLabel = "📋 Please paste a fresh, valid DeepSeek cURL command:";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
promptInProgress = false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function reprompt(reason) {
|
|
86
|
+
auth.markInvalid();
|
|
87
|
+
log.warn(`⚠️ ${reason}`);
|
|
88
|
+
if (process.stdin.isTTY && !promptInProgress) {
|
|
89
|
+
promptAndLoad("🔄 Session expired! Paste a fresh DeepSeek cURL command to resume:").catch((err) => {
|
|
90
|
+
log.error("Failed to reload session:", sanitize(err));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function startHealthMonitor() {
|
|
96
|
+
stopHealthMonitor();
|
|
97
|
+
healthTimer = setInterval(async () => {
|
|
98
|
+
if (!auth.verified || promptInProgress) return;
|
|
99
|
+
try {
|
|
100
|
+
await auth.verify(fetchImpl);
|
|
101
|
+
const summary = auth.statusSummary();
|
|
102
|
+
const expiryInfo = summary.next_due_expiry ? `${summary.next_due_expiry.cookie} due in ${summary.next_due_expiry.remaining}` : "active";
|
|
103
|
+
log.info(`Health check OK (age: ${summary.session_age}, ${expiryInfo})`);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
reprompt(sanitize(err));
|
|
106
|
+
}
|
|
107
|
+
}, config.healthCheckIntervalMs);
|
|
108
|
+
healthTimer.unref?.();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function stopHealthMonitor() {
|
|
112
|
+
if (healthTimer) {
|
|
113
|
+
clearInterval(healthTimer);
|
|
114
|
+
healthTimer = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return Object.freeze({ promptAndLoad, reprompt, startHealthMonitor, stopHealthMonitor });
|
|
119
|
+
}
|
package/src/deepseek/classify.js
CHANGED
|
@@ -35,3 +35,18 @@ export function deepSeekAuthFacts(res, jsonBody = null) {
|
|
|
35
35
|
}
|
|
36
36
|
return facts;
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* True only when DeepSeek tells us the chat session or parent message we referenced is gone.
|
|
41
|
+
* Deliberately narrow: anything else is a real failure that must surface to the client.
|
|
42
|
+
*/
|
|
43
|
+
export function isStaleContextRejection(res, jsonBody, staleBizCodes = []) {
|
|
44
|
+
if (!(res.status === 400 || res.status === 404 || res.status === 200)) return false;
|
|
45
|
+
const bizCode = jsonBody?.data?.biz_code ?? jsonBody?.code;
|
|
46
|
+
if (typeof bizCode === "number" && staleBizCodes.includes(bizCode)) return true;
|
|
47
|
+
const msg = String(jsonBody?.data?.biz_msg || jsonBody?.msg || "").toLowerCase();
|
|
48
|
+
if (!msg) return false;
|
|
49
|
+
const mentionsContext = /(chat[_ ]?session|parent[_ ]?message|message[_ ]?id)/.test(msg);
|
|
50
|
+
const mentionsGone = /(not\s*found|invalid|expired|deleted|does not exist|not exist)/.test(msg);
|
|
51
|
+
return mentionsContext && mentionsGone;
|
|
52
|
+
}
|
package/src/deepseek/client.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// HTTP client for the DeepSeek web API. Every failure is classified and thrown; nothing is papered over.
|
|
2
2
|
|
|
3
3
|
import { CONFIG } from "../config.js";
|
|
4
|
-
import { DeepSeekAuthError, UpstreamError } from "../errors.js";
|
|
4
|
+
import { DeepSeekAuthError, UpstreamContextRejectedError, UpstreamError } from "../errors.js";
|
|
5
5
|
import {
|
|
6
6
|
deepSeekAuthFacts,
|
|
7
7
|
isDeepSeekAuthFailure,
|
|
8
8
|
isHumanChallengeRequired,
|
|
9
|
+
isStaleContextRejection,
|
|
9
10
|
isWafChallengeResponse,
|
|
10
11
|
upstreamReason,
|
|
11
12
|
} from "./classify.js";
|
|
@@ -95,19 +96,21 @@ export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, confi
|
|
|
95
96
|
}
|
|
96
97
|
|
|
97
98
|
/**
|
|
98
|
-
* Opens a completion stream
|
|
99
|
-
* @param {{ prompt: string, thinkingEnabled: boolean, searchEnabled: boolean,
|
|
99
|
+
* Opens a completion stream. Returns the raw SSE body; decoding lives in sse.js.
|
|
100
|
+
* @param {{ chatSessionId: string, parentMessageId: number|null, prompt: string, thinkingEnabled: boolean, searchEnabled: boolean, signal?: AbortSignal }} params
|
|
100
101
|
*/
|
|
101
|
-
async function openCompletionStream({ prompt, thinkingEnabled, searchEnabled,
|
|
102
|
+
async function openCompletionStream({ chatSessionId, parentMessageId, prompt, thinkingEnabled, searchEnabled, signal }) {
|
|
102
103
|
ensureCredentials();
|
|
103
|
-
|
|
104
|
+
if (typeof chatSessionId !== "string" || !chatSessionId) {
|
|
105
|
+
throw new Error("openCompletionStream requires a DeepSeek chat session id");
|
|
106
|
+
}
|
|
104
107
|
|
|
105
108
|
const powHeader = powSolver.solve(await fetchPowChallenge());
|
|
106
109
|
const res = await postJson(
|
|
107
110
|
config.chatCompletionUrl,
|
|
108
111
|
{
|
|
109
|
-
chat_session_id:
|
|
110
|
-
parent_message_id:
|
|
112
|
+
chat_session_id: chatSessionId,
|
|
113
|
+
parent_message_id: parentMessageId,
|
|
111
114
|
model_type: "default",
|
|
112
115
|
prompt,
|
|
113
116
|
ref_file_ids: [],
|
|
@@ -136,7 +139,10 @@ export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, confi
|
|
|
136
139
|
if (isDeepSeekAuthFailure(res, errJson)) {
|
|
137
140
|
throw authFailure(isHumanChallengeRequired(errJson) ? HUMAN_CHALLENGE_NOTICE : SESSION_EXPIRED_NOTICE, res, errJson);
|
|
138
141
|
}
|
|
139
|
-
const details = { status: res.status, body: errJson ?? rawText.slice(0, 500), chat_session_id:
|
|
142
|
+
const details = { status: res.status, body: errJson ?? rawText.slice(0, 500), chat_session_id: chatSessionId, parent_message_id: parentMessageId };
|
|
143
|
+
if (isStaleContextRejection(res, errJson, config.staleContextBizCodes)) {
|
|
144
|
+
throw new UpstreamContextRejectedError(`DeepSeek rejected the referenced chat context: ${upstreamReason(errJson, rawText, res.status)}`, details);
|
|
145
|
+
}
|
|
140
146
|
throw new UpstreamError(`DeepSeek completion failed: ${upstreamReason(errJson, rawText, res.status)}`, { details });
|
|
141
147
|
}
|
|
142
148
|
|
|
@@ -145,7 +151,7 @@ export function createDeepSeekClient({ auth, powSolver, fetchImpl = fetch, confi
|
|
|
145
151
|
}
|
|
146
152
|
|
|
147
153
|
auth.markVerified();
|
|
148
|
-
return { streamBody: res.body
|
|
154
|
+
return { streamBody: res.body };
|
|
149
155
|
}
|
|
150
156
|
|
|
151
157
|
return Object.freeze({ createChatSession, openCompletionStream, fetchPowChallenge });
|
package/src/errors.js
CHANGED
|
@@ -45,3 +45,14 @@ export class UpstreamError extends HttpError {
|
|
|
45
45
|
this.name = "UpstreamError";
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Raised when DeepSeek explicitly rejected the chat session / parent message we referenced.
|
|
51
|
+
* The completion handler converts this into an explicit, logged re-seed for stateful branches.
|
|
52
|
+
*/
|
|
53
|
+
export class UpstreamContextRejectedError extends UpstreamError {
|
|
54
|
+
constructor(message, details) {
|
|
55
|
+
super(message, { statusCode: 502, code: "deepseek_context_rejected", details });
|
|
56
|
+
this.name = "UpstreamContextRejectedError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Canonical view of OpenAI-format messages and the fingerprints Black Mamba uses to verify that the context pi sends
|
|
2
|
+
// matches the context DeepSeek already holds. Hashes are compared, never used as keys.
|
|
3
|
+
|
|
4
|
+
import { BadRequestError } from "../errors.js";
|
|
5
|
+
import { canonicalJson, sha256Hex, shortHash } from "../utils/hash.js";
|
|
6
|
+
|
|
7
|
+
/** Exact prefix pi uses when it turns a compaction entry into a user message (pi-coding-agent core/messages.js). */
|
|
8
|
+
export const PI_COMPACTION_SUMMARY_PREFIX =
|
|
9
|
+
"The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
|
|
10
|
+
/** Exact prefix pi uses for branch summaries after /tree navigation. */
|
|
11
|
+
export const PI_BRANCH_SUMMARY_PREFIX = "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
|
|
12
|
+
|
|
13
|
+
export function normalizeRole(role) {
|
|
14
|
+
return typeof role === "string" ? role.trim().toLowerCase() : "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Renders message content to text. Non-text parts are made visible as explicit placeholders instead of being dropped.
|
|
19
|
+
*/
|
|
20
|
+
export function contentToText(content) {
|
|
21
|
+
if (content === null || content === undefined) return "";
|
|
22
|
+
if (typeof content === "string") return content;
|
|
23
|
+
if (Array.isArray(content)) {
|
|
24
|
+
return content
|
|
25
|
+
.map((part) => {
|
|
26
|
+
if (!part || typeof part !== "object") return "[unsupported content part]";
|
|
27
|
+
if (part.type === "text") return String(part.text ?? "");
|
|
28
|
+
if (part.type === "image_url" || part.type === "image") return "[image omitted: Black Mamba forwards text only]";
|
|
29
|
+
return `[unsupported content part: ${String(part.type)}]`;
|
|
30
|
+
})
|
|
31
|
+
.join("\n");
|
|
32
|
+
}
|
|
33
|
+
throw new BadRequestError("Message 'content' must be a string or an array of content parts", "invalid_message");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Parses tool-call arguments; returns { value, raw } where raw is kept only when the string is not valid JSON. */
|
|
37
|
+
export function parseToolArguments(args) {
|
|
38
|
+
if (args === undefined || args === null) return { value: {} };
|
|
39
|
+
if (typeof args === "object") return { value: args };
|
|
40
|
+
if (typeof args === "string") {
|
|
41
|
+
try {
|
|
42
|
+
return { value: JSON.parse(args) };
|
|
43
|
+
} catch {
|
|
44
|
+
return { value: null, raw: args };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { value: null, raw: String(args) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function toolCallName(tc) {
|
|
51
|
+
return tc?.function?.name ?? tc?.name ?? "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function toolCallArguments(tc) {
|
|
55
|
+
return tc?.function?.arguments ?? tc?.arguments;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Structural view of a message that ignores presentation-only fields (reasoning_content, name, ids). */
|
|
59
|
+
export function canonicalizeMessage(msg) {
|
|
60
|
+
const role = normalizeRole(msg.role);
|
|
61
|
+
const canonical = { role, content: msg.content === null || msg.content === undefined ? null : contentToText(msg.content) };
|
|
62
|
+
if (role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
|
|
63
|
+
canonical.tool_calls = msg.tool_calls.map((tc) => {
|
|
64
|
+
const parsed = parseToolArguments(toolCallArguments(tc));
|
|
65
|
+
return { name: toolCallName(tc), arguments: parsed.raw !== undefined ? parsed.raw : canonicalJson(parsed.value) };
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (role === "tool") canonical.tool_call_id = msg.tool_call_id ?? null;
|
|
69
|
+
return canonical;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function messageHash(msg) {
|
|
73
|
+
return shortHash(canonicalJson(canonicalizeMessage(msg)));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function hasLeadingSystem(messages) {
|
|
77
|
+
const role = normalizeRole(messages[0]?.role);
|
|
78
|
+
return role === "system" || role === "developer";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Hash of the leading system message (or "none"). The system prompt is verified separately from the conversation body. */
|
|
82
|
+
export function systemHash(messages) {
|
|
83
|
+
return hasLeadingSystem(messages) ? messageHash(messages[0]) : "none";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Chain hash over the conversation body (every message except a leading system message) of the first `count` messages.
|
|
88
|
+
* Order-sensitive, so insertions, omissions and edits anywhere in the prefix are detected.
|
|
89
|
+
*/
|
|
90
|
+
export function prefixHash(messages, count = messages.length) {
|
|
91
|
+
const start = hasLeadingSystem(messages) ? 1 : 0;
|
|
92
|
+
let acc = "bm-v2";
|
|
93
|
+
for (let i = start; i < count; i++) {
|
|
94
|
+
acc = sha256Hex(`${acc}:${messageHash(messages[i])}`);
|
|
95
|
+
}
|
|
96
|
+
return acc.slice(0, 32);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Fingerprint of an assistant reply as Black Mamba produced it (streamed text + emitted tool calls). */
|
|
100
|
+
export function assistantFingerprint({ text, toolCalls = [] }) {
|
|
101
|
+
return {
|
|
102
|
+
textHash: shortHash((text ?? "").trim()),
|
|
103
|
+
toolCalls: toolCalls.map((tc) => {
|
|
104
|
+
const parsed = parseToolArguments(tc.argumentsString ?? tc.arguments);
|
|
105
|
+
return { name: tc.name, argsHash: shortHash(parsed.raw !== undefined ? parsed.raw : canonicalJson(parsed.value)) };
|
|
106
|
+
}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Fingerprint of an assistant message as pi replays it in a later request. */
|
|
111
|
+
export function assistantFingerprintFromMessage(msg) {
|
|
112
|
+
return assistantFingerprint({
|
|
113
|
+
text: contentToText(msg.content),
|
|
114
|
+
toolCalls: Array.isArray(msg.tool_calls) ? msg.tool_calls.map((tc) => ({ name: toolCallName(tc), arguments: toolCallArguments(tc) })) : [],
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function fingerprintsEqual(a, b) {
|
|
119
|
+
if (!a || !b) return false;
|
|
120
|
+
if (a.textHash !== b.textHash || a.toolCalls.length !== b.toolCalls.length) return false;
|
|
121
|
+
return a.toolCalls.every((tc, i) => tc.name === b.toolCalls[i].name && tc.argsHash === b.toolCalls[i].argsHash);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function isCompactionSummaryMessage(msg) {
|
|
125
|
+
return normalizeRole(msg?.role) === "user" && contentToText(msg.content).startsWith(PI_COMPACTION_SUMMARY_PREFIX);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function isBranchSummaryMessage(msg) {
|
|
129
|
+
return normalizeRole(msg?.role) === "user" && contentToText(msg.content).startsWith(PI_BRANCH_SUMMARY_PREFIX);
|
|
130
|
+
}
|
|
@@ -1,56 +1,11 @@
|
|
|
1
1
|
// Renders OpenAI-format messages into the single text prompt DeepSeek web accepts.
|
|
2
|
-
//
|
|
2
|
+
// Two shapes: a *seed* (full history, starts a DeepSeek chat) and a *delta* (only what DeepSeek has not seen yet).
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { contentToText, normalizeRole, parseToolArguments, toolCallArguments, toolCallName } from "./messages.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
|
-
|
|
54
9
|
function toolNames(tools) {
|
|
55
10
|
return (Array.isArray(tools) ? tools : []).map((t) => t.function?.name || t.name).filter(Boolean);
|
|
56
11
|
}
|
|
@@ -158,6 +113,7 @@ function renderDsmlToolBlock(tc) {
|
|
|
158
113
|
return `<|DSML|invoke name="${name}">\n${params}\n</|DSML|invoke>`;
|
|
159
114
|
}
|
|
160
115
|
|
|
116
|
+
/** Assistant text followed by its tool calls rendered as native DSML blocks. */
|
|
161
117
|
export function renderAssistantContent(msg) {
|
|
162
118
|
let content = contentToText(msg.content);
|
|
163
119
|
if (Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
|
|
@@ -185,9 +141,8 @@ function trailingDirective(lastMessage, tools) {
|
|
|
185
141
|
}
|
|
186
142
|
|
|
187
143
|
/**
|
|
188
|
-
* Full-history prompt
|
|
189
|
-
*
|
|
190
|
-
* 2. Without tools (e.g. other apps): Plain transcript (SYSTEM: ..., USER: ..., ASSISTANT: ...).
|
|
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.
|
|
191
146
|
*/
|
|
192
147
|
export function renderSeedPrompt(messages, tools = []) {
|
|
193
148
|
const hasTools = Array.isArray(tools) && tools.length > 0;
|
|
@@ -226,3 +181,42 @@ export function renderSeedPrompt(messages, tools = []) {
|
|
|
226
181
|
if (directive) prompt += `\n${directive}`;
|
|
227
182
|
return prompt;
|
|
228
183
|
}
|
|
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,9 +1,29 @@
|
|
|
1
|
-
// Validates an OpenAI-compatible chat completion request.
|
|
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.
|
|
2
11
|
|
|
3
|
-
import { BadRequestError } from "
|
|
12
|
+
import { BadRequestError } from "../errors.js";
|
|
4
13
|
|
|
5
14
|
const DEFAULT_MODEL = "deepseek-chat";
|
|
6
15
|
|
|
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
|
+
|
|
7
27
|
function validateToolCall(tc, index) {
|
|
8
28
|
if (!tc || typeof tc !== "object") throw new BadRequestError(`tool_calls[${index}] must be an object`, "invalid_tool_call");
|
|
9
29
|
const fn = tc.function;
|
|
@@ -59,10 +79,27 @@ function resolveThinking(body, lowerModel) {
|
|
|
59
79
|
return lowerModel.includes("reasoner") || lowerModel.includes("r1") || lowerModel.includes("think");
|
|
60
80
|
}
|
|
61
81
|
|
|
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
|
+
|
|
62
99
|
/**
|
|
63
|
-
* @returns {{ messages: object[], tools: object[], model: string, stream: boolean, includeUsage: boolean, thinkingEnabled: boolean, searchEnabled: boolean }}
|
|
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 }}
|
|
64
101
|
*/
|
|
65
|
-
export function parseChatRequest(body) {
|
|
102
|
+
export function parseChatRequest(body, headers = {}) {
|
|
66
103
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
67
104
|
throw new BadRequestError("Request body must be a JSON object", "invalid_body");
|
|
68
105
|
}
|
|
@@ -82,5 +119,6 @@ export function parseChatRequest(body) {
|
|
|
82
119
|
includeUsage: Boolean(body.stream_options && body.stream_options.include_usage),
|
|
83
120
|
thinkingEnabled: resolveThinking(body, lowerModel),
|
|
84
121
|
searchEnabled: lowerModel.includes("search") || lowerModel.endsWith(":online"),
|
|
122
|
+
routing: extractRouting(headers),
|
|
85
123
|
});
|
|
86
124
|
}
|
|
@@ -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 "../utils/tokens.js";
|
|
4
4
|
|
|
5
5
|
export function createCompletionMetadata(nowMs = Date.now()) {
|
|
6
6
|
return { completionId: `chatcmpl-${nowMs}`, created: Math.floor(nowMs / 1000) };
|
|
@@ -34,11 +34,27 @@ 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
|
+
|
|
37
53
|
function baseChunk({ completionId, created, model }, choices, extra = {}) {
|
|
38
54
|
return { id: completionId, object: "chat.completion.chunk", created, model, choices, ...extra };
|
|
39
55
|
}
|
|
40
56
|
|
|
41
|
-
export function formatCompletionResponse({ completionId, created, model, content, toolCalls = [], usage }) {
|
|
57
|
+
export function formatCompletionResponse({ completionId, created, model, content, toolCalls = [], usage, mapping }) {
|
|
42
58
|
const hasTools = toolCalls.length > 0;
|
|
43
59
|
const message = { role: "assistant", content: hasTools ? content || null : content };
|
|
44
60
|
if (hasTools) message.tool_calls = toolCalls;
|
|
@@ -49,6 +65,7 @@ export function formatCompletionResponse({ completionId, created, model, content
|
|
|
49
65
|
model,
|
|
50
66
|
choices: [{ index: 0, message, finish_reason: hasTools ? "tool_calls" : "stop" }],
|
|
51
67
|
usage,
|
|
68
|
+
...(mapping ?? {}),
|
|
52
69
|
};
|
|
53
70
|
}
|
|
54
71
|
|
|
@@ -80,8 +97,8 @@ export function formatStreamToolCallArgsEvent(meta, { toolCallIndex, argumentsCh
|
|
|
80
97
|
return sseFrame(baseChunk(meta, [{ index: 0, delta: { tool_calls: [{ index: toolCallIndex, function: { arguments: argumentsChunk } }] }, finish_reason: null }]));
|
|
81
98
|
}
|
|
82
99
|
|
|
83
|
-
export function formatStreamFinishEvent(meta, { finishReason = "stop" } = {}) {
|
|
84
|
-
return sseFrame(baseChunk(meta, [{ index: 0, delta: {}, finish_reason: finishReason }]));
|
|
100
|
+
export function formatStreamFinishEvent(meta, { finishReason = "stop", mapping } = {}) {
|
|
101
|
+
return sseFrame(baseChunk(meta, [{ index: 0, delta: {}, finish_reason: finishReason }], mapping ?? {}));
|
|
85
102
|
}
|
|
86
103
|
|
|
87
104
|
export function formatStreamUsageEvent(meta, usage) {
|