Text generation
LLM inference for text generation and chat — i.e., use a large language model to generate text output based on input prompts and context.
Overview
Text generation uses qvac-fabric-llm.cpp as inference engine. Load any supported model using modelType: "llm". Then, provide array history as input where each element is an object with properties:
role: string; can be either"user"or"assistant"content: string
role: "user" indicates that content is a previous prompt. role: "assistant" indicates that content is a previous inference (LLM output).
Output is generated based on the full sequence of messages provided in history.
Functions
Use the following sequence of function calls:
For how to use each function, see SDK — API reference.
Models
You can load any llama.cpp-compatible text-generation/chat model. Model file format: *.gguf.
- If the model is sharded across multiple files (a multi-file bundle), see Sharded models.
- To adapt a model to domain-specific tasks, see Fine-tuning.
- For models available as constants, see SDK — Models.
Features
- Event stream:
completion()exposes a single orderedeventsasync iterable plus an aggregatedfinalpromise. Events are discriminated bytype—contentDelta,thinkingDelta,toolCall,toolError,completionStats,completionDone,rawDelta. The terminalcompletionDoneevent carries astopReason(e.g."eos","length","cancelled"). - Thinking content: models that emit
<think>blocks surface them as dedicatedthinkingDeltaevents (enable withcaptureThinking: true), so consumers don't have to parse tags from raw text. - Tool calls: the model emits structured tool calls as
toolCallevents ordered alongside content and thinking in the same stream. - MCP: plug MCP servers into
completion()so the model can use external tools (e.g., web search) via the same tool-call mechanism. - Raw output: with
emitRawDeltas: true, every raw model token is also emitted as arawDeltaevent in parallel to the structured events — useful for debugging or full-fidelity logging. - KV cache: cache and reuse the model's key/value attention state to speed up follow-up turns in long conversations. The
kvCacheparameter sets the cache key — pass a string to manage a session manually, ortrueto let the SDK auto-generate one. - Multimodal: attach images (and other media) to prompts so the model reasons over text and images together in the same conversation — see Multimodal.
- Multi-job continuous batching: load with
modelConfig.parallel >= 2(default1) to run severalcompletion()calls in parallel on one loaded model. Up toparallelprompts finish in roughly the time of the slowest one (extras queue), and each call has its ownrequestIdso you can cancel one without touching the others — see Concurrent completions. - Batch processing: run multiple prompts through a single loaded model in one call with
batchCompletion()— optimizes resources and reduces total run time. See Batch processing.
Examples
Usage
The canonical way to consume completion() is the events async iterable plus the aggregated final promise. The following script shows how to handle each event type and read the aggregated result:
/**
* Event-driven completion — demonstrates the unified `CompletionEvent` stream.
*
* `completion()` returns a `CompletionRun` with two primary surfaces:
*
* - `events` — an `AsyncIterable<CompletionEvent>` of ordered, typed events
* (`contentDelta`, `thinkingDelta`, `toolCall`, `toolError`,
* `completionStats`, `completionDone`, `rawDelta`).
* - `final` — a `Promise<CompletionFinal>` that resolves once the stream
* ends, providing aggregated `contentText`, `thinkingText`,
* `toolCalls`, `stats`, and `raw.fullText`.
*
* Set `captureThinking: true` to attempt best-effort `<think>` block parsing
* into dedicated `thinkingDelta` events. `final.raw.fullText` keeps the exact
* model output.
*/
import { completion, loadModel, unloadModel, QWEN3_600M_INST_Q4 } from '@qvac/sdk';
try {
const modelId = await loadModel({
modelSrc: QWEN3_600M_INST_Q4,
modelConfig: { ctx_size: 4096 },
onProgress: (p) => {
const mb = (n) => (n / 1e6).toFixed(1);
const line = `▸ Downloading ${p.percentage.toFixed(0)}% (${mb(p.downloaded)}/${mb(p.total)} MB)`;
process.stderr.write(process.stderr.isTTY ? `\r${line}` : `${line}\n`);
if (p.percentage >= 100)
process.stderr.write('\n');
}
});
console.log(`▸ Model loaded: ${modelId}`);
const result = completion({
modelId,
history: [{ role: 'user', content: 'Explain quantum computing in 2 sentences' }],
stream: true,
captureThinking: true
});
for await (const event of result.events) {
handleEvent(event);
}
const final = await result.final;
console.log('\n▸ Final result');
console.log(`▸ Content: ${final.contentText}`);
if (final.thinkingText) {
console.log(`▸ Thinking: ${final.thinkingText}`);
}
if (final.stats) {
console.log(`▸ ${final.stats.tokensPerSecond?.toFixed(1)} tok/s`);
}
if (final.toolCalls.length > 0) {
console.log(`▸ Tool calls: ${final.toolCalls.map((c) => c.name).join(', ')}`);
}
if (final.stopReason) {
console.log(`▸ Stop reason: ${final.stopReason}`);
}
console.log(`▸ Raw output length: ${final.raw.fullText.length} chars`);
await unloadModel({ modelId, clearStorage: false });
}
catch (error) {
console.error('✖', error);
process.exit(1);
}
function handleEvent(event) {
switch (event.type) {
case 'contentDelta':
process.stdout.write(event.text);
break;
case 'thinkingDelta':
process.stdout.write(`\x1b[2m${event.text}\x1b[0m`);
break;
case 'toolCall':
console.log(`\n▸ Tool: ${event.call.name}(${JSON.stringify(event.call.arguments)})`);
break;
case 'toolError':
console.log(`\n✖ Tool error [${event.error.code}]: ${event.error.message}`);
break;
case 'completionStats':
console.log(`\n▸ ${event.stats.tokensPerSecond?.toFixed(1)} tok/s`);
break;
case 'completionDone':
if (event.stopReason === 'error' && 'error' in event) {
console.log(`\n✖ ${event.error.message}`);
}
break;
case 'rawDelta':
break;
}
}The examples below (Tool call, MCP, KV cache) still consume result.tokenStream and result.toolCallStream, which are convenience wrappers around the canonical events / final stream shown above. Both APIs are supported; new code should prefer events / final.
Concurrent completions
Use this when you have more than one prompt in flight against the same loaded model at the same time — for example, serving several users, or fanning out related sub-tasks — and want them to finish together instead of one after another.
Load the model with modelConfig.parallel >= 2, then call completion() several times without awaiting between calls. The SDK runs up to parallel of them together on the model, so up to parallel prompts finish in roughly the time of the slowest one instead of the sum of all of them; beyond that they queue. Each call has its own requestId; cancel({ requestId }) stops just that call and its peers keep going — useful for a per-request stop button, rather than killing the whole model.
The default is 1 (one request at a time), so you have to set parallel explicitly to enable batching. Also raise ctx_size with it: the model's context window is split evenly across the parallel slots, so parallel: 4 on a ctx_size: 4096 model gives each request only ~1024 tokens. Size ctx_size for parallel × per-request context.
completion() and batchCompletion() share the model's parallel slots. Each call is one request — a big batchCompletion({ prompts: N }) never blocks more than one concurrent completion() from starting — but every prompt in the batch takes one decode slot, so a large batch fills all parallel slots itself and any concurrent completion() you launch will only start decoding once the batch has room. If you already have every prompt ready up front, batchCompletion() is often simpler; use concurrent completion() when prompts arrive over time or you want independent streams and cancellation per call.
The following script loads a parallel: 4 model, fires four completions at once, and then cancels one of two long runs to show its peer keeps decoding:
/**
* Multi-job continuous batching — concurrent completions on one loaded LLM.
*
* Unlike `batchCompletion()` (which bundles many prompts into ONE call), this
* shows independent `completion()` calls fired concurrently against the SAME
* loaded model. When the model is loaded with `modelConfig.parallel >= 2`, the
* SDK admits up to `parallel` completions at once and the llama.cpp addon
* decodes their sequences together — so a burst of prompts finishes in roughly
* the time of the slowest one, not the sum of all of them.
*
* Each `completion()` returns an independent `CompletionRun`:
* - `events` — an `AsyncIterable` of streamed events for that call.
* - `final` — a `Promise<CompletionFinal>` with THIS call's own
* `contentText` and `stats` (its own tokens/sec, TTFT).
* `stats.avgConcurrentSeq` stays model-level (how busy the
* shared backend was).
* - `requestId` — cancel just this call with `cancel({ requestId })`; peers
* keep decoding.
*
* Completions and batches share the model's `parallel` admission cap: with
* `parallel` in flight, admitting one more waits FIFO until a slot frees.
*
* Run from packages/sdk:
* bun run examples/multi-job-completion.ts
*/
import { completion, cancel, loadModel, unloadModel, InferenceCancelledError, LLAMA_3_2_1B_INST_Q4_0 } from '@qvac/sdk';
const PROMPTS = [
{ id: 'cherry', ask: 'Reply with only the word CHERRY.' },
{ id: 'banana', ask: 'Reply with only the word BANANA.' },
{ id: 'grape', ask: 'Reply with only the word GRAPE.' },
{ id: 'lemon', ask: 'Reply with only the word LEMON.' }
];
try {
// `parallel: 4` opens the concurrent decode slots continuous batching needs.
const modelId = await loadModel({
modelSrc: LLAMA_3_2_1B_INST_Q4_0,
modelType: 'llm',
modelConfig: { ctx_size: 4096, parallel: 4 },
onProgress: (p) => {
const mb = (n) => (n / 1e6).toFixed(1);
const line = `▸ Downloading ${p.percentage.toFixed(0)}% (${mb(p.downloaded)}/${mb(p.total)} MB)`;
process.stderr.write(process.stderr.isTTY ? `\r${line}` : `${line}\n`);
if (p.percentage >= 100)
process.stderr.write('\n');
}
});
console.log(`▸ Model loaded: ${modelId}`);
// ---- Concurrent completions. Fire every call before awaiting any, so all
// four are admitted and decode together (not one-after-another). ----
console.log('\n▸ Firing 4 completions concurrently:');
const started = Date.now();
const runs = PROMPTS.map(({ ask }) => completion({
modelId,
history: [{ role: 'user', content: ask }],
generationParams: { temp: 0, seed: 42, predict: 16 },
stream: true
}));
const outputs = await Promise.all(runs.map(async (run, i) => {
const final = await run.final;
return {
id: PROMPTS[i].id,
text: final.contentText.replace(/\s+/g, ' ').trim(),
stats: final.stats
};
}));
const elapsed = Date.now() - started;
for (const { id, text, stats } of outputs) {
const tps = stats?.tokensPerSecond?.toFixed(1) ?? 'n/a';
console.log(` ▸ ${id}: "${text}" (own ${tps} tok/s)`);
}
// Report the engine's own measure of overlap rather than inferring it from
// wall-clock: avgConcurrentSeq > 1 means the addon decoded sequences together.
const peakConcurrent = Math.max(...outputs.map((o) => o.stats?.avgConcurrentSeq ?? 0));
console.log(`\n▸ All 4 finished in ${elapsed} ms; engine avg concurrent sequences: ${peakConcurrent.toFixed(1)} (> 1 = decoded together).`);
// ---- Per-request cancel isolation. Fire two long runs; cancel only the
// first by its requestId. The second must keep decoding past the cancel. ----
console.log('\n▸ Per-request cancel: cancelling one of two in-flight completions:');
const doomed = completion({
modelId,
history: [{ role: 'user', content: 'Write a long story about an otter.' }],
generationParams: { temp: 0, seed: 1, predict: 256 },
stream: true
});
const survivor = completion({
modelId,
history: [{ role: 'user', content: 'Write a long story about a melon.' }],
generationParams: { temp: 0, seed: 42, predict: 256 },
stream: true
});
// Consume the survivor's stream directly so we can count tokens produced AFTER
// the cancel is acknowledged — a short survivor could otherwise finish before
// the cancel lands and pass even under a model-wide cancel.
const survivorTokens = survivor.events[Symbol.asyncIterator]();
// Wait until BOTH runs are actually streaming before cancelling, so the cancel
// hits a genuinely in-flight peer, not a pre-admission drop.
await Promise.all([doomed.events[Symbol.asyncIterator]().next(), survivorTokens.next()]);
await cancel({ requestId: doomed.requestId });
let doomedCancelled = false;
try {
await doomed.final;
}
catch (err) {
doomedCancelled = err instanceof InferenceCancelledError;
}
// Drain what the survivor emits from here on: a per-request cancel leaves it
// decoding to completion, a model-wide cancel would have stopped it.
let tokensAfterCancel = 0;
for (let next = await survivorTokens.next(); !next.done; next = await survivorTokens.next()) {
tokensAfterCancel++;
}
const survivorText = (await survivor.final).contentText.replace(/\s+/g, ' ').trim();
console.log(` ▸ cancelled run -> ${doomedCancelled ? 'cancelled' : 'NOT cancelled'}`);
console.log(` ▸ peer kept decoding -> +${tokensAfterCancel} tokens after the cancel ack`);
await unloadModel({ modelId, clearStorage: false });
// A zero exit must prove concurrent scheduling, typed cancellation, and survivor
// isolation: the first burst reported multiple resident sequences, the doomed
// run cancelled, and its peer produced tokens AFTER the cancel landed.
if (peakConcurrent <= 1 || !doomedCancelled || tokensAfterCancel === 0) {
console.error(`✖ multi-job behavior not demonstrated (peakConcurrent=${peakConcurrent.toFixed(2)}, doomedCancelled=${doomedCancelled}, tokensAfterCancel=${tokensAfterCancel}, survivorText="${survivorText}")`);
process.exit(1);
}
process.exit(0);
}
catch (error) {
console.error('✖', error);
process.exit(1);
}Tool call
The following script shows how to provide tool definitions to completion(), consume the streaming output, and read the parsed tool calls.
import { completion, loadModel, unloadModel, QWEN3_1_7B_INST_Q4 } from '@qvac/sdk';
import { tools, toolSchemas, mockExecute } from './shared';
try {
const modelId = await loadModel({
modelSrc: QWEN3_1_7B_INST_Q4,
modelConfig: {
ctx_size: 4096,
tools: true
},
onProgress: (p) => {
const mb = (n) => (n / 1e6).toFixed(1);
const line = `▸ Downloading ${p.percentage.toFixed(0)}% (${mb(p.downloaded)}/${mb(p.total)} MB)`;
process.stderr.write(process.stderr.isTTY ? `\r${line}` : `${line}\n`);
if (p.percentage >= 100)
process.stderr.write('\n');
}
});
console.log(`▸ Model loaded successfully! Model ID: ${modelId}`);
const history = [
{
role: 'system',
content: 'You are a helpful assistant that can use tools to get the weather and horoscope.'
},
{
role: 'user',
content: "What's the weather in Tokyo and my horoscope for Aquarius?"
}
];
console.log('\n▸ AI Response:');
console.log('▸ (Streaming with tool definitions in prompt)\n');
const result = completion({ modelId, history, stream: true, tools });
const tokensTask = (async () => {
for await (const token of result.tokenStream) {
process.stdout.write(token);
}
})();
const toolsTask = (async () => {
for await (const evt of result.toolCallStream) {
console.log(`\n\n▸ Tool Call Detected: ${evt.call.name}(${JSON.stringify(evt.call.arguments)})`);
console.log(`▸ ID: ${evt.call.id}`);
}
})();
await Promise.all([tokensTask, toolsTask]);
const stats = await result.stats;
const toolCalls = await result.toolCalls;
console.log('\n\n▸ Parsed Tool Calls:');
if (toolCalls.length > 0) {
for (const call of toolCalls) {
console.log(`▸ ${call.name}(${JSON.stringify(call.arguments)})`);
const schema = toolSchemas[call.name];
if (schema) {
const validated = schema.safeParse(call.arguments);
if (validated.success) {
console.log(`▸ Arguments validated with Zod`);
}
else {
console.log(`✖ Validation failed:`, validated.error);
}
}
}
}
else {
console.log('▸ No tool calls detected in response');
}
console.log('\n▸ Performance Stats:', stats);
if (toolCalls.length > 0) {
console.log('\n\n▸ Simulating Tool Execution...');
const toolResults = toolCalls.map((call) => {
const result = mockExecute(call.name, call.arguments);
console.log(`▸ ${call.name}: ${result}`);
return { toolCallId: call.id, result };
});
history.push({
role: 'assistant',
content: await result.text
});
for (const toolResult of toolResults) {
history.push({
role: 'tool',
content: toolResult.result
});
}
console.log('\n\n▸ Follow-up Response with Tool Results:');
const followUpResult = completion({
modelId,
history,
stream: true,
tools
});
for await (const token of followUpResult.tokenStream) {
process.stdout.write(token);
}
const followUpStats = await followUpResult.stats;
console.log('\n\n▸ Follow-up Stats:', followUpStats);
}
console.log('\n\n▸ Completed!');
await unloadModel({ modelId, clearStorage: false });
}
catch (error) {
console.error('✖', error);
process.exit(1);
}Worker-orchestrated tool loop
Python also exposes an advanced worker-orchestrated path, where the worker runs the whole multi-turn tool loop and calls back into your local handlers. Use this only when you need the worker to own the tool loop rather than managing each turn in application code:
"""Worker-orchestrated tool loop — mirrors the SDK's server-side tool loop
(beyond the client-side loop in completion_tools.py).
`completion_orchestrate()` hands the multi-turn tool loop to the WORKER: when
the model asks for a tool, the worker emits a callback frame, this client runs
the registered `handler`, writes the result back, and generation continues —
no manual history stitching. Every tool needs a `handler` (it runs code on
this machine, so only a local worker may orchestrate).
Advanced path: `completion_orchestrate` lives in `tetherto.qvac_sdk._completion` rather than
the flat `tetherto.qvac_sdk` surface. The public tool API is `completion(tools=...)` (the
client-side loop in completion_tools.py), matching the JS SDK; worker
orchestration has no JS client wrapper yet.
RUN: python examples/completion_orchestrate.py
"""
from __future__ import annotations
import asyncio
import sys
from tetherto.qvac_sdk import Client, load_model, unload_model
from tetherto.qvac_sdk._completion import completion_orchestrate
from tetherto.qvac_sdk.models import QWEN3_1_7B_INST_Q4
def print_progress(p) -> None:
"""Print model download progress; pass as `on_progress=` to `load_model`."""
line = (
f"▸ Downloading {p.percentage:.0f}% "
f"({p.downloaded / 1e6:.1f}/{p.total / 1e6:.1f} MB)"
)
print(line, end="\r" if sys.stderr.isatty() else "\n", file=sys.stderr)
if p.percentage >= 100:
print(file=sys.stderr)
async def get_weather(arguments) -> str:
return f"The weather in {arguments.get('location')} is 22°C and sunny."
async def get_horoscope(arguments) -> str:
return f"{arguments.get('sign')}: today favours careful validation."
TOOLS = [
{
"name": "get_weather",
"description": "Get the current weather for a location",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
"handler": get_weather,
},
{
"name": "get_horoscope",
"description": "Get the horoscope for a star sign",
"parameters": {
"type": "object",
"properties": {"sign": {"type": "string"}},
"required": ["sign"],
},
"handler": get_horoscope,
},
]
async def main() -> int:
async with Client() as client:
t = client.transport
try:
model_id = await load_model(
t,
model_src=QWEN3_1_7B_INST_Q4,
model_config={"ctx_size": 4096, "tools": True},
on_progress=print_progress,
)
print(f"▸ Model loaded: {model_id}\n")
run = completion_orchestrate(
t,
model_id=model_id,
history=[
{
"role": "user",
"content": "What's the weather in Tokyo and my horoscope for Aquarius?",
}
],
tools=TOOLS,
max_tool_turns=4,
)
async for event in run.events:
if event.type == "contentDelta":
sys.stdout.write(event.text)
sys.stdout.flush()
final = await run.final
print("\n\n▸ Final answer:", final.content_text)
await unload_model(t, model_id)
except Exception as error:
print(f"✖ {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))MCP
You create and manage the MCP client, connect it to one or more MCP servers, and pass it to completion(). The following script shows how to attach an MCP client to completion() so the model can call a web search tool and then continue with the results:
/**
* MCP DuckDuckGo Search Example
*
* A web search example using DuckDuckGo - no API key required!
* The server provides tools to search the web and get answers.
*
* Prerequisites:
* - Install MCP SDK: bun add @modelcontextprotocol/sdk
*
* Run with: bun run examples/mcp-websearch.ts
*/
import { completion, loadModel, unloadModel, QWEN3_1_7B_INST_Q4 } from '@/index';
// MCP SDK is a user-installed optional dependency
// Install with: bun add @modelcontextprotocol/sdk
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
function parseSearchResults(mcpResult) {
try {
const result = mcpResult;
// Extract text content from MCP response
const textContent = result.content?.find((c) => c.type === 'text');
if (!textContent?.text) {
return JSON.stringify(mcpResult);
}
// Parse the JSON array of search results
const rawResults = JSON.parse(textContent.text);
// Extract just the useful fields (title, url, snippet)
const cleanResults = rawResults.slice(0, 5).map((r) => ({
title: r.title ?? 'Unknown',
url: r.url ?? '',
snippet: r.snippet ?? ''
}));
// Format as concise text for LLM
return cleanResults
.map((r, i) => `[${i + 1}] ${r.title}\n URL: ${r.url}\n ${r.snippet}`)
.join('\n\n');
}
catch {
// If parsing fails, return a truncated version
const str = typeof mcpResult === 'string' ? mcpResult : JSON.stringify(mcpResult);
return str.slice(0, 2000);
}
}
let mcpClient = null;
try {
console.log('▸ MCP DuckDuckGo Search Example\n');
// ============================================================
// STEP 1: Connect to DuckDuckGo MCP server
// ============================================================
console.log('▸ Starting DuckDuckGo MCP server...');
mcpClient = new Client({
name: 'qvac-ddg-example',
version: '1.0.0'
});
const transport = new StdioClientTransport({
command: 'npx',
args: ['-y', '@oevortex/ddg_search']
});
await mcpClient.connect(transport);
console.log('▸ MCP server connected\n');
// ============================================================
// STEP 2: Load model
// ============================================================
console.log('▸ Loading model...');
const modelId = await loadModel({
modelSrc: QWEN3_1_7B_INST_Q4,
modelConfig: {
ctx_size: 4096,
tools: true
},
onProgress: (p) => {
const mb = (n) => (n / 1e6).toFixed(1);
const line = `▸ Downloading ${p.percentage.toFixed(0)}% (${mb(p.downloaded)}/${mb(p.total)} MB)`;
process.stderr.write(process.stderr.isTTY ? `\r${line}` : `${line}\n`);
if (p.percentage >= 100)
process.stderr.write('\n');
}
});
console.log(`\n▸ Model loaded\n`);
// ============================================================
// STEP 3: Ask AI to search the web (with MCP client)
// ============================================================
const history = [
{
role: 'system',
content: `You are a helpful assistant with access to web search.
Use the search tool when you need current information.
Always cite your sources with the URL.`
},
{
role: 'user',
content: 'What is the current weather in New York City?'
}
];
console.log('▸ Asking AI to search the web...\n');
console.log('▸ AI Response:');
// Pass MCP client directly to completion - tools are adapted internally!
const result = completion({
modelId,
history,
stream: true,
mcp: [{ client: mcpClient, includeResources: false }]
});
for await (const token of result.tokenStream) {
process.stdout.write(token);
}
const toolCalls = await result.toolCalls;
console.log('\n');
// ============================================================
// STEP 4: Execute tool calls using call() - automatic MCP routing!
// ============================================================
if (toolCalls.length > 0) {
console.log('▸ Executing search...\n');
const toolResults = [];
for (const toolCall of toolCalls) {
console.log(`▸ ${toolCall.name}(${JSON.stringify(toolCall.arguments)})`);
if (!toolCall.invoke) {
console.log(`▸ No handler found for tool "${toolCall.name}"`);
continue;
}
// Use invoke() - automatically routes to the correct MCP client!
const mcpResult = await toolCall.invoke();
// Parse and clean up the search results
const cleanResult = parseSearchResults(mcpResult);
console.log(`▸ Got search results:`);
console.log(cleanResult
.split('\n')
.map((l) => ` ${l}`)
.join('\n'));
console.log();
toolResults.push({ id: toolCall.id, result: cleanResult });
}
// ============================================================
// STEP 5: Continue with search results
// ============================================================
console.log('▸ Getting AI response with search results...\n');
history.push({
role: 'assistant',
content: await result.text
});
for (const tr of toolResults) {
history.push({
role: 'tool',
content: tr.result
});
}
console.log('▸ Final Response:');
const finalResult = completion({
modelId,
history,
stream: true,
mcp: [{ client: mcpClient, includeResources: false }]
});
for await (const token of finalResult.tokenStream) {
process.stdout.write(token);
}
console.log('\n');
}
// ============================================================
// Cleanup
// ============================================================
console.log('▸ Cleaning up...');
await unloadModel({ modelId, clearStorage: false });
console.log('▸ Done\n');
console.log('▸ Example completed!');
process.exit(0);
}
catch (error) {
console.error('✖', error);
process.exit(1);
}
finally {
if (mcpClient) {
try {
await mcpClient.close();
}
catch {
// Ignore close errors
}
}
}KV cache
The kvCache parameter on completion() both enables caching and sets the cache key. It accepts:
- A non-empty string (e.g.
"user-123-session-a") — use this key for a caller-managed cache. You control the session identity, so you can reuse the same key across turns and later clean it up withdeleteCache({ kvCacheKey }). true— SDK-managed auto cache. The SDK generates the key internally from the conversation history. Convenient for single-session use, but you don't get a stable key for manual cleanup.falseorundefined— no caching (default).
SDK-managed auto caches are removed after 24 hours idle or under disk quota pressure. The quota is 512 MiB on React Native and 4 GiB on other runtimes. Caller-managed string-key caches are not subject to this automatic retention policy.
There is no separate kvCacheKey completion parameter — when you want a caller-managed cache, pass the key string directly as kvCache:
completion({
modelId,
history,
stream: true,
kvCache: "user-123-session-a",
});The following script enables kvCache: true to speed up follow-up turns, and then compares it with kvCache: false on the same history:
import { completion, LLAMA_3_2_1B_INST_Q4_0, loadModel, unloadModel, VERBOSITY } from '@qvac/sdk';
try {
// Load the model
const modelId = await loadModel({
modelSrc: LLAMA_3_2_1B_INST_Q4_0,
modelConfig: {
device: 'gpu',
ctx_size: 2048,
verbosity: VERBOSITY.ERROR
}
});
console.log('▸ Testing KV Cache functionality...\n');
// First conversation with auto-keyed cache enabled
console.log('▸ First conversation (building cache for the next turn):');
const history1 = [{ role: 'user', content: 'What is the capital of France?' }];
const result1 = completion({
modelId,
history: history1,
stream: true,
kvCache: true
}); // kvCache = true
for await (const token of result1.tokenStream) {
process.stdout.write(token);
}
const final1 = await result1.final;
const stats1 = final1.stats;
console.log(`\n▸ First completion stats: ${JSON.stringify(stats1)}\n`);
// Continue conversation (should reuse the completed first-turn cache).
console.log('▸ Continuing conversation (reusing previous turn cache):');
const history2 = [
{ role: 'user', content: 'What is the capital of France?' },
{
role: 'assistant',
content: final1.cacheableAssistantContent ?? final1.contentText
},
{ role: 'user', content: 'What about Germany?' }
];
// Auto-keyed caching should:
// 1. Find the cache saved after turn 1 under [user, assistant]
// 2. Load that cache and process only the new "What about Germany?" user turn
// 3. Save the updated cache and rename it to include the new assistant response
const result2 = completion({
modelId,
history: history2,
stream: true,
kvCache: true
}); // kvCache = true
for await (const token of result2.tokenStream) {
process.stdout.write(token);
}
const stats2 = await result2.stats;
console.log(`\n▸ Second completion stats: ${JSON.stringify(stats2)}\n`);
// Compare with non-cached version
console.log('▸ Same conversation without cache:');
const result3 = completion({
modelId,
history: history2,
stream: true,
kvCache: false
}); // kvCache = false
for await (const token of result3.tokenStream) {
process.stdout.write(token);
}
const stats3 = await result3.stats;
console.log(`\n▸ Non-cached completion stats: ${JSON.stringify(stats3)}\n`);
console.log('▸ KV Cache test completed!');
await unloadModel({ modelId, clearStorage: false });
}
catch (error) {
console.error('✖', error);
process.exit(1);
}Tip: all examples throughout this documentation are self-contained and runnable. For instructions on how to run them, see the JS/TS quickstart or the Python quickstart.