@mathew-macharia/black-mamba 1.0.4 → 1.0.6
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} +46 -52
- 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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// POST /v1/chat/completions orchestration: parse -> plan -> DeepSeek turn -> stream back -> record node.
|
|
2
|
+
|
|
3
|
+
import { CONFIG } from "../config.js";
|
|
4
|
+
import { UpstreamContextRejectedError, UpstreamError } from "../errors.js";
|
|
5
|
+
import { createLogger } from "../logger.js";
|
|
6
|
+
import { consumeDeepSeekSseStream } from "../deepseek/sse.js";
|
|
7
|
+
import { assistantFingerprint } from "../pi/messages.js";
|
|
8
|
+
import { parseChatRequest } from "../pi/request.js";
|
|
9
|
+
import {
|
|
10
|
+
buildMappingInfo,
|
|
11
|
+
buildUsageStats,
|
|
12
|
+
createCompletionMetadata,
|
|
13
|
+
formatCompletionResponse,
|
|
14
|
+
formatStreamDeltaEvent,
|
|
15
|
+
formatStreamFinishEvent,
|
|
16
|
+
formatStreamReasoningEvent,
|
|
17
|
+
formatStreamRoleEvent,
|
|
18
|
+
formatStreamToolCallArgsEvent,
|
|
19
|
+
formatStreamToolCallStartEvent,
|
|
20
|
+
formatStreamUsageEvent,
|
|
21
|
+
} from "../pi/response.js";
|
|
22
|
+
import { createToolCallStreamProcessor, parseToolCallsFromText } from "../pi/tool-calls.js";
|
|
23
|
+
import { STRATEGY, planCompletion, reseedAfterUpstreamRejection } from "../session/planner.js";
|
|
24
|
+
import { estimateTokenCount } from "../utils/tokens.js";
|
|
25
|
+
import { readJsonBody, sendJson, startSse, writeSse } from "./http.js";
|
|
26
|
+
|
|
27
|
+
const STRATEGY_HEADER = "x-black-mamba-strategy";
|
|
28
|
+
|
|
29
|
+
function describePlan(plan, routing) {
|
|
30
|
+
const pi = routing ? `${routing.sessionId}/${routing.entryId}` : "stateless";
|
|
31
|
+
const parent = plan.parentNode ? plan.parentNode.piEntryId : "-";
|
|
32
|
+
const ds = plan.deepseek.sessionId ? `${plan.deepseek.sessionId}:${plan.deepseek.parentMessageId ?? "root"}` : "new-chat";
|
|
33
|
+
return `pi=${pi} parent=${parent} strategy=${plan.strategy}${plan.reason ? ` reason=${plan.reason}` : ""} ds=${ds} delta=${plan.deltaMessageCount} prompt_chars=${plan.prompt.length}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} deps
|
|
38
|
+
* @param {ReturnType<import("../deepseek/client.js").createDeepSeekClient>} deps.deepseek
|
|
39
|
+
* @param {import("../session/graph.js").SessionGraph} deps.graph
|
|
40
|
+
* @param {ReturnType<import("./pacing.js").createPacer>} deps.pacer
|
|
41
|
+
*/
|
|
42
|
+
export function createCompletionHandler({ deepseek, graph, pacer, logger = createLogger("Mapping"), keepAliveIntervalMs = CONFIG.server.keepAliveIntervalMs }) {
|
|
43
|
+
async function openStream(plan, request, signal) {
|
|
44
|
+
const dsSessionId = plan.deepseek.sessionId ?? (await deepseek.createChatSession());
|
|
45
|
+
const { streamBody } = await deepseek.openCompletionStream({
|
|
46
|
+
chatSessionId: dsSessionId,
|
|
47
|
+
parentMessageId: plan.deepseek.parentMessageId,
|
|
48
|
+
prompt: plan.prompt,
|
|
49
|
+
thinkingEnabled: request.thinkingEnabled,
|
|
50
|
+
searchEnabled: request.searchEnabled,
|
|
51
|
+
signal,
|
|
52
|
+
});
|
|
53
|
+
return { dsSessionId, streamBody };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function openWithExplicitRecovery(plan, request, signal) {
|
|
57
|
+
try {
|
|
58
|
+
return { plan, ...(await openStream(plan, request, signal)) };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (!(err instanceof UpstreamContextRejectedError) || plan.strategy !== STRATEGY.DELTA) throw err;
|
|
61
|
+
logger.warn(`DeepSeek rejected chat ${plan.deepseek.sessionId} parent ${plan.deepseek.parentMessageId}: ${err.message}. Re-seeding this branch into a fresh chat.`);
|
|
62
|
+
const reseeded = reseedAfterUpstreamRejection(request, plan, err.details);
|
|
63
|
+
logger.info(describePlan(reseeded, request.routing));
|
|
64
|
+
return { plan: reseeded, ...(await openStream(reseeded, request, signal)) };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveTurnTokens(result, plan, replyText) {
|
|
69
|
+
if (result.turnTokens > 0) return { turnTokens: result.turnTokens, source: "deepseek" };
|
|
70
|
+
return { turnTokens: estimateTokenCount(plan.prompt) + estimateTokenCount(replyText), source: "estimated" };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function recordNode({ plan, dsSessionId, result, text, toolCalls, turnTokens }) {
|
|
74
|
+
if (!plan.record) {
|
|
75
|
+
if (result.messageId === null || result.messageId === undefined) {
|
|
76
|
+
logger.warn("DeepSeek stream ended without response_message_id (stateless request; nothing to record).");
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
if (result.messageId === null || result.messageId === undefined) {
|
|
81
|
+
throw new UpstreamError("DeepSeek did not return a response_message_id; this turn cannot be mapped onto the session tree and was not recorded.", {
|
|
82
|
+
code: "deepseek_missing_message_id",
|
|
83
|
+
details: { piEntryId: plan.record.piEntryId, deepseekSessionId: dsSessionId },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const node = {
|
|
87
|
+
piEntryId: plan.record.piEntryId,
|
|
88
|
+
piParentEntryId: plan.record.piParentEntryId,
|
|
89
|
+
strategy: plan.strategy,
|
|
90
|
+
reason: plan.reason,
|
|
91
|
+
deepseek: { sessionId: dsSessionId, messageId: result.messageId, parentMessageId: plan.deepseek.parentMessageId },
|
|
92
|
+
context: { ...plan.record.context, assistant: assistantFingerprint({ text, toolCalls }) },
|
|
93
|
+
usage: { turnTokens, cachedTokens: plan.cachedTokens },
|
|
94
|
+
createdAt: Date.now(),
|
|
95
|
+
};
|
|
96
|
+
await graph.putNode(plan.session, node);
|
|
97
|
+
logger.info(`recorded pi=${plan.session.piSessionId}/${node.piEntryId} -> ds=${dsSessionId}:${result.messageId} (parent ${node.deepseek.parentMessageId ?? "root"}, ${turnTokens} turn tokens)`);
|
|
98
|
+
return node;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function mappingInfo({ request, plan, dsSessionId, messageId, usageSource }) {
|
|
102
|
+
const info = buildMappingInfo({
|
|
103
|
+
sessionId: request.routing?.sessionId ?? dsSessionId,
|
|
104
|
+
deepseekSessionId: dsSessionId,
|
|
105
|
+
messageId,
|
|
106
|
+
parentMessageId: plan.deepseek.parentMessageId,
|
|
107
|
+
strategy: plan.strategy,
|
|
108
|
+
reason: plan.reason,
|
|
109
|
+
deltaMessageCount: plan.deltaMessageCount,
|
|
110
|
+
});
|
|
111
|
+
info.black_mamba.usage_source = usageSource;
|
|
112
|
+
return info;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function respondJson({ res, request, plan, dsSessionId, streamBody, meta }) {
|
|
116
|
+
const result = await consumeDeepSeekSseStream(streamBody);
|
|
117
|
+
let text = result.reply;
|
|
118
|
+
let toolCalls = [];
|
|
119
|
+
if (request.tools.length > 0) {
|
|
120
|
+
const parsed = parseToolCallsFromText(result.reply);
|
|
121
|
+
text = parsed.cleanText;
|
|
122
|
+
toolCalls = parsed.toolCalls;
|
|
123
|
+
}
|
|
124
|
+
const { turnTokens, source } = resolveTurnTokens(result, plan, result.reply);
|
|
125
|
+
const fingerprintCalls = toolCalls.map((tc) => ({ name: tc.function.name, argumentsString: tc.function.arguments }));
|
|
126
|
+
await recordNode({ plan, dsSessionId, result, text, toolCalls: fingerprintCalls, turnTokens });
|
|
127
|
+
|
|
128
|
+
const usage = buildUsageStats({ prompt: plan.prompt, reply: result.reply, turnTokens, cachedTokens: plan.cachedTokens });
|
|
129
|
+
const payload = formatCompletionResponse({
|
|
130
|
+
...meta,
|
|
131
|
+
model: request.model,
|
|
132
|
+
content: text,
|
|
133
|
+
toolCalls,
|
|
134
|
+
usage,
|
|
135
|
+
mapping: mappingInfo({ request, plan, dsSessionId, messageId: result.messageId, usageSource: source }),
|
|
136
|
+
});
|
|
137
|
+
sendJson(res, 200, payload, { [STRATEGY_HEADER]: plan.strategy });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function respondStream({ req, res, request, plan, dsSessionId, streamBody, meta }) {
|
|
141
|
+
startSse(res, req, { [STRATEGY_HEADER]: plan.strategy });
|
|
142
|
+
writeSse(res, formatStreamRoleEvent(meta));
|
|
143
|
+
const keepAlive = setInterval(() => writeSse(res, ": keep-alive\n\n"), keepAliveIntervalMs);
|
|
144
|
+
keepAlive.unref?.();
|
|
145
|
+
|
|
146
|
+
let emittedText = "";
|
|
147
|
+
const emittedToolCalls = [];
|
|
148
|
+
const emitText = (content) => {
|
|
149
|
+
emittedText += content;
|
|
150
|
+
writeSse(res, formatStreamDeltaEvent(meta, content));
|
|
151
|
+
};
|
|
152
|
+
const emitToolCall = ({ index, id, name, argumentsString }) => {
|
|
153
|
+
emittedToolCalls.push({ name, argumentsString });
|
|
154
|
+
writeSse(res, formatStreamToolCallStartEvent(meta, { toolCallIndex: index, id, name }));
|
|
155
|
+
writeSse(res, formatStreamToolCallArgsEvent(meta, { toolCallIndex: index, argumentsChunk: argumentsString }));
|
|
156
|
+
};
|
|
157
|
+
const processor = request.tools.length > 0 ? createToolCallStreamProcessor({ onTextDelta: emitText, onToolCall: emitToolCall }) : null;
|
|
158
|
+
|
|
159
|
+
let result;
|
|
160
|
+
try {
|
|
161
|
+
result = await consumeDeepSeekSseStream(streamBody, (chunk) => {
|
|
162
|
+
if (chunk.isThinking) writeSse(res, formatStreamReasoningEvent(meta, chunk.text));
|
|
163
|
+
else if (processor) processor.processChunk(chunk.text);
|
|
164
|
+
else emitText(chunk.text);
|
|
165
|
+
});
|
|
166
|
+
processor?.flush();
|
|
167
|
+
} finally {
|
|
168
|
+
clearInterval(keepAlive);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { turnTokens, source } = resolveTurnTokens(result, plan, result.reply);
|
|
172
|
+
await recordNode({ plan, dsSessionId, result, text: emittedText, toolCalls: emittedToolCalls, turnTokens });
|
|
173
|
+
|
|
174
|
+
writeSse(
|
|
175
|
+
res,
|
|
176
|
+
formatStreamFinishEvent(meta, {
|
|
177
|
+
finishReason: emittedToolCalls.length > 0 ? "tool_calls" : "stop",
|
|
178
|
+
mapping: mappingInfo({ request, plan, dsSessionId, messageId: result.messageId, usageSource: source }),
|
|
179
|
+
})
|
|
180
|
+
);
|
|
181
|
+
if (request.includeUsage) {
|
|
182
|
+
writeSse(res, formatStreamUsageEvent(meta, buildUsageStats({ prompt: plan.prompt, reply: result.reply, turnTokens, cachedTokens: plan.cachedTokens })));
|
|
183
|
+
}
|
|
184
|
+
writeSse(res, "data: [DONE]\n\n");
|
|
185
|
+
res.end();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return async function handleChatCompletions(req, res) {
|
|
189
|
+
const body = await readJsonBody(req);
|
|
190
|
+
const request = parseChatRequest(body, req.headers);
|
|
191
|
+
const initialPlan = await planCompletion({ request, graph, logger });
|
|
192
|
+
logger.info(describePlan(initialPlan, request.routing));
|
|
193
|
+
|
|
194
|
+
// Abort the DeepSeek turn if pi disconnects mid-stream (pi marks its assistant message as aborted and drops it).
|
|
195
|
+
const abortController = new AbortController();
|
|
196
|
+
let finished = false;
|
|
197
|
+
if (typeof res.on === "function") {
|
|
198
|
+
res.on("close", () => {
|
|
199
|
+
if (!finished && !res.writableFinished) abortController.abort();
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
await pacer.apply();
|
|
204
|
+
try {
|
|
205
|
+
const { plan, dsSessionId, streamBody } = await openWithExplicitRecovery(initialPlan, request, abortController.signal);
|
|
206
|
+
const meta = { ...createCompletionMetadata(), model: request.model };
|
|
207
|
+
if (request.stream) await respondStream({ req, res, request, plan, dsSessionId, streamBody, meta });
|
|
208
|
+
else await respondJson({ res, request, plan, dsSessionId, streamBody, meta });
|
|
209
|
+
} finally {
|
|
210
|
+
finished = true;
|
|
211
|
+
pacer.recordCompletionFinished();
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -1,18 +1,17 @@
|
|
|
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 "../pi/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, X-Session-Id, X-Entry-Id, X-Parent-Id, X-Parent-Session-Id";
|
|
11
11
|
|
|
12
12
|
export function validateApiKey(req, apiKey) {
|
|
13
|
-
if (!apiKey) return true; // if no apiKey configured, allow all
|
|
14
13
|
const authHeader = req.headers["authorization"];
|
|
15
|
-
if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) return false;
|
|
14
|
+
if (typeof authHeader !== "string" || !authHeader.startsWith("Bearer ") || !apiKey) return false;
|
|
16
15
|
const expected = Buffer.from(apiKey, "utf8");
|
|
17
16
|
const provided = Buffer.from(authHeader.slice("Bearer ".length), "utf8");
|
|
18
17
|
return expected.length === provided.length && crypto.timingSafeEqual(expected, provided);
|
|
@@ -69,7 +68,7 @@ export function errorBody(err, sanitize) {
|
|
|
69
68
|
return { message: sanitize(err instanceof Error ? err.message : "Internal server error"), code: "internal_error" };
|
|
70
69
|
}
|
|
71
70
|
|
|
72
|
-
async function routeAuthed(req, res, pathname, { handleChatCompletions }) {
|
|
71
|
+
async function routeAuthed(req, res, pathname, { handleChatCompletions, graph }) {
|
|
73
72
|
if (req.method === "GET" && pathname === "/v1/models") {
|
|
74
73
|
const created = Math.floor(Date.now() / 1000);
|
|
75
74
|
sendJson(res, 200, { object: "list", data: SUPPORTED_MODEL_IDS.map((id) => ({ id, object: "model", created, owned_by: "deepseek" })) });
|
|
@@ -80,22 +79,41 @@ async function routeAuthed(req, res, pathname, { handleChatCompletions }) {
|
|
|
80
79
|
return;
|
|
81
80
|
}
|
|
82
81
|
|
|
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
|
+
|
|
83
101
|
sendJson(res, 404, { error: { message: "Route not found", code: "not_found" } });
|
|
84
102
|
}
|
|
85
103
|
|
|
86
104
|
/**
|
|
87
105
|
* @param {object} deps
|
|
88
|
-
* @param {(req, res) => Promise<void>} deps.handleChatCompletions
|
|
106
|
+
* @param {(req, res) => Promise<void>} [deps.handleChatCompletions]
|
|
89
107
|
* @param {() => object} [deps.getStatus]
|
|
108
|
+
* @param {import("../session/graph.js").SessionGraph} [deps.graph]
|
|
90
109
|
* @param {(err: unknown) => string} deps.sanitize
|
|
91
|
-
* @param {string} [deps.
|
|
92
|
-
* @param {object} [deps.logger]
|
|
110
|
+
* @param {(authorizationHeader: string) => object|null} [deps.resolveAccount]
|
|
93
111
|
*/
|
|
94
|
-
export function createRequestHandler({ handleChatCompletions, getStatus
|
|
112
|
+
export function createRequestHandler({ handleChatCompletions, getStatus, graph, sanitize, apiKey = "", logger = createLogger("Server"), resolveAccount = null }) {
|
|
95
113
|
return async function handleRequest(req, res) {
|
|
96
114
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
97
115
|
res.setHeader("Access-Control-Allow-Headers", CORS_HEADERS);
|
|
98
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
116
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
|
99
117
|
|
|
100
118
|
if (req.method === "OPTIONS") {
|
|
101
119
|
res.writeHead(204);
|
|
@@ -104,8 +122,24 @@ export function createRequestHandler({ handleChatCompletions, getStatus = () =>
|
|
|
104
122
|
}
|
|
105
123
|
|
|
106
124
|
const pathname = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`).pathname;
|
|
125
|
+
let requestLog = logger;
|
|
107
126
|
|
|
108
127
|
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
|
+
|
|
109
143
|
if (req.method === "GET" && pathname === "/") {
|
|
110
144
|
sendJson(res, 200, getStatus());
|
|
111
145
|
return;
|
|
@@ -114,12 +148,12 @@ export function createRequestHandler({ handleChatCompletions, getStatus = () =>
|
|
|
114
148
|
sendJson(res, 401, { error: { message: "Invalid API key", code: "invalid_api_key" } });
|
|
115
149
|
return;
|
|
116
150
|
}
|
|
117
|
-
await routeAuthed(req, res, pathname, { handleChatCompletions });
|
|
151
|
+
await routeAuthed(req, res, pathname, { handleChatCompletions, graph });
|
|
118
152
|
} catch (err) {
|
|
119
153
|
const statusCode = err instanceof HttpError ? err.statusCode : 500;
|
|
120
154
|
const body = errorBody(err, sanitize);
|
|
121
|
-
if (statusCode >= 500)
|
|
122
|
-
else
|
|
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);
|
|
123
157
|
|
|
124
158
|
if (!res.headersSent) {
|
|
125
159
|
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;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Session graph: the mapping between pi's session tree and DeepSeek's chat trees.
|
|
2
|
+
//
|
|
3
|
+
// One Session per pi session id. One Node per LLM request pi made, keyed by the pi entry id at which the
|
|
4
|
+
// request was made (x-entry-id). Each node records which DeepSeek chat session and message it produced and a
|
|
5
|
+
// fingerprint of the context that produced it. DeepSeek message ids are never used as keys because they
|
|
6
|
+
// restart in every DeepSeek chat session.
|
|
7
|
+
|
|
8
|
+
import { CONFIG } from "../config.js";
|
|
9
|
+
import { createLogger } from "../logger.js";
|
|
10
|
+
import { SESSION_SCHEMA_VERSION, SessionStore } from "./store.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} Node
|
|
14
|
+
* @property {string} piEntryId pi entry id at which the request was made
|
|
15
|
+
* @property {string|null} piParentEntryId pi entry id of the node this request attached to (null for seeds)
|
|
16
|
+
* @property {string} strategy how the node was created (root_seed | delta | reseed_* ...)
|
|
17
|
+
* @property {string|null} reason
|
|
18
|
+
* @property {{ sessionId: string, messageId: number|string, parentMessageId: number|string|null }} deepseek
|
|
19
|
+
* @property {{ messageCount: number, systemHash: string, prefixHash: string, assistant: { textHash: string, toolCalls: {name:string, argsHash:string}[] } }} context
|
|
20
|
+
* @property {{ turnTokens: number, cachedTokens: number }} usage
|
|
21
|
+
* @property {number} createdAt
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export class SessionGraph {
|
|
25
|
+
constructor({ sessionsDir = CONFIG.sessions.dir, ttlMs = CONFIG.sessions.ttlMs, maxEntries = CONFIG.sessions.maxEntries, logger = createLogger("SessionGraph") } = {}) {
|
|
26
|
+
this.logger = logger;
|
|
27
|
+
this.ttlMs = ttlMs;
|
|
28
|
+
this.maxEntries = maxEntries;
|
|
29
|
+
this.store = new SessionStore({ dir: sessionsDir, ttlMs, logger });
|
|
30
|
+
this.sessions = new Map(); // piSessionId -> Session
|
|
31
|
+
this.cleanupTimer = null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async init() {
|
|
35
|
+
await this.store.init();
|
|
36
|
+
await this.pruneExpired();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async getSession(piSessionId) {
|
|
40
|
+
if (!piSessionId) return null;
|
|
41
|
+
const key = String(piSessionId);
|
|
42
|
+
let session = this.sessions.get(key);
|
|
43
|
+
if (!session) {
|
|
44
|
+
session = await this.store.read(key);
|
|
45
|
+
if (session) this.sessions.set(key, session);
|
|
46
|
+
}
|
|
47
|
+
if (session) session.lastAccessedAt = Date.now();
|
|
48
|
+
return session ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async createSession(piSessionId) {
|
|
52
|
+
const key = String(piSessionId);
|
|
53
|
+
if (!key) throw new Error("piSessionId is required");
|
|
54
|
+
const existing = await this.getSession(key);
|
|
55
|
+
if (existing) return existing;
|
|
56
|
+
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const session = {
|
|
59
|
+
schemaVersion: SESSION_SCHEMA_VERSION,
|
|
60
|
+
piSessionId: key,
|
|
61
|
+
createdAt: now,
|
|
62
|
+
lastAccessedAt: now,
|
|
63
|
+
forkedFrom: null,
|
|
64
|
+
nodes: {},
|
|
65
|
+
};
|
|
66
|
+
this.sessions.set(key, session);
|
|
67
|
+
await this.store.write(key, session);
|
|
68
|
+
return session;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
getNode(session, piEntryId) {
|
|
72
|
+
if (!session || piEntryId === null || piEntryId === undefined) return null;
|
|
73
|
+
return session.nodes[String(piEntryId)] ?? null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
nodeCount(session) {
|
|
77
|
+
return session ? Object.keys(session.nodes).length : 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Records (or replaces) the node for a pi entry id and persists the session. */
|
|
81
|
+
async putNode(session, node) {
|
|
82
|
+
if (!node?.piEntryId) throw new Error("node.piEntryId is required");
|
|
83
|
+
const key = String(node.piEntryId);
|
|
84
|
+
if (session.nodes[key]) {
|
|
85
|
+
this.logger.warn(`Node ${key} in session ${session.piSessionId} already existed and was replaced (pi re-used a leaf entry id).`);
|
|
86
|
+
}
|
|
87
|
+
session.nodes[key] = node;
|
|
88
|
+
session.lastAccessedAt = Date.now();
|
|
89
|
+
await this.store.write(session.piSessionId, session);
|
|
90
|
+
return node;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Copies every node of `source` into `target` (used when pi forks a session; entry ids are preserved by pi). */
|
|
94
|
+
async adoptNodes(target, source) {
|
|
95
|
+
for (const [key, node] of Object.entries(source.nodes)) {
|
|
96
|
+
if (!target.nodes[key]) target.nodes[key] = node;
|
|
97
|
+
}
|
|
98
|
+
target.forkedFrom = { piSessionId: source.piSessionId, adoptedAt: Date.now() };
|
|
99
|
+
target.lastAccessedAt = Date.now();
|
|
100
|
+
await this.store.write(target.piSessionId, target);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Tokens DeepSeek already holds in context when attaching to `node`: the node's own turn plus every
|
|
105
|
+
* ancestor turn in the same DeepSeek chat session.
|
|
106
|
+
*/
|
|
107
|
+
branchCachedTokens(session, node) {
|
|
108
|
+
let total = 0;
|
|
109
|
+
const visited = new Set();
|
|
110
|
+
let current = node;
|
|
111
|
+
while (current && !visited.has(current.piEntryId)) {
|
|
112
|
+
visited.add(current.piEntryId);
|
|
113
|
+
if (current.deepseek.sessionId !== node.deepseek.sessionId) break;
|
|
114
|
+
total += current.usage?.turnTokens || 0;
|
|
115
|
+
current = current.piParentEntryId ? this.getNode(session, current.piParentEntryId) : null;
|
|
116
|
+
}
|
|
117
|
+
return total;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async deleteSession(piSessionId) {
|
|
121
|
+
const key = String(piSessionId);
|
|
122
|
+
const existed = this.sessions.delete(key);
|
|
123
|
+
await this.store.remove(key);
|
|
124
|
+
return existed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async pruneExpired(now = Date.now()) {
|
|
128
|
+
for (const [key, session] of this.sessions.entries()) {
|
|
129
|
+
if (now - (session.lastAccessedAt || 0) > this.ttlMs) {
|
|
130
|
+
this.sessions.delete(key);
|
|
131
|
+
await this.store.remove(key);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
await this.store.pruneOlderThan(now);
|
|
135
|
+
|
|
136
|
+
if (this.sessions.size > this.maxEntries) {
|
|
137
|
+
const sorted = Array.from(this.sessions.entries()).sort(([, a], [, b]) => (a.lastAccessedAt || 0) - (b.lastAccessedAt || 0));
|
|
138
|
+
for (const [key] of sorted.slice(0, this.sessions.size - this.maxEntries)) {
|
|
139
|
+
// Evict from memory only; the file stays until its TTL so the session can be reloaded.
|
|
140
|
+
this.sessions.delete(key);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
startPeriodicPrune(intervalMs = 60 * 60 * 1000) {
|
|
146
|
+
this.stopPeriodicPrune();
|
|
147
|
+
this.cleanupTimer = setInterval(() => {
|
|
148
|
+
this.pruneExpired().catch((err) => this.logger.warn("Periodic prune error:", err.message));
|
|
149
|
+
}, intervalMs);
|
|
150
|
+
this.cleanupTimer.unref?.();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
stopPeriodicPrune() {
|
|
154
|
+
if (this.cleanupTimer) {
|
|
155
|
+
clearInterval(this.cleanupTimer);
|
|
156
|
+
this.cleanupTimer = null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Read-only view for the inspection endpoint. */
|
|
161
|
+
describeSession(session) {
|
|
162
|
+
return {
|
|
163
|
+
piSessionId: session.piSessionId,
|
|
164
|
+
createdAt: new Date(session.createdAt).toISOString(),
|
|
165
|
+
lastAccessedAt: new Date(session.lastAccessedAt).toISOString(),
|
|
166
|
+
forkedFrom: session.forkedFrom,
|
|
167
|
+
nodeCount: this.nodeCount(session),
|
|
168
|
+
nodes: Object.values(session.nodes)
|
|
169
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
170
|
+
.map((n) => ({
|
|
171
|
+
piEntryId: n.piEntryId,
|
|
172
|
+
piParentEntryId: n.piParentEntryId,
|
|
173
|
+
strategy: n.strategy,
|
|
174
|
+
reason: n.reason,
|
|
175
|
+
deepseek: n.deepseek,
|
|
176
|
+
messageCount: n.context.messageCount,
|
|
177
|
+
turnTokens: n.usage.turnTokens,
|
|
178
|
+
createdAt: new Date(n.createdAt).toISOString(),
|
|
179
|
+
})),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|