@graphorin/provider-llamacpp-node 0.6.0 → 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/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';