@nextclaw/nextclaw-ncp-runtime-adapter-hermes-http 0.1.1
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/LICENSE +21 -0
- package/README.md +53 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +48 -0
- package/dist/hermes-http-adapter-config.utils.d.ts +16 -0
- package/dist/hermes-http-adapter-config.utils.js +59 -0
- package/dist/hermes-http-adapter-message.utils.d.ts +83 -0
- package/dist/hermes-http-adapter-message.utils.js +458 -0
- package/dist/hermes-http-adapter-session-store.service.js +105 -0
- package/dist/hermes-http-adapter.service.d.ts +14 -0
- package/dist/hermes-http-adapter.service.js +454 -0
- package/dist/hermes-http-adapter.types.d.ts +26 -0
- package/dist/hermes-openai-stream-parser.utils.d.ts +6 -0
- package/dist/hermes-openai-stream-parser.utils.js +54 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/package.json +37 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import { HermesHttpAdapterConfigResolver } from "./hermes-http-adapter-config.utils.js";
|
|
2
|
+
import { HermesAssistantEventCollector, HermesInlineToolTraceTranslator, HermesReasoningDeltaTranslator, buildHermesMessages, createAssistantMessageFromParts, inferHermesProvider, normalizeHermesRequestedModel, readHermesProviderRoute, resolveHermesModel, toErrorSseFrame, toNcpError, toNcpSseFrame } from "./hermes-http-adapter-message.utils.js";
|
|
3
|
+
import { parseHermesOpenAIChatStream } from "./hermes-openai-stream-parser.utils.js";
|
|
4
|
+
import { HermesHttpAdapterSessionStore } from "./hermes-http-adapter-session-store.service.js";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { DefaultNcpStreamEncoder } from "@nextclaw/ncp-agent-runtime";
|
|
8
|
+
import { NcpEventType } from "@nextclaw/ncp";
|
|
9
|
+
//#region src/hermes-http-adapter.service.ts
|
|
10
|
+
function isUserVisibleAssistantEvent(event) {
|
|
11
|
+
const eventType = event.type;
|
|
12
|
+
return eventType.startsWith("message.text-") || eventType.startsWith("message.reasoning-") || eventType.startsWith("message.tool-call-");
|
|
13
|
+
}
|
|
14
|
+
var HermesHttpAdapterRouteService = class {
|
|
15
|
+
sessions = new HermesHttpAdapterSessionStore();
|
|
16
|
+
streamEncoder = new DefaultNcpStreamEncoder({ reasoningNormalizationMode: "think-tags" });
|
|
17
|
+
fetchImpl;
|
|
18
|
+
constructor(config, buildAssistantMessage = createAssistantMessageFromParts) {
|
|
19
|
+
this.config = config;
|
|
20
|
+
this.buildAssistantMessage = buildAssistantMessage;
|
|
21
|
+
this.fetchImpl = config.fetchImpl ?? fetch;
|
|
22
|
+
}
|
|
23
|
+
handle = async (request, response) => {
|
|
24
|
+
const requestUrl = new URL(request.url ?? "/", this.baseOrigin);
|
|
25
|
+
const normalizedPath = requestUrl.pathname;
|
|
26
|
+
if (request.method === "GET" && normalizedPath === "/health") {
|
|
27
|
+
await this.handleHealth(response);
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (request.method === "POST" && normalizedPath === `${this.config.basePath}/send`) {
|
|
31
|
+
await this.handleSend(request, response);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (request.method === "GET" && normalizedPath === `${this.config.basePath}/stream`) {
|
|
35
|
+
await this.handleStream(request, response, requestUrl);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (request.method === "POST" && normalizedPath === `${this.config.basePath}/abort`) {
|
|
39
|
+
await this.handleAbort(request, response);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.writeJson(response, 404, {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: {
|
|
45
|
+
code: "NOT_FOUND",
|
|
46
|
+
message: `Unknown path: ${normalizedPath}`
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
};
|
|
50
|
+
dispose = () => {
|
|
51
|
+
this.sessions.dispose();
|
|
52
|
+
};
|
|
53
|
+
get baseOrigin() {
|
|
54
|
+
return `http://${this.config.host}:${this.config.port}`;
|
|
55
|
+
}
|
|
56
|
+
handleHealth = async (response) => {
|
|
57
|
+
const controller = new AbortController();
|
|
58
|
+
const timeout = setTimeout(() => controller.abort(), this.config.healthcheckTimeoutMs);
|
|
59
|
+
try {
|
|
60
|
+
const upstream = await this.fetchImpl(this.config.healthcheckUrl, {
|
|
61
|
+
method: "GET",
|
|
62
|
+
signal: controller.signal
|
|
63
|
+
});
|
|
64
|
+
if (!upstream.ok) {
|
|
65
|
+
this.writeJson(response, 503, {
|
|
66
|
+
status: "degraded",
|
|
67
|
+
adapter: "ok",
|
|
68
|
+
upstream: {
|
|
69
|
+
status: "error",
|
|
70
|
+
httpStatus: upstream.status
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
this.writeJson(response, 200, {
|
|
76
|
+
status: "ok",
|
|
77
|
+
adapter: "ok",
|
|
78
|
+
upstream: "ok"
|
|
79
|
+
});
|
|
80
|
+
} catch (error) {
|
|
81
|
+
this.writeJson(response, 503, {
|
|
82
|
+
status: "degraded",
|
|
83
|
+
adapter: "ok",
|
|
84
|
+
upstream: {
|
|
85
|
+
status: "unreachable",
|
|
86
|
+
message: error instanceof Error ? error.message : String(error)
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(timeout);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
handleSend = async (request, response) => {
|
|
94
|
+
try {
|
|
95
|
+
const envelope = await this.readJsonBody(request);
|
|
96
|
+
if (!envelope?.sessionId || !envelope.message) {
|
|
97
|
+
this.writeJson(response, 400, {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: {
|
|
100
|
+
code: "INVALID_BODY",
|
|
101
|
+
message: "sessionId and message are required."
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const run = {
|
|
107
|
+
envelope,
|
|
108
|
+
messageId: randomUUID(),
|
|
109
|
+
runId: randomUUID()
|
|
110
|
+
};
|
|
111
|
+
this.sessions.setPendingRun(run);
|
|
112
|
+
this.writeJson(response, 200, { ok: true });
|
|
113
|
+
} catch (error) {
|
|
114
|
+
this.writeJson(response, 409, {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: {
|
|
117
|
+
code: "RUN_CONFLICT",
|
|
118
|
+
message: error instanceof Error ? error.message : String(error)
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
handleAbort = async (request, response) => {
|
|
124
|
+
try {
|
|
125
|
+
const payload = await this.readJsonBody(request);
|
|
126
|
+
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
|
|
127
|
+
if (!sessionId.trim()) {
|
|
128
|
+
this.writeJson(response, 400, {
|
|
129
|
+
ok: false,
|
|
130
|
+
error: {
|
|
131
|
+
code: "INVALID_BODY",
|
|
132
|
+
message: "sessionId is required."
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const aborted = this.sessions.abortSession(sessionId);
|
|
138
|
+
this.writeJson(response, 200, {
|
|
139
|
+
ok: true,
|
|
140
|
+
aborted
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
this.writeJson(response, 500, {
|
|
144
|
+
ok: false,
|
|
145
|
+
error: {
|
|
146
|
+
code: "ABORT_FAILED",
|
|
147
|
+
message: error instanceof Error ? error.message : String(error)
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
handleStream = async (request, response, requestUrl) => {
|
|
153
|
+
const sessionId = requestUrl.searchParams.get("sessionId")?.trim();
|
|
154
|
+
if (!sessionId) {
|
|
155
|
+
this.writeJson(response, 400, {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: {
|
|
158
|
+
code: "INVALID_QUERY",
|
|
159
|
+
message: "sessionId is required."
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const connectionController = new AbortController();
|
|
165
|
+
const runController = new AbortController();
|
|
166
|
+
response.once("close", () => {
|
|
167
|
+
connectionController.abort("client disconnected");
|
|
168
|
+
runController.abort("client disconnected");
|
|
169
|
+
});
|
|
170
|
+
response.writeHead(200, {
|
|
171
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
172
|
+
"cache-control": "no-cache, no-transform",
|
|
173
|
+
connection: "keep-alive",
|
|
174
|
+
"x-accel-buffering": "no"
|
|
175
|
+
});
|
|
176
|
+
try {
|
|
177
|
+
const run = await this.sessions.waitForPendingRun(sessionId, connectionController.signal, this.config.streamWaitTimeoutMs);
|
|
178
|
+
this.sessions.setActiveAbortController(sessionId, runController);
|
|
179
|
+
const streamResult = await this.startRun({
|
|
180
|
+
run,
|
|
181
|
+
signal: runController.signal
|
|
182
|
+
});
|
|
183
|
+
for await (const event of streamResult.events) {
|
|
184
|
+
if (connectionController.signal.aborted) break;
|
|
185
|
+
response.write(toNcpSseFrame(event));
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (!connectionController.signal.aborted) response.write(toErrorSseFrame({
|
|
189
|
+
code: "STREAM_FAILED",
|
|
190
|
+
message: error instanceof Error ? error.message : String(error)
|
|
191
|
+
}));
|
|
192
|
+
} finally {
|
|
193
|
+
this.sessions.clearActiveAbortController(sessionId);
|
|
194
|
+
response.end();
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
startRun = async (params) => {
|
|
198
|
+
const { run, signal } = params;
|
|
199
|
+
const providerRoute = this.readProviderRoute(run.envelope);
|
|
200
|
+
const requestedModel = normalizeHermesRequestedModel(resolveHermesModel({
|
|
201
|
+
envelope: run.envelope,
|
|
202
|
+
fallbackModel: this.config.model
|
|
203
|
+
}));
|
|
204
|
+
if (!providerRoute) await this.ensureHermesSessionModel({
|
|
205
|
+
sessionId: run.envelope.sessionId,
|
|
206
|
+
requestedModel,
|
|
207
|
+
signal
|
|
208
|
+
});
|
|
209
|
+
const response = await this.fetchHermesChatCompletion({
|
|
210
|
+
sessionId: run.envelope.sessionId,
|
|
211
|
+
signal,
|
|
212
|
+
stream: true,
|
|
213
|
+
model: requestedModel,
|
|
214
|
+
providerRoute,
|
|
215
|
+
messages: buildHermesMessages({
|
|
216
|
+
envelope: run.envelope,
|
|
217
|
+
systemPrompt: this.config.systemPrompt
|
|
218
|
+
}),
|
|
219
|
+
tools: run.envelope.tools
|
|
220
|
+
});
|
|
221
|
+
if (!response.body) throw new Error("[hermes-http-adapter] Hermes stream response body is missing.");
|
|
222
|
+
this.sessions.setSelectedModel(run.envelope.sessionId, requestedModel);
|
|
223
|
+
return { events: this.createRunEvents({
|
|
224
|
+
response,
|
|
225
|
+
run,
|
|
226
|
+
signal
|
|
227
|
+
}) };
|
|
228
|
+
};
|
|
229
|
+
createRunEvents = async function* (params) {
|
|
230
|
+
const { response, run, signal } = params;
|
|
231
|
+
const sessionId = run.envelope.sessionId;
|
|
232
|
+
this.readProviderRoute(run.envelope);
|
|
233
|
+
const metadata = { hermesModel: normalizeHermesRequestedModel(resolveHermesModel({
|
|
234
|
+
envelope: run.envelope,
|
|
235
|
+
fallbackModel: this.config.model
|
|
236
|
+
})) };
|
|
237
|
+
let emittedUserVisibleAssistantEvent = false;
|
|
238
|
+
const messageCollector = new HermesAssistantEventCollector();
|
|
239
|
+
const inlineToolTraceTranslator = new HermesInlineToolTraceTranslator();
|
|
240
|
+
const reasoningDeltaTranslator = new HermesReasoningDeltaTranslator();
|
|
241
|
+
yield {
|
|
242
|
+
type: NcpEventType.MessageAccepted,
|
|
243
|
+
payload: {
|
|
244
|
+
messageId: run.messageId,
|
|
245
|
+
correlationId: run.envelope.correlationId
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
yield {
|
|
249
|
+
type: NcpEventType.RunStarted,
|
|
250
|
+
payload: {
|
|
251
|
+
sessionId,
|
|
252
|
+
messageId: run.messageId,
|
|
253
|
+
runId: run.runId
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
try {
|
|
257
|
+
for await (const event of reasoningDeltaTranslator.translate(inlineToolTraceTranslator.translate(this.streamEncoder.encode(parseHermesOpenAIChatStream(response.body), {
|
|
258
|
+
sessionId,
|
|
259
|
+
messageId: run.messageId,
|
|
260
|
+
runId: run.runId,
|
|
261
|
+
correlationId: run.envelope.correlationId
|
|
262
|
+
})))) {
|
|
263
|
+
messageCollector.applyEvent(event);
|
|
264
|
+
if (isUserVisibleAssistantEvent(event)) emittedUserVisibleAssistantEvent = true;
|
|
265
|
+
yield event;
|
|
266
|
+
}
|
|
267
|
+
if (!emittedUserVisibleAssistantEvent || !messageCollector.hasParts()) throw new Error(`[hermes-http-adapter] Hermes completed without any assistant content for session ${sessionId}.`);
|
|
268
|
+
const message = this.buildAssistantMessage({
|
|
269
|
+
sessionId,
|
|
270
|
+
messageId: run.messageId,
|
|
271
|
+
parts: messageCollector.buildParts(),
|
|
272
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
273
|
+
metadata
|
|
274
|
+
});
|
|
275
|
+
yield {
|
|
276
|
+
type: NcpEventType.MessageCompleted,
|
|
277
|
+
payload: {
|
|
278
|
+
sessionId,
|
|
279
|
+
message,
|
|
280
|
+
correlationId: run.envelope.correlationId,
|
|
281
|
+
metadata
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
yield {
|
|
285
|
+
type: NcpEventType.RunFinished,
|
|
286
|
+
payload: {
|
|
287
|
+
sessionId,
|
|
288
|
+
messageId: run.messageId,
|
|
289
|
+
runId: run.runId
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (signal.aborted) {
|
|
294
|
+
const abortError = toNcpError(error, "abort-error");
|
|
295
|
+
yield {
|
|
296
|
+
type: NcpEventType.MessageFailed,
|
|
297
|
+
payload: {
|
|
298
|
+
sessionId,
|
|
299
|
+
messageId: run.messageId,
|
|
300
|
+
correlationId: run.envelope.correlationId,
|
|
301
|
+
error: abortError
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
yield {
|
|
305
|
+
type: NcpEventType.RunError,
|
|
306
|
+
payload: {
|
|
307
|
+
sessionId,
|
|
308
|
+
messageId: run.messageId,
|
|
309
|
+
runId: run.runId,
|
|
310
|
+
error: abortError.message
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const runtimeError = toNcpError(error, "runtime-error");
|
|
316
|
+
yield {
|
|
317
|
+
type: NcpEventType.MessageFailed,
|
|
318
|
+
payload: {
|
|
319
|
+
sessionId,
|
|
320
|
+
messageId: run.messageId,
|
|
321
|
+
correlationId: run.envelope.correlationId,
|
|
322
|
+
error: runtimeError
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
yield {
|
|
326
|
+
type: NcpEventType.RunError,
|
|
327
|
+
payload: {
|
|
328
|
+
sessionId,
|
|
329
|
+
messageId: run.messageId,
|
|
330
|
+
runId: run.runId,
|
|
331
|
+
error: runtimeError.message
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
ensureHermesSessionModel = async (params) => {
|
|
337
|
+
const { requestedModel, sessionId, signal } = params;
|
|
338
|
+
if ((this.sessions.readSelectedModel(sessionId) ?? this.config.model) === requestedModel) return;
|
|
339
|
+
const response = await this.fetchHermesChatCompletion({
|
|
340
|
+
sessionId,
|
|
341
|
+
signal,
|
|
342
|
+
stream: false,
|
|
343
|
+
model: requestedModel,
|
|
344
|
+
messages: [{
|
|
345
|
+
role: "user",
|
|
346
|
+
content: `/model ${requestedModel}`
|
|
347
|
+
}]
|
|
348
|
+
});
|
|
349
|
+
const content = (await this.readJsonResponse(response)).choices?.[0]?.message?.content;
|
|
350
|
+
if (typeof content === "string" && content.trim().startsWith("Error:")) throw new Error(`[hermes-http-adapter] failed to switch Hermes model to ${requestedModel}: ${content.trim()}`);
|
|
351
|
+
this.sessions.setSelectedModel(sessionId, requestedModel);
|
|
352
|
+
};
|
|
353
|
+
fetchHermesChatCompletion = async (params) => {
|
|
354
|
+
const { messages, model, providerRoute, sessionId, signal, stream, tools } = params;
|
|
355
|
+
const headers = new Headers({
|
|
356
|
+
"content-type": "application/json",
|
|
357
|
+
accept: stream ? "text/event-stream" : "application/json"
|
|
358
|
+
});
|
|
359
|
+
if (this.config.hermesApiKey) headers.set("authorization", `Bearer ${this.config.hermesApiKey}`);
|
|
360
|
+
const hermesSessionId = this.sessions.readHermesSessionId(sessionId);
|
|
361
|
+
if (hermesSessionId) headers.set("x-hermes-session-id", hermesSessionId);
|
|
362
|
+
const response = await this.fetchImpl(this.config.chatCompletionsUrl, {
|
|
363
|
+
method: "POST",
|
|
364
|
+
headers,
|
|
365
|
+
signal,
|
|
366
|
+
body: JSON.stringify({
|
|
367
|
+
model,
|
|
368
|
+
stream,
|
|
369
|
+
messages,
|
|
370
|
+
...providerRoute ? { nextclaw_provider_route: this.toHermesProviderRoutePayload(providerRoute) } : {},
|
|
371
|
+
...tools && tools.length > 0 ? { tools } : {}
|
|
372
|
+
})
|
|
373
|
+
});
|
|
374
|
+
if (!response.ok) throw new Error(`[hermes-http-adapter] Hermes request failed with HTTP ${response.status}.`);
|
|
375
|
+
const returnedSessionId = response.headers.get("x-hermes-session-id")?.trim();
|
|
376
|
+
if (returnedSessionId) this.sessions.setHermesSessionId(sessionId, returnedSessionId);
|
|
377
|
+
return response;
|
|
378
|
+
};
|
|
379
|
+
readProviderRoute = (envelope) => {
|
|
380
|
+
const providerRoute = readHermesProviderRoute(envelope);
|
|
381
|
+
if (!providerRoute) return;
|
|
382
|
+
if (!providerRoute.apiBase) throw new Error(`[hermes-http-adapter] missing provider apiBase for model "${providerRoute.model}". Configure the selected NextClaw provider before using Hermes.`);
|
|
383
|
+
return providerRoute;
|
|
384
|
+
};
|
|
385
|
+
toHermesProviderRoutePayload = (providerRoute) => {
|
|
386
|
+
const inferredProvider = inferHermesProvider(providerRoute);
|
|
387
|
+
return {
|
|
388
|
+
model: providerRoute.model,
|
|
389
|
+
...inferredProvider ? { provider: inferredProvider } : {},
|
|
390
|
+
...providerRoute.apiKey ? { api_key: providerRoute.apiKey } : {},
|
|
391
|
+
...providerRoute.apiBase ? { base_url: providerRoute.apiBase } : {},
|
|
392
|
+
...providerRoute.apiMode ? { api_mode: providerRoute.apiMode } : {},
|
|
393
|
+
...Object.keys(providerRoute.headers).length > 0 ? { extra_headers: providerRoute.headers } : {}
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
readJsonResponse = async (response) => {
|
|
397
|
+
try {
|
|
398
|
+
return await response.json();
|
|
399
|
+
} catch (error) {
|
|
400
|
+
throw new Error(`[hermes-http-adapter] expected JSON response from Hermes: ${error instanceof Error ? error.message : String(error)}`);
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
readJsonBody = async (request) => {
|
|
404
|
+
const chunks = [];
|
|
405
|
+
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
406
|
+
const body = Buffer.concat(chunks).toString("utf8");
|
|
407
|
+
if (!body.trim()) throw new Error("request body is empty");
|
|
408
|
+
return JSON.parse(body);
|
|
409
|
+
};
|
|
410
|
+
writeJson = (response, statusCode, payload) => {
|
|
411
|
+
response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
|
|
412
|
+
response.end(JSON.stringify(payload));
|
|
413
|
+
};
|
|
414
|
+
};
|
|
415
|
+
var HermesHttpAdapterServer = class {
|
|
416
|
+
config;
|
|
417
|
+
routeService;
|
|
418
|
+
server = null;
|
|
419
|
+
constructor(configInput = {}) {
|
|
420
|
+
this.config = new HermesHttpAdapterConfigResolver(configInput).resolve();
|
|
421
|
+
this.routeService = new HermesHttpAdapterRouteService(this.config);
|
|
422
|
+
}
|
|
423
|
+
start = async () => {
|
|
424
|
+
if (this.server) return;
|
|
425
|
+
const nextServer = createServer((request, response) => {
|
|
426
|
+
this.routeService.handle(request, response);
|
|
427
|
+
});
|
|
428
|
+
await new Promise((resolve, reject) => {
|
|
429
|
+
nextServer.once("error", reject);
|
|
430
|
+
nextServer.listen(this.config.port, this.config.host, () => {
|
|
431
|
+
nextServer.off("error", reject);
|
|
432
|
+
resolve();
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
this.server = nextServer;
|
|
436
|
+
};
|
|
437
|
+
stop = async () => {
|
|
438
|
+
this.routeService.dispose();
|
|
439
|
+
const activeServer = this.server;
|
|
440
|
+
if (!activeServer) return;
|
|
441
|
+
await new Promise((resolve, reject) => {
|
|
442
|
+
activeServer.close((error) => {
|
|
443
|
+
if (error) {
|
|
444
|
+
reject(error);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
resolve();
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
this.server = null;
|
|
451
|
+
};
|
|
452
|
+
};
|
|
453
|
+
//#endregion
|
|
454
|
+
export { HermesHttpAdapterServer };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { NcpEndpointEvent, NcpMessage, NcpMessagePart, NcpRequestEnvelope } from "@nextclaw/ncp";
|
|
2
|
+
|
|
3
|
+
//#region src/hermes-http-adapter.types.d.ts
|
|
4
|
+
type HermesHttpAdapterFetchLike = (input: URL | string | Request, init?: RequestInit) => Promise<Response>;
|
|
5
|
+
type HermesHttpAdapterConfig = {
|
|
6
|
+
host: string;
|
|
7
|
+
port: number;
|
|
8
|
+
basePath: string;
|
|
9
|
+
hermesBaseUrl: string;
|
|
10
|
+
hermesApiKey?: string;
|
|
11
|
+
model: string;
|
|
12
|
+
systemPrompt?: string;
|
|
13
|
+
streamWaitTimeoutMs: number;
|
|
14
|
+
healthcheckTimeoutMs: number;
|
|
15
|
+
fetchImpl?: HermesHttpAdapterFetchLike;
|
|
16
|
+
};
|
|
17
|
+
type HermesHttpAdapterResolvedConfig = HermesHttpAdapterConfig & {
|
|
18
|
+
chatCompletionsUrl: string;
|
|
19
|
+
healthcheckUrl: string;
|
|
20
|
+
};
|
|
21
|
+
type HermesOpenAIMessage = {
|
|
22
|
+
role: "system" | "user" | "assistant";
|
|
23
|
+
content: string;
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
export { HermesHttpAdapterConfig, HermesHttpAdapterFetchLike, HermesHttpAdapterResolvedConfig, HermesOpenAIMessage };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { OpenAIChatChunk } from "@nextclaw/ncp";
|
|
2
|
+
|
|
3
|
+
//#region src/hermes-openai-stream-parser.utils.d.ts
|
|
4
|
+
declare function parseHermesOpenAIChatStream(stream: ReadableStream<Uint8Array>): AsyncGenerator<OpenAIChatChunk>;
|
|
5
|
+
//#endregion
|
|
6
|
+
export { parseHermesOpenAIChatStream };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//#region src/hermes-openai-stream-parser.utils.ts
|
|
2
|
+
async function* parseHermesOpenAIChatStream(stream) {
|
|
3
|
+
for await (const frame of consumeSseFrames(stream)) {
|
|
4
|
+
const payload = frame.data.trim();
|
|
5
|
+
if (!payload || payload === "[DONE]") continue;
|
|
6
|
+
yield JSON.parse(payload);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
async function* consumeSseFrames(stream) {
|
|
10
|
+
const reader = stream.getReader();
|
|
11
|
+
const decoder = new TextDecoder();
|
|
12
|
+
let buffer = "";
|
|
13
|
+
try {
|
|
14
|
+
while (true) {
|
|
15
|
+
const { value, done } = await reader.read();
|
|
16
|
+
if (done) break;
|
|
17
|
+
buffer += decoder.decode(value, { stream: true });
|
|
18
|
+
const drained = drainSseFrames(buffer);
|
|
19
|
+
buffer = drained.rest;
|
|
20
|
+
for (const frame of drained.frames) yield frame;
|
|
21
|
+
}
|
|
22
|
+
buffer += decoder.decode();
|
|
23
|
+
const drained = drainSseFrames(buffer, true);
|
|
24
|
+
for (const frame of drained.frames) yield frame;
|
|
25
|
+
} finally {
|
|
26
|
+
reader.releaseLock();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function drainSseFrames(rawBuffer, flush = false) {
|
|
30
|
+
const parts = rawBuffer.split(/\r?\n\r?\n/u);
|
|
31
|
+
const rest = parts.pop() ?? "";
|
|
32
|
+
const frames = parts.map(parseSseFrame).filter((frame) => Boolean(frame));
|
|
33
|
+
if (!flush || !rest.trim()) return {
|
|
34
|
+
frames,
|
|
35
|
+
rest
|
|
36
|
+
};
|
|
37
|
+
const finalFrame = parseSseFrame(rest);
|
|
38
|
+
return {
|
|
39
|
+
frames: finalFrame ? [...frames, finalFrame] : frames,
|
|
40
|
+
rest: ""
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function parseSseFrame(frameText) {
|
|
44
|
+
const dataLines = [];
|
|
45
|
+
for (const rawLine of frameText.split(/\r?\n/u)) {
|
|
46
|
+
const line = rawLine.trimEnd();
|
|
47
|
+
if (!line || line.startsWith(":") || !line.startsWith("data:")) continue;
|
|
48
|
+
dataLines.push(line.slice(5).trimStart());
|
|
49
|
+
}
|
|
50
|
+
if (dataLines.length === 0) return null;
|
|
51
|
+
return { data: dataLines.join("\n") };
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
export { parseHermesOpenAIChatStream };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { HermesHttpAdapterConfig, HermesHttpAdapterFetchLike, HermesHttpAdapterResolvedConfig } from "./hermes-http-adapter.types.js";
|
|
2
|
+
import { HermesHttpAdapterConfigInput, HermesHttpAdapterConfigResolver, normalizeBasePath, resolveHermesApiUrl, resolveHermesHealthcheckUrl } from "./hermes-http-adapter-config.utils.js";
|
|
3
|
+
import { HermesHttpAdapterServer } from "./hermes-http-adapter.service.js";
|
|
4
|
+
import { HermesAssistantEventCollector, HermesInlineToolTraceTranslator, HermesProviderRoute, HermesReasoningDeltaTranslator, buildHermesMessages, createAssistantMessage, createAssistantMessageFromParts, inferHermesProvider, normalizeHermesRequestedModel, readHermesProviderRoute, resolveHermesModel, toErrorSseFrame, toNcpError, toNcpSseFrame } from "./hermes-http-adapter-message.utils.js";
|
|
5
|
+
import { parseHermesOpenAIChatStream } from "./hermes-openai-stream-parser.utils.js";
|
|
6
|
+
export { HermesAssistantEventCollector, type HermesHttpAdapterConfig, type HermesHttpAdapterConfigInput, HermesHttpAdapterConfigResolver, type HermesHttpAdapterFetchLike, type HermesHttpAdapterResolvedConfig, HermesHttpAdapterServer, HermesInlineToolTraceTranslator, type HermesProviderRoute, HermesReasoningDeltaTranslator, buildHermesMessages, createAssistantMessage, createAssistantMessageFromParts, inferHermesProvider, normalizeBasePath, normalizeHermesRequestedModel, parseHermesOpenAIChatStream, readHermesProviderRoute, resolveHermesApiUrl, resolveHermesHealthcheckUrl, resolveHermesModel, toErrorSseFrame, toNcpError, toNcpSseFrame };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { HermesHttpAdapterConfigResolver, normalizeBasePath, resolveHermesApiUrl, resolveHermesHealthcheckUrl } from "./hermes-http-adapter-config.utils.js";
|
|
2
|
+
import { HermesAssistantEventCollector, HermesInlineToolTraceTranslator, HermesReasoningDeltaTranslator, buildHermesMessages, createAssistantMessage, createAssistantMessageFromParts, inferHermesProvider, normalizeHermesRequestedModel, readHermesProviderRoute, resolveHermesModel, toErrorSseFrame, toNcpError, toNcpSseFrame } from "./hermes-http-adapter-message.utils.js";
|
|
3
|
+
import { parseHermesOpenAIChatStream } from "./hermes-openai-stream-parser.utils.js";
|
|
4
|
+
import { HermesHttpAdapterServer } from "./hermes-http-adapter.service.js";
|
|
5
|
+
export { HermesAssistantEventCollector, HermesHttpAdapterConfigResolver, HermesHttpAdapterServer, HermesInlineToolTraceTranslator, HermesReasoningDeltaTranslator, buildHermesMessages, createAssistantMessage, createAssistantMessageFromParts, inferHermesProvider, normalizeBasePath, normalizeHermesRequestedModel, parseHermesOpenAIChatStream, readHermesProviderRoute, resolveHermesApiUrl, resolveHermesHealthcheckUrl, resolveHermesModel, toErrorSseFrame, toNcpError, toNcpSseFrame };
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nextclaw/nextclaw-ncp-runtime-adapter-hermes-http",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Standalone NCP HTTP adapter server that bridges NextClaw http-runtime sessions to Hermes API Server.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"nextclaw-hermes-http-adapter": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"development": "./src/index.ts",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@nextclaw/ncp-agent-runtime": "0.3.12",
|
|
23
|
+
"@nextclaw/ncp-http-agent-client": "0.3.14",
|
|
24
|
+
"@nextclaw/ncp": "0.5.2"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^20.17.6",
|
|
28
|
+
"typescript": "^5.6.3",
|
|
29
|
+
"vitest": "^4.1.2"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsdown src/index.ts src/cli.ts --dts --clean --target es2022 --no-fixedExtension --unbundle",
|
|
33
|
+
"lint": "eslint .",
|
|
34
|
+
"tsc": "tsc -p tsconfig.json",
|
|
35
|
+
"test": "vitest run"
|
|
36
|
+
}
|
|
37
|
+
}
|