@graphorin/provider-llamacpp-node 0.6.1 → 0.7.0
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/CHANGELOG.md +20 -0
- package/README.md +1 -1
- package/dist/adapter.d.ts +13 -0
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +115 -12
- package/dist/adapter.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/dist/runtime.d.ts +27 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js.map +1 -1
- package/package.json +7 -5
- package/src/adapter.ts +543 -0
- package/src/counter.ts +79 -0
- package/src/index.ts +38 -0
- package/src/runtime.ts +202 -0
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `llamaCppNodeAdapter` - in-process GGUF adapter built on
|
|
3
|
+
* `node-llama-cpp@^3.5`. The adapter declares `trust: 'loopback'`
|
|
4
|
+
* permanently because the model lives in the same trust boundary as
|
|
5
|
+
* the host process.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
Provider,
|
|
12
|
+
ProviderCapabilities,
|
|
13
|
+
ProviderEvent,
|
|
14
|
+
ProviderRequest,
|
|
15
|
+
ProviderResponse,
|
|
16
|
+
Sensitivity,
|
|
17
|
+
Usage,
|
|
18
|
+
} from '@graphorin/core';
|
|
19
|
+
import { ProviderHttpError } from '@graphorin/provider/errors';
|
|
20
|
+
import { applyReasoningPolicy, resolveReasoningRetention } from '@graphorin/provider/reasoning';
|
|
21
|
+
import {
|
|
22
|
+
type LlamaChatHistoryItem,
|
|
23
|
+
type LlamaChatSessionPeer,
|
|
24
|
+
type LlamaCppNodeRuntimeOverrides,
|
|
25
|
+
type LlamaModelInstance,
|
|
26
|
+
type LlamaSessionInstance,
|
|
27
|
+
loadLlamaChatSessionCtor,
|
|
28
|
+
loadLlamaModule,
|
|
29
|
+
} from './runtime.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Default sensitivity envelope for the in-process adapter - same as
|
|
33
|
+
* the loopback HTTP path.
|
|
34
|
+
*
|
|
35
|
+
* @stable
|
|
36
|
+
*/
|
|
37
|
+
export const LLAMA_CPP_NODE_ACCEPTS_SENSITIVITY: ReadonlyArray<Sensitivity> = Object.freeze([
|
|
38
|
+
'public',
|
|
39
|
+
'internal',
|
|
40
|
+
'secret',
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Options accepted by {@link llamaCppNodeAdapter}.
|
|
45
|
+
*
|
|
46
|
+
* @stable
|
|
47
|
+
*/
|
|
48
|
+
export interface LlamaCppNodeAdapterOptions {
|
|
49
|
+
/** Filesystem path to the `.gguf` model file. */
|
|
50
|
+
readonly modelPath: string;
|
|
51
|
+
/** Number of layers to offload to the GPU. Default `'auto'`. */
|
|
52
|
+
readonly gpuLayers?: number | 'auto';
|
|
53
|
+
/** Optional context-window override. */
|
|
54
|
+
readonly contextSize?: number;
|
|
55
|
+
/** Provider name attached to spans / log lines. */
|
|
56
|
+
readonly name?: string;
|
|
57
|
+
/** Capability declaration. Merged on top of the defaults table. */
|
|
58
|
+
readonly capabilities?: Partial<ProviderCapabilities>;
|
|
59
|
+
/** Sensitivity override (defaults to the loopback envelope). */
|
|
60
|
+
readonly acceptsSensitivity?: ReadonlyArray<Sensitivity>;
|
|
61
|
+
/**
|
|
62
|
+
* Test-only runtime override. When unset the adapter loads
|
|
63
|
+
* `node-llama-cpp` lazily on first call.
|
|
64
|
+
*/
|
|
65
|
+
readonly runtimeOverrides?: LlamaCppNodeRuntimeOverrides;
|
|
66
|
+
/**
|
|
67
|
+
* Optional `model` override that short-circuits the
|
|
68
|
+
* `loadLlamaModule(...).loadModel(...)` flow. Tests pass a fixture
|
|
69
|
+
* shaped instance.
|
|
70
|
+
*/
|
|
71
|
+
readonly modelOverride?: LlamaModelInstance;
|
|
72
|
+
/**
|
|
73
|
+
* Optional session factory override. When unset, the adapter builds a
|
|
74
|
+
* real session from the peer (PS-3): `model.createContext()` →
|
|
75
|
+
* `new LlamaChatSession({ contextSequence })`, streaming through
|
|
76
|
+
* `prompt(text, { onTextChunk })`. Overrides
|
|
77
|
+
* (`runtimeOverrides.createSession` or this option) keep the test
|
|
78
|
+
* seam.
|
|
79
|
+
*/
|
|
80
|
+
readonly sessionFactory?: (
|
|
81
|
+
model: LlamaModelInstance,
|
|
82
|
+
system?: string,
|
|
83
|
+
) => Promise<LlamaSessionInstance>;
|
|
84
|
+
/**
|
|
85
|
+
* W-096: reuse ONE session (context + KV cache) across requests
|
|
86
|
+
* instead of creating and disposing a fresh one per call - an agent
|
|
87
|
+
* loop then avoids re-prefilling the growing transcript on every
|
|
88
|
+
* step. Requests serialise through a promise mutex (a llama context
|
|
89
|
+
* sequence is single-threaded), and the chat history re-syncs via
|
|
90
|
+
* `setChatHistory` before each prompt. Strictly opt-in: the default
|
|
91
|
+
* per-request lifecycle stays memory-safe and concurrency-safe; the
|
|
92
|
+
* cached session also skips per-request disposal (it lives until the
|
|
93
|
+
* process / instance is released). Sessions WITHOUT `setChatHistory`
|
|
94
|
+
* cannot re-sync and silently degrade to per-request behaviour.
|
|
95
|
+
*/
|
|
96
|
+
readonly persistentSession?: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const DEFAULT_CAPABILITIES: ProviderCapabilities = {
|
|
100
|
+
streaming: true,
|
|
101
|
+
toolCalling: false,
|
|
102
|
+
parallelToolCalls: false,
|
|
103
|
+
multimodal: false,
|
|
104
|
+
structuredOutput: false,
|
|
105
|
+
reasoning: false,
|
|
106
|
+
contextWindow: 8_192,
|
|
107
|
+
maxOutput: 4_096,
|
|
108
|
+
reasoningContract: 'optional',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Build a Graphorin {@link Provider} backed by an in-process GGUF
|
|
113
|
+
* model. The first call lazily loads the `node-llama-cpp` peer + the
|
|
114
|
+
* model file; subsequent calls reuse the cached instances.
|
|
115
|
+
*
|
|
116
|
+
* @stable
|
|
117
|
+
*/
|
|
118
|
+
export function llamaCppNodeAdapter(options: LlamaCppNodeAdapterOptions): Provider {
|
|
119
|
+
const providerName = options.name ?? `llama-cpp-node-${basename(options.modelPath)}`;
|
|
120
|
+
const capabilities: ProviderCapabilities = {
|
|
121
|
+
...DEFAULT_CAPABILITIES,
|
|
122
|
+
...options.capabilities,
|
|
123
|
+
};
|
|
124
|
+
const acceptsSensitivity = options.acceptsSensitivity ?? LLAMA_CPP_NODE_ACCEPTS_SENSITIVITY;
|
|
125
|
+
let resolved: { model: LlamaModelInstance } | null = null;
|
|
126
|
+
let resolving: Promise<{ model: LlamaModelInstance }> | null = null;
|
|
127
|
+
const ensureModel = async (): Promise<{ model: LlamaModelInstance }> => {
|
|
128
|
+
if (resolved !== null) return resolved;
|
|
129
|
+
if (resolving !== null) return resolving;
|
|
130
|
+
resolving = (async () => {
|
|
131
|
+
const model = options.modelOverride ?? (await resolveModel(options));
|
|
132
|
+
resolved = { model };
|
|
133
|
+
resolving = null;
|
|
134
|
+
return resolved;
|
|
135
|
+
})();
|
|
136
|
+
return resolving;
|
|
137
|
+
};
|
|
138
|
+
const sessionFactory =
|
|
139
|
+
options.sessionFactory ??
|
|
140
|
+
options.runtimeOverrides?.createSession ??
|
|
141
|
+
((model: LlamaModelInstance, system?: string) => defaultSessionFactory(model, system, options));
|
|
142
|
+
const acquireSession = buildSessionManager(options, sessionFactory);
|
|
143
|
+
return {
|
|
144
|
+
name: providerName,
|
|
145
|
+
modelId: options.modelPath,
|
|
146
|
+
capabilities,
|
|
147
|
+
acceptsSensitivity,
|
|
148
|
+
stream(req) {
|
|
149
|
+
return streamLlamaCppNode(
|
|
150
|
+
ensureModel,
|
|
151
|
+
acquireSession,
|
|
152
|
+
providerName,
|
|
153
|
+
options,
|
|
154
|
+
applyLlamaCppNodePreflight(req, capabilities),
|
|
155
|
+
);
|
|
156
|
+
},
|
|
157
|
+
async generate(req) {
|
|
158
|
+
const events = streamLlamaCppNode(
|
|
159
|
+
ensureModel,
|
|
160
|
+
acquireSession,
|
|
161
|
+
providerName,
|
|
162
|
+
options,
|
|
163
|
+
applyLlamaCppNodePreflight(req, capabilities),
|
|
164
|
+
);
|
|
165
|
+
const collected: string[] = [];
|
|
166
|
+
let usage: Usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
167
|
+
let finishReason: ProviderResponse['finishReason'] = 'stop';
|
|
168
|
+
let streamError: { message: string } | undefined;
|
|
169
|
+
for await (const event of events) {
|
|
170
|
+
if (event.type === 'text-delta') collected.push(event.delta);
|
|
171
|
+
if (event.type === 'error') streamError = event.error;
|
|
172
|
+
if (event.type === 'finish') {
|
|
173
|
+
usage = event.usage;
|
|
174
|
+
finishReason = event.finishReason;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// PS-4: a swallowed mid-stream error returned truncated text
|
|
178
|
+
// indistinguishable from success - and a never-throwing
|
|
179
|
+
// generate() bypasses withRetry / withFallback entirely.
|
|
180
|
+
if (streamError !== undefined) {
|
|
181
|
+
throw new ProviderHttpError({
|
|
182
|
+
providerName,
|
|
183
|
+
status: 0,
|
|
184
|
+
message: streamError.message,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
text: collected.join(''),
|
|
189
|
+
usage,
|
|
190
|
+
finishReason,
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function applyLlamaCppNodePreflight(
|
|
197
|
+
req: ProviderRequest,
|
|
198
|
+
capabilities: ProviderCapabilities,
|
|
199
|
+
): ProviderRequest {
|
|
200
|
+
const retention = resolveReasoningRetention({
|
|
201
|
+
...(req.reasoningRetention !== undefined ? { requested: req.reasoningRetention } : {}),
|
|
202
|
+
...(capabilities.reasoningContract !== undefined
|
|
203
|
+
? { contract: capabilities.reasoningContract }
|
|
204
|
+
: {}),
|
|
205
|
+
});
|
|
206
|
+
if (retention === 'pass-through-all') return req;
|
|
207
|
+
const filtered = applyReasoningPolicy({ messages: req.messages, retention });
|
|
208
|
+
if (filtered === req.messages) return req;
|
|
209
|
+
return { ...req, messages: filtered };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function* streamLlamaCppNode(
|
|
213
|
+
ensureModel: () => Promise<{ model: LlamaModelInstance }>,
|
|
214
|
+
acquireSession: SessionManager,
|
|
215
|
+
providerName: string,
|
|
216
|
+
options: LlamaCppNodeAdapterOptions,
|
|
217
|
+
req: ProviderRequest,
|
|
218
|
+
): AsyncIterable<ProviderEvent> {
|
|
219
|
+
const { model } = await ensureModel();
|
|
220
|
+
const lease = await acquireSession(
|
|
221
|
+
model,
|
|
222
|
+
typeof req.systemMessage === 'string' ? req.systemMessage : undefined,
|
|
223
|
+
);
|
|
224
|
+
const session = lease.session;
|
|
225
|
+
// W-096: feed the transcript as REAL chat history when the session
|
|
226
|
+
// supports it (node-llama-cpp v3 `setChatHistory`) and prompt ONLY
|
|
227
|
+
// the last user turn - the pre-fix pseudo-prompt double-templated the
|
|
228
|
+
// whole conversation inside one user turn, degrading chat-tuned GGUF
|
|
229
|
+
// models. Sessions without the setter (older fixtures, custom
|
|
230
|
+
// factories) keep the legacy renderPrompt path unchanged.
|
|
231
|
+
const historic = typeof session.setChatHistory === 'function';
|
|
232
|
+
let prompt: string;
|
|
233
|
+
if (historic) {
|
|
234
|
+
const { priorTurns, lastUserText } = buildChatHistory(req);
|
|
235
|
+
session.setChatHistory?.(priorTurns);
|
|
236
|
+
prompt = lastUserText;
|
|
237
|
+
} else {
|
|
238
|
+
prompt = renderPrompt(req);
|
|
239
|
+
}
|
|
240
|
+
// Prompt-token accounting stays the full-transcript proxy in both
|
|
241
|
+
// modes so the figure is comparable regardless of the wire path.
|
|
242
|
+
const promptTokens = lengthOf(model.tokenize(renderPrompt(req)));
|
|
243
|
+
yield {
|
|
244
|
+
type: 'stream-start',
|
|
245
|
+
metadata: {
|
|
246
|
+
providerName,
|
|
247
|
+
modelId: options.modelPath,
|
|
248
|
+
createdAt: new Date().toISOString(),
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
const pieces: string[] = [];
|
|
252
|
+
let errored = false;
|
|
253
|
+
let aborted = false;
|
|
254
|
+
try {
|
|
255
|
+
const streamOptions: { signal?: AbortSignal; maxTokens?: number; temperature?: number } = {};
|
|
256
|
+
if (req.signal !== undefined) streamOptions.signal = req.signal;
|
|
257
|
+
if (req.maxTokens !== undefined) streamOptions.maxTokens = req.maxTokens;
|
|
258
|
+
if (req.temperature !== undefined) streamOptions.temperature = req.temperature;
|
|
259
|
+
for await (const piece of session.promptStreamingResponse(prompt, streamOptions)) {
|
|
260
|
+
if (req.signal?.aborted) {
|
|
261
|
+
aborted = true;
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
if (typeof piece !== 'string' || piece.length === 0) continue;
|
|
265
|
+
pieces.push(piece);
|
|
266
|
+
yield { type: 'text-delta', delta: piece };
|
|
267
|
+
if (req.signal?.aborted) {
|
|
268
|
+
aborted = true;
|
|
269
|
+
break;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
errored = true;
|
|
274
|
+
yield { type: 'error', error: { kind: 'unknown', message: (err as Error).message } };
|
|
275
|
+
} finally {
|
|
276
|
+
lease.release();
|
|
277
|
+
}
|
|
278
|
+
// W-096: tokenize the ASSEMBLED response once - per-chunk tokenization
|
|
279
|
+
// over-counted at every chunk boundary (BPE merges span chunk seams).
|
|
280
|
+
const completionTokens = lengthOf(model.tokenize(pieces.join('')));
|
|
281
|
+
yield {
|
|
282
|
+
type: 'finish',
|
|
283
|
+
// PS-4: a mid-stream failure must not masquerade as a clean stop -
|
|
284
|
+
// partial text would be indistinguishable from success. PS-12: an
|
|
285
|
+
// aborted stream reports 'aborted', mirroring the HTTP adapters.
|
|
286
|
+
finishReason: errored ? 'error' : aborted ? 'aborted' : 'stop',
|
|
287
|
+
usage: {
|
|
288
|
+
promptTokens,
|
|
289
|
+
completionTokens,
|
|
290
|
+
totalTokens: promptTokens + completionTokens,
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Lease over a session: per-request leases dispose on release. */
|
|
296
|
+
interface SessionLease {
|
|
297
|
+
readonly session: LlamaSessionInstance;
|
|
298
|
+
release(): void;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
type SessionManager = (model: LlamaModelInstance, system?: string) => Promise<SessionLease>;
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* W-096: per-request sessions by default (create -> use -> dispose, the
|
|
305
|
+
* memory-safe core-provider-08 lifecycle); with `persistentSession:
|
|
306
|
+
* true` ONE cached session serialised by a promise mutex (a llama
|
|
307
|
+
* context sequence is single-threaded) whose history re-syncs via
|
|
308
|
+
* `setChatHistory` on every request. A cached session that turns out
|
|
309
|
+
* not to support `setChatHistory` degrades to per-request behaviour -
|
|
310
|
+
* without the setter its history cannot be made consistent.
|
|
311
|
+
*/
|
|
312
|
+
function buildSessionManager(
|
|
313
|
+
options: LlamaCppNodeAdapterOptions,
|
|
314
|
+
sessionFactory: (model: LlamaModelInstance, system?: string) => Promise<LlamaSessionInstance>,
|
|
315
|
+
): SessionManager {
|
|
316
|
+
if (options.persistentSession !== true) {
|
|
317
|
+
return async (model, system) => {
|
|
318
|
+
const session = await sessionFactory(model, system);
|
|
319
|
+
return {
|
|
320
|
+
session,
|
|
321
|
+
release: () => {
|
|
322
|
+
// Release the per-request context / KV cache (core-provider-08).
|
|
323
|
+
try {
|
|
324
|
+
session.dispose?.();
|
|
325
|
+
} catch {
|
|
326
|
+
// Disposal failures must never mask the stream outcome.
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
let cached: LlamaSessionInstance | null = null;
|
|
333
|
+
let queueTail: Promise<void> = Promise.resolve();
|
|
334
|
+
return async (model, system) => {
|
|
335
|
+
let unlock!: () => void;
|
|
336
|
+
const gate = new Promise<void>((resolve) => {
|
|
337
|
+
unlock = resolve;
|
|
338
|
+
});
|
|
339
|
+
const priorTail = queueTail;
|
|
340
|
+
queueTail = queueTail.then(() => gate);
|
|
341
|
+
await priorTail;
|
|
342
|
+
if (cached === null) cached = await sessionFactory(model, system);
|
|
343
|
+
const session = cached;
|
|
344
|
+
if (typeof session.setChatHistory !== 'function') {
|
|
345
|
+
// Cannot re-sync history across requests - fall back to the
|
|
346
|
+
// per-request lifecycle for THIS session and try again next call.
|
|
347
|
+
cached = null;
|
|
348
|
+
return {
|
|
349
|
+
session,
|
|
350
|
+
release: () => {
|
|
351
|
+
try {
|
|
352
|
+
session.dispose?.();
|
|
353
|
+
} catch {
|
|
354
|
+
// best-effort
|
|
355
|
+
}
|
|
356
|
+
unlock();
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return { session, release: () => unlock() };
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* W-096: split the request into node-llama-cpp chat-history turns plus
|
|
366
|
+
* the trailing user text (the prompt). The system message is included
|
|
367
|
+
* as a history item because `setChatHistory` REPLACES the session's
|
|
368
|
+
* whole history - including the slot the constructor `systemPrompt`
|
|
369
|
+
* seeded - so relying on the constructor alone would drop it. Tool
|
|
370
|
+
* roles cannot occur (`toolCalling: false`); a defensive skip keeps a
|
|
371
|
+
* stray one from corrupting the template.
|
|
372
|
+
*/
|
|
373
|
+
function buildChatHistory(req: ProviderRequest): {
|
|
374
|
+
readonly priorTurns: ReadonlyArray<LlamaChatHistoryItem>;
|
|
375
|
+
readonly lastUserText: string;
|
|
376
|
+
} {
|
|
377
|
+
const turns: LlamaChatHistoryItem[] = [];
|
|
378
|
+
if (typeof req.systemMessage === 'string' && req.systemMessage.length > 0) {
|
|
379
|
+
turns.push({ type: 'system', text: req.systemMessage });
|
|
380
|
+
}
|
|
381
|
+
for (const msg of req.messages) {
|
|
382
|
+
const text = textOf(msg);
|
|
383
|
+
if (msg.role === 'user') turns.push({ type: 'user', text });
|
|
384
|
+
else if (msg.role === 'assistant') turns.push({ type: 'model', response: [text] });
|
|
385
|
+
else if (msg.role === 'system') turns.push({ type: 'system', text });
|
|
386
|
+
}
|
|
387
|
+
const last = turns[turns.length - 1];
|
|
388
|
+
if (last !== undefined && last.type === 'user') {
|
|
389
|
+
turns.pop();
|
|
390
|
+
return { priorTurns: turns, lastUserText: last.text };
|
|
391
|
+
}
|
|
392
|
+
// No trailing user turn (continuation) - full history, empty prompt.
|
|
393
|
+
return { priorTurns: turns, lastUserText: '' };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function textOf(msg: ProviderRequest['messages'][number]): string {
|
|
397
|
+
return typeof msg.content === 'string'
|
|
398
|
+
? msg.content
|
|
399
|
+
: msg.content
|
|
400
|
+
.map((p) => (p.type === 'text' ? p.text : p.type === 'reasoning' ? p.text : ''))
|
|
401
|
+
.join('');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async function resolveModel(options: LlamaCppNodeAdapterOptions): Promise<LlamaModelInstance> {
|
|
405
|
+
const llama = await loadLlamaModule(options.runtimeOverrides);
|
|
406
|
+
return llama.loadModel({
|
|
407
|
+
modelPath: options.modelPath,
|
|
408
|
+
...(options.gpuLayers !== undefined ? { gpuLayers: options.gpuLayers } : {}),
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The REAL default session factory (PS-3): `model.createContext()` →
|
|
414
|
+
* `context.getSequence()` → `new LlamaChatSession({ contextSequence })`
|
|
415
|
+
* from the lazily-loaded peer, adapting its callback-streaming
|
|
416
|
+
* `prompt(text, { onTextChunk })` to the adapter's
|
|
417
|
+
* `promptStreamingResponse` AsyncIterable contract.
|
|
418
|
+
*/
|
|
419
|
+
async function defaultSessionFactory(
|
|
420
|
+
model: LlamaModelInstance,
|
|
421
|
+
system: string | undefined,
|
|
422
|
+
options: LlamaCppNodeAdapterOptions,
|
|
423
|
+
): Promise<LlamaSessionInstance> {
|
|
424
|
+
const ChatSession = await loadLlamaChatSessionCtor(options.runtimeOverrides);
|
|
425
|
+
const context = await model.createContext(
|
|
426
|
+
options.contextSize !== undefined ? { contextSize: options.contextSize } : undefined,
|
|
427
|
+
);
|
|
428
|
+
const sequence = context.getSequence();
|
|
429
|
+
const session = new ChatSession({
|
|
430
|
+
contextSequence: sequence,
|
|
431
|
+
...(system !== undefined ? { systemPrompt: system } : {}),
|
|
432
|
+
});
|
|
433
|
+
return {
|
|
434
|
+
promptStreamingResponse(prompt, streamOptions) {
|
|
435
|
+
return promptToIterable(session, prompt, streamOptions);
|
|
436
|
+
},
|
|
437
|
+
dispose() {
|
|
438
|
+
// Sequence first, then its owning context (core-provider-08).
|
|
439
|
+
try {
|
|
440
|
+
sequence.dispose?.();
|
|
441
|
+
} catch {
|
|
442
|
+
// fall through to the context
|
|
443
|
+
}
|
|
444
|
+
context.dispose?.();
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Bridge the peer's callback-streaming `prompt(...)` into the
|
|
451
|
+
* AsyncIterable the adapter consumes. A rejection from `prompt`
|
|
452
|
+
* rejects the pending/next iteration so the stream's error path
|
|
453
|
+
* (PS-4) observes it.
|
|
454
|
+
*/
|
|
455
|
+
function promptToIterable(
|
|
456
|
+
session: LlamaChatSessionPeer,
|
|
457
|
+
prompt: string,
|
|
458
|
+
streamOptions?: {
|
|
459
|
+
readonly signal?: AbortSignal;
|
|
460
|
+
readonly maxTokens?: number;
|
|
461
|
+
readonly temperature?: number;
|
|
462
|
+
},
|
|
463
|
+
): AsyncIterable<string> {
|
|
464
|
+
const queue: string[] = [];
|
|
465
|
+
let done = false;
|
|
466
|
+
let failure: unknown;
|
|
467
|
+
let wake: (() => void) | null = null;
|
|
468
|
+
const notify = (): void => {
|
|
469
|
+
if (wake !== null) {
|
|
470
|
+
const w = wake;
|
|
471
|
+
wake = null;
|
|
472
|
+
w();
|
|
473
|
+
}
|
|
474
|
+
};
|
|
475
|
+
void session
|
|
476
|
+
.prompt(prompt, {
|
|
477
|
+
...(streamOptions?.signal !== undefined ? { signal: streamOptions.signal } : {}),
|
|
478
|
+
...(streamOptions?.maxTokens !== undefined ? { maxTokens: streamOptions.maxTokens } : {}),
|
|
479
|
+
...(streamOptions?.temperature !== undefined
|
|
480
|
+
? { temperature: streamOptions.temperature }
|
|
481
|
+
: {}),
|
|
482
|
+
onTextChunk: (chunk: string) => {
|
|
483
|
+
if (typeof chunk === 'string' && chunk.length > 0) queue.push(chunk);
|
|
484
|
+
notify();
|
|
485
|
+
},
|
|
486
|
+
})
|
|
487
|
+
.then(
|
|
488
|
+
() => {
|
|
489
|
+
done = true;
|
|
490
|
+
notify();
|
|
491
|
+
},
|
|
492
|
+
(err: unknown) => {
|
|
493
|
+
failure = err instanceof Error ? err : new Error(String(err));
|
|
494
|
+
done = true;
|
|
495
|
+
notify();
|
|
496
|
+
},
|
|
497
|
+
);
|
|
498
|
+
return {
|
|
499
|
+
[Symbol.asyncIterator](): AsyncIterator<string> {
|
|
500
|
+
return {
|
|
501
|
+
async next(): Promise<IteratorResult<string>> {
|
|
502
|
+
for (;;) {
|
|
503
|
+
const head = queue.shift();
|
|
504
|
+
if (head !== undefined) return { done: false, value: head };
|
|
505
|
+
if (failure !== undefined) throw failure;
|
|
506
|
+
if (done) return { done: true, value: undefined };
|
|
507
|
+
await new Promise<void>((resolve) => {
|
|
508
|
+
wake = resolve;
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
},
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function renderPrompt(req: ProviderRequest): string {
|
|
518
|
+
const parts: string[] = [];
|
|
519
|
+
// `req.systemMessage` is deliberately NOT rendered here: the session
|
|
520
|
+
// factory already sets it as the chat-template `systemPrompt`, and
|
|
521
|
+
// rendering it again would show the model the system prompt twice
|
|
522
|
+
// (core-provider-08).
|
|
523
|
+
for (const msg of req.messages) {
|
|
524
|
+
const text =
|
|
525
|
+
typeof msg.content === 'string'
|
|
526
|
+
? msg.content
|
|
527
|
+
: msg.content
|
|
528
|
+
.map((p) => (p.type === 'text' ? p.text : p.type === 'reasoning' ? p.text : ''))
|
|
529
|
+
.join('');
|
|
530
|
+
parts.push(`[${msg.role}] ${text}`);
|
|
531
|
+
}
|
|
532
|
+
return parts.join('\n');
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function basename(path: string): string {
|
|
536
|
+
const idx = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
|
|
537
|
+
return idx === -1 ? path : path.slice(idx + 1);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function lengthOf(tokens: readonly number[] | Uint32Array | Uint8Array | null | undefined): number {
|
|
541
|
+
if (tokens === null || tokens === undefined) return 0;
|
|
542
|
+
return (tokens as { length?: number }).length ?? 0;
|
|
543
|
+
}
|
package/src/counter.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `LlamaCppNativeCounter` - token counter that wraps the loaded
|
|
3
|
+
* `node-llama-cpp` model's `tokenize(text)` function. The counter is
|
|
4
|
+
* strictly tighter than the cl100k_base proxy because it uses the
|
|
5
|
+
* exact tokenizer the GGUF file embeds.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Message, TokenCounter } from '@graphorin/core';
|
|
11
|
+
import { serialiseMessageForCount, serializedToString } from '@graphorin/provider/counters';
|
|
12
|
+
|
|
13
|
+
import type { LlamaModelInstance } from './runtime.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Options for {@link LlamaCppNativeCounter}.
|
|
17
|
+
*
|
|
18
|
+
* @stable
|
|
19
|
+
*/
|
|
20
|
+
export interface LlamaCppNativeCounterOptions {
|
|
21
|
+
readonly model: LlamaModelInstance;
|
|
22
|
+
readonly modelPath?: string;
|
|
23
|
+
readonly id?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Counter that delegates to `model.tokenize(text)` from the loaded
|
|
28
|
+
* GGUF instance. Cache invalidation is keyed on the model file path
|
|
29
|
+
* (when supplied) so swapping models invalidates per-message caches
|
|
30
|
+
* upstream.
|
|
31
|
+
*
|
|
32
|
+
* @stable
|
|
33
|
+
*/
|
|
34
|
+
export class LlamaCppNativeCounter implements TokenCounter {
|
|
35
|
+
readonly id: string;
|
|
36
|
+
readonly version: string;
|
|
37
|
+
readonly #model: LlamaModelInstance;
|
|
38
|
+
|
|
39
|
+
constructor(options: LlamaCppNativeCounterOptions) {
|
|
40
|
+
this.#model = options.model;
|
|
41
|
+
const fingerprint = options.modelPath !== undefined ? hash(options.modelPath) : 'unknown';
|
|
42
|
+
this.id = options.id ?? `llama-cpp-native@${fingerprint}`;
|
|
43
|
+
this.version = `llama-cpp-native-${fingerprint}-v1`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async count(messages: ReadonlyArray<Message>): Promise<number> {
|
|
47
|
+
let total = 0;
|
|
48
|
+
for (const msg of messages) {
|
|
49
|
+
const serialised = serialiseMessageForCount(msg);
|
|
50
|
+
total += this.#tokenLengthOf(serializedToString(serialised));
|
|
51
|
+
}
|
|
52
|
+
return total;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async countText(text: string): Promise<number> {
|
|
56
|
+
if (text.length === 0) return 0;
|
|
57
|
+
return this.#tokenLengthOf(text);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#tokenLengthOf(text: string): number {
|
|
61
|
+
if (text.length === 0) return 0;
|
|
62
|
+
const tokens = this.#model.tokenize(text);
|
|
63
|
+
if (tokens === null || tokens === undefined) return 0;
|
|
64
|
+
if (typeof (tokens as { length?: number }).length === 'number') {
|
|
65
|
+
return (tokens as { length: number }).length;
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hash(value: string): string {
|
|
72
|
+
// Lightweight deterministic fingerprint - the framework only needs
|
|
73
|
+
// it for cache invalidation, not for cryptographic purposes.
|
|
74
|
+
let h = 0;
|
|
75
|
+
for (let i = 0; i < value.length; i++) {
|
|
76
|
+
h = (h * 31 + value.charCodeAt(i)) | 0;
|
|
77
|
+
}
|
|
78
|
+
return Math.abs(h).toString(16);
|
|
79
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @graphorin/provider-llamacpp-node - in-process GGUF execution
|
|
3
|
+
* adapter for the Graphorin framework. The package wraps
|
|
4
|
+
* `node-llama-cpp@^3.5` to load `.gguf` model files directly into the
|
|
5
|
+
* same Node process - no daemon, no port to manage, no GPU contention
|
|
6
|
+
* with other processes.
|
|
7
|
+
*
|
|
8
|
+
* The adapter declares `trust: 'loopback'` permanently because the
|
|
9
|
+
* model lives in the same trust boundary as the host process; the
|
|
10
|
+
* symmetry mirrors `@graphorin/embedder-transformersjs` (in-process
|
|
11
|
+
* embedder; same trust boundary).
|
|
12
|
+
*
|
|
13
|
+
* The companion package is operationally simpler than the HTTP-shaped
|
|
14
|
+
* adapters but does NOT survive a process restart mid-stream - the
|
|
15
|
+
* model context lives in the process and is lost on exit. For HITL
|
|
16
|
+
* durable mid-stream resume, one of the HTTP-shaped adapters
|
|
17
|
+
* (`ollamaAdapter`, `llamaCppServerAdapter`, `openAICompatibleAdapter`)
|
|
18
|
+
* is the better choice.
|
|
19
|
+
*
|
|
20
|
+
* @packageDocumentation
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Canonical version constant, derived from `package.json` at build time. */
|
|
24
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
25
|
+
|
|
26
|
+
export const VERSION: string = pkg.version;
|
|
27
|
+
|
|
28
|
+
export { type LlamaCppNodeAdapterOptions, llamaCppNodeAdapter } from './adapter.js';
|
|
29
|
+
export {
|
|
30
|
+
LlamaCppNativeCounter,
|
|
31
|
+
type LlamaCppNativeCounterOptions,
|
|
32
|
+
} from './counter.js';
|
|
33
|
+
export type {
|
|
34
|
+
LlamaCppNodeRuntimeOverrides,
|
|
35
|
+
LlamaInstance,
|
|
36
|
+
LlamaModelInstance,
|
|
37
|
+
LlamaSessionInstance,
|
|
38
|
+
} from './runtime.js';
|