@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,177 @@
|
|
|
1
|
+
// Mapping planner: decides, for one pi request, exactly which DeepSeek chat/parent to attach to and what text to
|
|
2
|
+
// send. Every outcome is a named strategy with a reason. There is no implicit fallback: either the parent node
|
|
3
|
+
// exists and the context pi sent verifiably extends it (-> delta), or DeepSeek has no matching context and a
|
|
4
|
+
// fresh chat is seeded with the full history (-> seed). Both outcomes reproduce pi's context exactly.
|
|
5
|
+
//
|
|
6
|
+
// Strategies
|
|
7
|
+
// stateless_seed no x-session-id: full history into a throwaway DeepSeek chat, nothing recorded
|
|
8
|
+
// root_seed stateful request with no usable parent node (first turn, unknown session, /tree to root)
|
|
9
|
+
// delta parent node found and verified: send only the messages DeepSeek has not seen
|
|
10
|
+
// reseed_context_diverged parent node found but pi's context no longer extends it (compaction, context_edit, ...)
|
|
11
|
+
// reseed_upstream_rejected DeepSeek rejected the stored chat/parent (chat deleted or expired on their side)
|
|
12
|
+
|
|
13
|
+
import { BadRequestError, HttpError } from "../errors.js";
|
|
14
|
+
import {
|
|
15
|
+
assistantFingerprintFromMessage,
|
|
16
|
+
contentToText,
|
|
17
|
+
fingerprintsEqual,
|
|
18
|
+
hasLeadingSystem,
|
|
19
|
+
isCompactionSummaryMessage,
|
|
20
|
+
normalizeRole,
|
|
21
|
+
prefixHash,
|
|
22
|
+
systemHash,
|
|
23
|
+
} from "../pi/messages.js";
|
|
24
|
+
import { renderDeltaPrompt, renderSeedPrompt, renderSystemPrompt } from "../pi/prompt.js";
|
|
25
|
+
|
|
26
|
+
export const STRATEGY = Object.freeze({
|
|
27
|
+
STATELESS_SEED: "stateless_seed",
|
|
28
|
+
ROOT_SEED: "root_seed",
|
|
29
|
+
DELTA: "delta",
|
|
30
|
+
RESEED_DIVERGED: "reseed_context_diverged",
|
|
31
|
+
RESEED_UPSTREAM: "reseed_upstream_rejected",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function contextFingerprint(messages) {
|
|
35
|
+
return { messageCount: messages.length, systemHash: systemHash(messages), prefixHash: prefixHash(messages) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Checks that `messages` is exactly "the parent's request context + the parent's reply + new messages".
|
|
40
|
+
* @returns {{ ok: true } | { ok: false, reason: string, details: object }}
|
|
41
|
+
*/
|
|
42
|
+
export function verifyContextExtendsNode(messages, node) {
|
|
43
|
+
const parentCount = node.context.messageCount;
|
|
44
|
+
const firstBody = hasLeadingSystem(messages) ? 1 : 0;
|
|
45
|
+
// pi replaces the history with a summary message on compaction; the context then no longer extends any earlier node.
|
|
46
|
+
const startsWithCompaction = messages.length > firstBody && isCompactionSummaryMessage(messages[firstBody]);
|
|
47
|
+
|
|
48
|
+
if (messages.length <= parentCount) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
reason: startsWithCompaction ? "compaction" : "context_shorter_than_parent",
|
|
52
|
+
details: { messageCount: messages.length, parentMessageCount: parentCount },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const expectedPrefix = prefixHash(messages, parentCount);
|
|
56
|
+
if (expectedPrefix !== node.context.prefixHash) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
reason: startsWithCompaction ? "compaction" : "context_prefix_changed",
|
|
60
|
+
details: { parentMessageCount: parentCount, expectedPrefixHash: node.context.prefixHash, actualPrefixHash: expectedPrefix },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const reply = messages[parentCount];
|
|
64
|
+
if (normalizeRole(reply.role) !== "assistant") {
|
|
65
|
+
return { ok: false, reason: "parent_reply_missing", details: { indexOfReply: parentCount, roleFound: normalizeRole(reply.role) } };
|
|
66
|
+
}
|
|
67
|
+
if (!fingerprintsEqual(assistantFingerprintFromMessage(reply), node.context.assistant)) {
|
|
68
|
+
return { ok: false, reason: "parent_reply_modified", details: { indexOfReply: parentCount } };
|
|
69
|
+
}
|
|
70
|
+
return { ok: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function seedPlan({ request, strategy, reason, details = null, session = null, parentNode = null }) {
|
|
74
|
+
const { messages, tools, routing } = request;
|
|
75
|
+
return Object.freeze({
|
|
76
|
+
strategy,
|
|
77
|
+
reason,
|
|
78
|
+
details,
|
|
79
|
+
session,
|
|
80
|
+
parentNode,
|
|
81
|
+
prompt: renderSeedPrompt(messages, tools),
|
|
82
|
+
deepseek: { sessionId: null, parentMessageId: null }, // null sessionId => the handler creates a fresh DeepSeek chat
|
|
83
|
+
cachedTokens: 0,
|
|
84
|
+
deltaMessageCount: messages.length,
|
|
85
|
+
record: routing
|
|
86
|
+
? { piEntryId: routing.entryId, piParentEntryId: routing.parentEntryId, context: contextFingerprint(messages) }
|
|
87
|
+
: null,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Resolves the parent node, adopting a forked session's graph when pi tells us where it was forked from. */
|
|
92
|
+
async function resolveParent({ graph, routing, logger }) {
|
|
93
|
+
let session = await graph.getSession(routing.sessionId);
|
|
94
|
+
let parentNode = null;
|
|
95
|
+
let adoptedFrom = null;
|
|
96
|
+
|
|
97
|
+
if (routing.parentEntryId) {
|
|
98
|
+
parentNode = graph.getNode(session, routing.parentEntryId);
|
|
99
|
+
|
|
100
|
+
if (!parentNode && routing.parentSessionId && routing.parentSessionId !== routing.sessionId) {
|
|
101
|
+
const parentSession = await graph.getSession(routing.parentSessionId);
|
|
102
|
+
const candidate = graph.getNode(parentSession, routing.parentEntryId);
|
|
103
|
+
if (candidate) {
|
|
104
|
+
session = session ?? (await graph.createSession(routing.sessionId));
|
|
105
|
+
await graph.adoptNodes(session, parentSession);
|
|
106
|
+
parentNode = graph.getNode(session, routing.parentEntryId);
|
|
107
|
+
adoptedFrom = routing.parentSessionId;
|
|
108
|
+
logger.info(`Fork detected: session ${routing.sessionId} adopted ${graph.nodeCount(parentSession)} node(s) from ${routing.parentSessionId}.`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!session) session = await graph.createSession(routing.sessionId);
|
|
114
|
+
return { session, parentNode, adoptedFrom };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {{ request: ReturnType<import("../pi/request.js").parseChatRequest>, graph: import("./graph.js").SessionGraph, logger: object }} deps
|
|
119
|
+
*/
|
|
120
|
+
export async function planCompletion({ request, graph, logger }) {
|
|
121
|
+
const { messages, tools, routing } = request;
|
|
122
|
+
|
|
123
|
+
if (!routing) {
|
|
124
|
+
return seedPlan({ request, strategy: STRATEGY.STATELESS_SEED, reason: "no_session_routing" });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const { session, parentNode, adoptedFrom } = await resolveParent({ graph, routing, logger });
|
|
128
|
+
|
|
129
|
+
if (!parentNode) {
|
|
130
|
+
let reason = "no_parent_entry";
|
|
131
|
+
if (routing.parentEntryId) reason = graph.nodeCount(session) === 0 ? "session_unknown" : "parent_entry_unknown";
|
|
132
|
+
return seedPlan({ request, strategy: STRATEGY.ROOT_SEED, reason, session, details: { parentEntryId: routing.parentEntryId } });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const verification = verifyContextExtendsNode(messages, parentNode);
|
|
136
|
+
if (!verification.ok) {
|
|
137
|
+
return seedPlan({
|
|
138
|
+
request,
|
|
139
|
+
strategy: STRATEGY.RESEED_DIVERGED,
|
|
140
|
+
reason: verification.reason,
|
|
141
|
+
details: { ...verification.details, parentEntryId: parentNode.piEntryId },
|
|
142
|
+
session,
|
|
143
|
+
parentNode,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const deltaMessages = messages.slice(parentNode.context.messageCount + 1);
|
|
148
|
+
if (deltaMessages.length === 0) {
|
|
149
|
+
throw new HttpError(409, "Request contains no messages after the parent node's reply; nothing to send to DeepSeek.", {
|
|
150
|
+
code: "no_new_messages",
|
|
151
|
+
details: { parentEntryId: parentNode.piEntryId, messageCount: messages.length },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const fingerprint = contextFingerprint(messages);
|
|
156
|
+
const systemChanged = fingerprint.systemHash !== parentNode.context.systemHash;
|
|
157
|
+
const systemUpdate = systemChanged ? renderSystemPrompt(hasLeadingSystem(messages) ? contentToText(messages[0].content) : "", tools) : null;
|
|
158
|
+
|
|
159
|
+
return Object.freeze({
|
|
160
|
+
strategy: STRATEGY.DELTA,
|
|
161
|
+
reason: adoptedFrom ? `forked_from:${adoptedFrom}` : systemChanged ? "system_prompt_changed" : null,
|
|
162
|
+
details: null,
|
|
163
|
+
session,
|
|
164
|
+
parentNode,
|
|
165
|
+
prompt: renderDeltaPrompt(deltaMessages, tools, { systemUpdate }),
|
|
166
|
+
deepseek: { sessionId: parentNode.deepseek.sessionId, parentMessageId: parentNode.deepseek.messageId },
|
|
167
|
+
cachedTokens: graph.branchCachedTokens(session, parentNode),
|
|
168
|
+
deltaMessageCount: deltaMessages.length,
|
|
169
|
+
record: { piEntryId: routing.entryId, piParentEntryId: parentNode.piEntryId, context: fingerprint },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Explicit re-seed after DeepSeek rejected the chat/parent a delta plan referenced. */
|
|
174
|
+
export function reseedAfterUpstreamRejection(request, plan, details) {
|
|
175
|
+
if (!plan.session) throw new BadRequestError("Cannot re-seed a stateless request", "reseed_without_session");
|
|
176
|
+
return seedPlan({ request, strategy: STRATEGY.RESEED_UPSTREAM, reason: "upstream_context_rejected", details, session: plan.session, parentNode: plan.parentNode });
|
|
177
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Disk persistence for session graphs: one JSON file per pi session, atomic writes, TTL pruning.
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { mkdir, readFile, readdir, rename, stat as statFs, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
|
|
8
|
+
export const SESSION_SCHEMA_VERSION = 2;
|
|
9
|
+
|
|
10
|
+
export function sanitizeSessionFilename(sessionId) {
|
|
11
|
+
const safePrefix = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48);
|
|
12
|
+
const hash = crypto.createHash("sha256").update(String(sessionId)).digest("hex").slice(0, 16);
|
|
13
|
+
return `${safePrefix}_${hash}.json`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class SessionStore {
|
|
17
|
+
constructor({ dir, ttlMs, logger }) {
|
|
18
|
+
this.dir = dir;
|
|
19
|
+
this.ttlMs = ttlMs;
|
|
20
|
+
this.logger = logger;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
filePath(sessionId) {
|
|
24
|
+
return path.join(this.dir, sanitizeSessionFilename(sessionId));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async init() {
|
|
28
|
+
if (!existsSync(this.dir)) await mkdir(this.dir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Returns the stored session or null. Expired files are removed. Files written by the pre-v2 schema are
|
|
33
|
+
* ignored (their node keys mixed pi and DeepSeek ids and cannot be trusted); they are reported once and
|
|
34
|
+
* left to TTL pruning.
|
|
35
|
+
*/
|
|
36
|
+
async read(sessionId, now = Date.now()) {
|
|
37
|
+
const filePath = this.filePath(sessionId);
|
|
38
|
+
if (!existsSync(filePath)) return null;
|
|
39
|
+
|
|
40
|
+
let parsed;
|
|
41
|
+
try {
|
|
42
|
+
parsed = JSON.parse(await readFile(filePath, "utf8"));
|
|
43
|
+
} catch (err) {
|
|
44
|
+
this.logger.warn(`Session file ${path.basename(filePath)} is unreadable (${err.message}); treating session as unknown.`);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
48
|
+
|
|
49
|
+
if (parsed.schemaVersion !== SESSION_SCHEMA_VERSION) {
|
|
50
|
+
this.logger.warn(
|
|
51
|
+
`Session file ${path.basename(filePath)} uses schema ${parsed.schemaVersion ?? 1} (expected ${SESSION_SCHEMA_VERSION}); ignoring it. The branch will be re-seeded.`
|
|
52
|
+
);
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (now - (parsed.lastAccessedAt || parsed.createdAt || 0) > this.ttlMs) {
|
|
56
|
+
await unlink(filePath).catch(() => {});
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return parsed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async write(sessionId, session) {
|
|
63
|
+
await this.init();
|
|
64
|
+
const filePath = this.filePath(sessionId);
|
|
65
|
+
const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`;
|
|
66
|
+
const json = JSON.stringify(session, null, 2);
|
|
67
|
+
await writeFile(tmpPath, json, "utf8");
|
|
68
|
+
try {
|
|
69
|
+
await rename(tmpPath, filePath);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
// Windows can refuse to replace a file that another process has open; write in place instead.
|
|
72
|
+
this.logger.warn(`Atomic rename failed for ${path.basename(filePath)} (${err.message}); writing in place.`);
|
|
73
|
+
await writeFile(filePath, json, "utf8");
|
|
74
|
+
await unlink(tmpPath).catch(() => {});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async remove(sessionId) {
|
|
79
|
+
await unlink(this.filePath(sessionId)).catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async pruneOlderThan(now = Date.now()) {
|
|
83
|
+
if (!existsSync(this.dir)) return 0;
|
|
84
|
+
let removed = 0;
|
|
85
|
+
for (const file of await readdir(this.dir)) {
|
|
86
|
+
if (!file.endsWith(".json")) continue;
|
|
87
|
+
const filePath = path.join(this.dir, file);
|
|
88
|
+
try {
|
|
89
|
+
const info = await statFs(filePath);
|
|
90
|
+
if (now - info.mtimeMs > this.ttlMs) {
|
|
91
|
+
await unlink(filePath);
|
|
92
|
+
removed++;
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
// file vanished concurrently
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return removed;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export function sha256Hex(input) {
|
|
4
|
+
return crypto.createHash("sha256").update(input, "utf8").digest("hex");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Short (16 hex chars) digest used for fingerprints that are compared, never used as a key. */
|
|
8
|
+
export function shortHash(input) {
|
|
9
|
+
return sha256Hex(input).slice(0, 16);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Deterministic JSON: object keys sorted recursively so semantically equal values hash equally. */
|
|
13
|
+
export function canonicalJson(value) {
|
|
14
|
+
return JSON.stringify(sortKeys(value));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sortKeys(value) {
|
|
18
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
19
|
+
if (value && typeof value === "object") {
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const key of Object.keys(value).sort()) {
|
|
22
|
+
out[key] = sortKeys(value[key]);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
package/src/completions.js
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
// POST /v1/chat/completions handler:
|
|
2
|
-
// Stateless prompt dump -> rotational account selection -> human pacing -> DeepSeek turn -> OpenAI stream/JSON.
|
|
3
|
-
|
|
4
|
-
import { CONFIG } from "./config.js";
|
|
5
|
-
import { createLogger } from "./logger.js";
|
|
6
|
-
import { consumeDeepSeekSseStream } from "./deepseek/sse.js";
|
|
7
|
-
import { renderSeedPrompt } from "./prompt.js";
|
|
8
|
-
import { parseChatRequest } from "./request.js";
|
|
9
|
-
import {
|
|
10
|
-
buildUsageStats,
|
|
11
|
-
createCompletionMetadata,
|
|
12
|
-
formatCompletionResponse,
|
|
13
|
-
formatStreamDeltaEvent,
|
|
14
|
-
formatStreamFinishEvent,
|
|
15
|
-
formatStreamReasoningEvent,
|
|
16
|
-
formatStreamRoleEvent,
|
|
17
|
-
formatStreamToolCallArgsEvent,
|
|
18
|
-
formatStreamToolCallStartEvent,
|
|
19
|
-
formatStreamUsageEvent,
|
|
20
|
-
} from "./response.js";
|
|
21
|
-
import { createToolCallStreamProcessor, parseToolCallsFromText } from "./tool-calls.js";
|
|
22
|
-
import { estimateTokenCount } from "./tokens.js";
|
|
23
|
-
import { readJsonBody, sendJson, startSse, writeSse } from "./http.js";
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* @param {object} deps
|
|
27
|
-
* @param {ReturnType<import("./accounts.js").createAccountPool>} deps.pool
|
|
28
|
-
* @param {object} [deps.logger]
|
|
29
|
-
*/
|
|
30
|
-
export function createCompletionHandler({ pool, logger = createLogger("Completions"), keepAliveIntervalMs = CONFIG.server.keepAliveIntervalMs }) {
|
|
31
|
-
async function respondJson({ res, request, account, streamBody, prompt, meta }) {
|
|
32
|
-
const result = await consumeDeepSeekSseStream(streamBody);
|
|
33
|
-
let text = result.reply;
|
|
34
|
-
let toolCalls = [];
|
|
35
|
-
|
|
36
|
-
if (request.tools.length > 0) {
|
|
37
|
-
const parsed = parseToolCallsFromText(result.reply);
|
|
38
|
-
text = parsed.cleanText;
|
|
39
|
-
toolCalls = parsed.toolCalls;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const turnTokens = result.turnTokens > 0
|
|
43
|
-
? result.turnTokens
|
|
44
|
-
: estimateTokenCount(prompt) + estimateTokenCount(result.reply);
|
|
45
|
-
|
|
46
|
-
const usage = buildUsageStats({ prompt, reply: result.reply, turnTokens });
|
|
47
|
-
const payload = formatCompletionResponse({
|
|
48
|
-
...meta,
|
|
49
|
-
model: request.model,
|
|
50
|
-
content: text,
|
|
51
|
-
toolCalls,
|
|
52
|
-
usage,
|
|
53
|
-
});
|
|
54
|
-
sendJson(res, 200, payload);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
async function respondStream({ req, res, request, account, streamBody, prompt, meta }) {
|
|
58
|
-
startSse(res, req);
|
|
59
|
-
writeSse(res, formatStreamRoleEvent(meta));
|
|
60
|
-
const keepAlive = setInterval(() => writeSse(res, ": keep-alive\n\n"), keepAliveIntervalMs);
|
|
61
|
-
keepAlive.unref?.();
|
|
62
|
-
|
|
63
|
-
let emittedText = "";
|
|
64
|
-
const emittedToolCalls = [];
|
|
65
|
-
const emitText = (content) => {
|
|
66
|
-
emittedText += content;
|
|
67
|
-
writeSse(res, formatStreamDeltaEvent(meta, content));
|
|
68
|
-
};
|
|
69
|
-
const emitToolCall = ({ index, id, name, argumentsString }) => {
|
|
70
|
-
emittedToolCalls.push({ name, argumentsString });
|
|
71
|
-
writeSse(res, formatStreamToolCallStartEvent(meta, { toolCallIndex: index, id, name }));
|
|
72
|
-
writeSse(res, formatStreamToolCallArgsEvent(meta, { toolCallIndex: index, argumentsChunk: argumentsString }));
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
const processor = request.tools.length > 0
|
|
76
|
-
? createToolCallStreamProcessor({ onTextDelta: emitText, onToolCall: emitToolCall })
|
|
77
|
-
: null;
|
|
78
|
-
|
|
79
|
-
let result;
|
|
80
|
-
try {
|
|
81
|
-
result = await consumeDeepSeekSseStream(streamBody, (chunk) => {
|
|
82
|
-
if (chunk.isThinking) writeSse(res, formatStreamReasoningEvent(meta, chunk.text));
|
|
83
|
-
else if (processor) processor.processChunk(chunk.text);
|
|
84
|
-
else emitText(chunk.text);
|
|
85
|
-
});
|
|
86
|
-
processor?.flush();
|
|
87
|
-
} finally {
|
|
88
|
-
clearInterval(keepAlive);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const turnTokens = result.turnTokens > 0
|
|
92
|
-
? result.turnTokens
|
|
93
|
-
: estimateTokenCount(prompt) + estimateTokenCount(result.reply);
|
|
94
|
-
|
|
95
|
-
writeSse(
|
|
96
|
-
res,
|
|
97
|
-
formatStreamFinishEvent(meta, {
|
|
98
|
-
finishReason: emittedToolCalls.length > 0 ? "tool_calls" : "stop",
|
|
99
|
-
})
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
if (request.includeUsage) {
|
|
103
|
-
writeSse(res, formatStreamUsageEvent(meta, buildUsageStats({ prompt, reply: result.reply, turnTokens })));
|
|
104
|
-
}
|
|
105
|
-
writeSse(res, "data: [DONE]\n\n");
|
|
106
|
-
res.end();
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return async function handleChatCompletions(req, res) {
|
|
110
|
-
const body = await readJsonBody(req);
|
|
111
|
-
const request = parseChatRequest(body);
|
|
112
|
-
const prompt = renderSeedPrompt(request.messages, request.tools);
|
|
113
|
-
|
|
114
|
-
// Rotational account selection ("Wi-Fi router" hub)
|
|
115
|
-
const account = pool.nextAccount();
|
|
116
|
-
logger.info(`Request [${request.model}] -> [${account.name}] (prompt: ${prompt.length} chars, tools: ${request.tools.length})`);
|
|
117
|
-
|
|
118
|
-
const abortController = new AbortController();
|
|
119
|
-
let finished = false;
|
|
120
|
-
if (typeof res.on === "function") {
|
|
121
|
-
res.on("close", () => {
|
|
122
|
-
if (!finished && !res.writableFinished) abortController.abort();
|
|
123
|
-
});
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Apply per-account human pacing
|
|
127
|
-
await account.pacer.apply();
|
|
128
|
-
|
|
129
|
-
try {
|
|
130
|
-
const { streamBody } = await account.deepseek.openCompletionStream({
|
|
131
|
-
prompt,
|
|
132
|
-
thinkingEnabled: request.thinkingEnabled,
|
|
133
|
-
searchEnabled: request.searchEnabled,
|
|
134
|
-
signal: abortController.signal,
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
const meta = { ...createCompletionMetadata(), model: request.model };
|
|
138
|
-
if (request.stream) {
|
|
139
|
-
await respondStream({ req, res, request, account, streamBody, prompt, meta });
|
|
140
|
-
} else {
|
|
141
|
-
await respondJson({ res, request, account, streamBody, prompt, meta });
|
|
142
|
-
}
|
|
143
|
-
} finally {
|
|
144
|
-
finished = true;
|
|
145
|
-
account.pacer.recordCompletionFinished();
|
|
146
|
-
}
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|