Rand Stats

LLM::Chat

zef:apogee

Actions Status

LLM::Chat

Simple framework for LLM inferencing in Raku. Supports multiple backends (OpenAI-compatible, KoboldCpp), chat templates (ChatML, Llama 3/4, Mistral, Gemma 2, and any HuggingFace Jinja2 template), conversation management with context shifting, and token counting.

Synopsis

use LLM::Chat::Backend::KoboldCpp;
use LLM::Chat::Template::ChatML;
use LLM::Chat::Conversation;

my $backend = LLM::Chat::Backend::KoboldCpp.new(
    api_url  => 'http://localhost:5001/v1',
    template => LLM::Chat::Template::ChatML.new,
);

my $conv = LLM::Chat::Conversation.new;
$conv.add-message('user', 'Hello!');

my $response = $backend.text-completion($conv.messages);

Templates

Built-in Templates

use LLM::Chat::Template::ChatML;
use LLM::Chat::Template::Llama3;
use LLM::Chat::Template::Llama4;
use LLM::Chat::Template::MistralV7;
use LLM::Chat::Template::Gemma2;
use LLM::Chat::Template::DeepSeekV4;

my $template = LLM::Chat::Template::ChatML.new;

DeepSeek V4

DeepSeek V4 publishes a Python message encoder instead of a Jinja chat template. LLM::Chat::Template::DeepSeekV4 implements the prompt-encoding half of that protocol (not completion parsing):

use LLM::Chat::Template::DeepSeekV4;

my $template = LLM::Chat::Template::DeepSeekV4.new;
my $prompt = $template.render(@messages);

# Full upstream message shape, retaining JSON member order:
my $prompt = $template.encode-json(
    $messages-json,
    thinking-mode    => 'thinking',
    reasoning-effort => 'high',
);

encode-messages accepts associative structures and supports context, thinking removal, tools, DSML calls/results, response formats, developer and latest-reminder roles, and quick tasks. encode-json is preferable when the exact JSON wire-member order must be reflected in the rendered prompt.

Jinja2 Templates (HuggingFace)

Load any HuggingFace chat_template directly from a tokenizer_config.json:

use LLM::Chat::Template::Jinja2;

# From tokenizer_config.json
my $json = 'tokenizer_config.json'.IO.slurp;
my $template = LLM::Chat::Template::Jinja2.from-tokenizer-config($json);

# Or provide the template string directly
my $template = LLM::Chat::Template::Jinja2.new(
    template  => $jinja2-string,
    bos-token => '<s>',
    eos-token => '</s>',
);

The Jinja2 template support is powered by Template::Jinja2, a complete Jinja2 engine for Raku with byte-identical output to Python Jinja2.

Backends

KoboldCpp

use LLM::Chat::Backend::KoboldCpp;

my $backend = LLM::Chat::Backend::KoboldCpp.new(
    api_url   => 'http://localhost:5001/v1',
    template  => $template,  # for text completions
    max_tokens => 200,
);

KoboldCpp reports a generation that ended on one of your stop strings as finish_reason: "length" — the same as one that ran out of budget. When a completion finishes with length, this backend asks KoboldCpp's GET /api/extra/perf why it stopped, and finishes it as stop when that was a stop string and the token counts match (or, with no usage to match, are well under max_tokens). The wire value stays readable:

$resp.finish-reason;            # 'stop'
$resp.provider-finish-reason;   # 'length'
$resp.finish-reason-evidence;   # { source, verdict, note, stop_reason, last_token_count, ... }

Anything short of a clear answer leaves length. The check is bounded (verify-length-finish-timeout, 2 seconds) and can be turned off with verify-length-finish => False. The rules and their limits are in LLM::Chat::Backend::KoboldCpp.

OpenAI-compatible

Any OpenAI-compatible API (vLLM, Ollama, etc.):

use LLM::Chat::Backend::OpenAICommon;

my $backend = LLM::Chat::Backend::OpenAICommon.new(
    api_url => 'http://localhost:8000/v1',
    model   => 'my-model',
);

Message normalization

Some chat templates (Mistral's, among others) refuse any conversation that is not an optional system message followed by strictly alternating user and assistant turns — and llama.cpp or KoboldCpp --jinja run the model's own template. Every OpenAI-compatible backend can reshape the outgoing conversation to fit:

my $backend = LLM::Chat::Backend::KoboldCpp.new(
    api_url               => 'http://localhost:5001/v1',
    message-normalization => 'alternate-roles',   # default 'none'
);

Adjacent plain messages of one role merge (contents joined with a blank line), the leading system messages become one, and a system message later in the conversation becomes user text under a [System message] marker line, merged into the user turn beside it. Tool calls and tool results are never merged or crossed; a user message straight after tool results, which such a template reads as the user speaking twice, gets an assistant turn in front of it saying tool-result-acknowledgement ('Understood.'; '' for none). Nothing is reordered, and the result is a pure function of the messages — a repeated conversation prefix normalizes to the same bytes, so prompt caches keep matching. It applies to the chat paths' messages and to the messages the text paths render through their template. The full rules are in LLM::Chat::Backend::OpenAICommon.

Structured output and field order

Set json_schema on the settings to constrain the reply's shape. It goes out as response_format on OpenAI-compatible backends and as the native json_schema field on KoboldCpp. Servers that compile the schema into a grammar (llama.cpp, KoboldCpp) make the model write an object's fields in the order the schema lists them, and LLM::Chat lists a schema's properties in the order of its required array, then any optional properties alphabetically — so the order of required is the order the model writes the fields:

my $settings = LLM::Chat::Backend::Settings.new(
    json_schema => %(
        type       => 'object',
        properties => %(
            change   => %( type => 'string' ),
            evidence => %( type => 'string' ),
        ),
        required   => [ 'evidence', 'change' ],   # evidence first
    ),
);

Every request body is serialized deterministically — the same request is the same bytes on every call and in every process, which also keeps provider prompt caches warm. The full rule, including nested schemas, $defs and tool parameters, is in LLM::Chat::WireJSON, which is exported for building prompt text with the same guarantees.

Mock (for tests)

Canned-response backend for unit and integration tests. Returns pre-configured responses in order, records every call for assertions, and can be scripted to fail on specific calls to exercise retry / fallback paths in downstream consumers.

use LLM::Chat::Backend::Mock;
use LLM::Chat::Backend::Settings;

my $mock = LLM::Chat::Backend::Mock.new(
    settings  => LLM::Chat::Backend::Settings.new,
    responses => ['first', 'second', 'third'],
    # Optional: script per-call failures by index. Returning a defined
    # hash fails that call; returning Nil proceeds normally.
    error-producer => -> $i {
        when $i == 0 { { class => 'http', status => 503,
                         message => 'bad gateway' } }
        default      { Nil }
    },
    # Optional: script a truncated-but-successful call by index. The
    # call still succeeds with its canned body; the Response just
    # carries finish-reason 'length', the way a real blocking
    # completion reports an exhausted token budget.
    finish-reason-producer => -> $i { $i == 0 ?? 'length' !! Str },
);

my $resp = $mock.chat-completion(@messages);
# $mock.recorded-calls[0]<messages>, <response>, <error>, <call-index>, ...
# $mock.call-index — monotonic counter, bumped on every call

See LLM::Chat::Backend::Mock for the full attribute list and recording contract.

Response

Every completion method returns an LLM::Chat::Backend::Response (or ::Stream for streaming calls). Callers poll .is-done, read .msg on success, and inspect .err on failure.

Responses also carry structured error metadata on the failure path so consumers can classify errors without regex-parsing raw messages:

until $resp.is-done { sleep 0.01 }

if $resp.is-success {
    say $resp.msg;
}
else {
    say "failed: {$resp.err}";
    say "  class:  {$resp.error-class  // '(none)'}";   # 'http' / 'timeout' /
                                                        # 'connection' /
                                                        # 'response' / 'unknown'
    say "  status: {$resp.error-status // '(none)'}";   # HTTP code when
                                                        # error-class eq 'http'
}

error-class values:

LLM::Data::Inference::Task reads these fields to decide between abort / retry-same / advance in its model-fallback policy — consumers that want the same policy without depending on that module can implement it against the Response.error-class / .error-status pair directly.

Provider-reported usage is also available on the Response when the backend emits it:

$resp.prompt-tokens;       # Int, undefined on backends that don't emit usage
$resp.completion-tokens;   # Int
$resp.total-tokens;        # Int
$resp.cached-prompt-tokens;# Int, the cached SLICE of prompt-tokens; undefined
                           # when the provider says nothing about caching
                           # (which is not the same as nothing cached)
$resp.cost;                # Num (credits)
$resp.model-used;          # Str, provider-reported routed model
$resp.provider-id;         # Str, provider-assigned request id
$resp.finish-reason;       # Str ('stop' / 'length' / 'content_filter' / ...)
$resp.provider-finish-reason; # Str, the reason exactly as the wire said it —
                           # differs only when a backend corrected it
$resp.finish-reason-evidence; # Hash, how a backend decided, when it checked

How a stream ends

A streamed generation ends in one of three ways, and every backend built on LLM::Chat::Backend::OpenAICommon settles the Response the same way for each:

A finish reason is recorded when it arrives but acted on only when the stream ends, because the usage frame (stream_options.include_usage) comes after the finish chunk — a stream settled on the finish chunk never reported its token counts. Content after the finish chunk is ignored; a server that sends its finish chunk and then goes quiet is settled after stream-finish-grace seconds (default 10) and its connection dropped.

A streamed data: line that doesn't parse is skipped rather than fatal, but counted on .dropped-frames:

$resp.dropped-frames;      # Int, 0 unless frames were unparseable

If any frames were dropped while tool calls were being assembled, the stream fails whatever else happened — tool-call arguments are streamed as JSON fragments concatenated blind, so a hole in them can produce valid JSON saying something the model never asked for, and nothing downstream could detect it. Prose-only streams keep their success and leave the counter for the caller to judge.

Cancelling a generation

There is a fourth way a generation can end that isn't the provider's decision: the caller changes its mind. cancel is for that, and it stops the server as well as the client.

my $resp = $backend.chat-completion-stream(@messages);
# ... the user hits escape, or a newer request supersedes this one ...
$backend.cancel($resp);      # or $resp.cancel — same effect

$resp.is-done;               # True, immediately and synchronously
$resp.is-cancelled;          # True
$resp.err;                   # 'Cancelled by user'
$resp.error-class;           # undefined — a cancel is not a failure
$resp.latest;                # whatever had already arrived, kept

The OpenAI-compatible protocol has no abort message: a client that no longer wants a completion hangs up, and llama.cpp's server, OpenRouter and the gateways between them stop generating when it does. So cancel closes the HTTP connection carrying the generation. Without that it would only quit the local supply — the caller sees nothing more while the model generates, and bills for, the entire reply anyway. On a slow model with a caller that cancels freely, that accumulates abandoned generations nobody will ever read.

Three properties are worth relying on:

A cancelled Response is deliberately not classified — .error-class stays undefined — so a fallback policy reading the Response cannot mistake an abandoned request for a flaky backend and retry it.

LLM::Chat::Backend::KoboldCpp additionally sends POST /api/extra/abort, which stops a generation still in prefill, before there is a connection to close. Its cancel returns the background Promise for that POST; it is fire-and-forget and callers may sink it.

Tool Loop

LLM::Chat::ToolLoop wraps a streaming backend and runs OpenAI-style tool-call rounds until the model produces a final answer or the safety limits are reached. It is backend-agnostic: pass tools in OpenAI format and an executor callback that returns role = "tool"> result hashes.

use LLM::Chat::ToolLoop;

my @tools = $mcp-server.tools-for-llm;

my $loop = LLM::Chat::ToolLoop.new(
    backend => $backend,
    tools => @tools,
    execute-tools => -> @calls {
        $mcp-server.execute-tool-calls(@calls)
    },
    # Defaults: 4 tool rounds, 12 total calls, 2 identical calls.
);

my $resp = $loop.chat-completion-stream(@messages);
until $resp.is-done { sleep 0.01 }
say $resp.msg if $resp.is-success;

For KoboldCpp, use the OpenAI-compatible chat endpoint http://host:5001/v1. Streaming tool calls arrive as delta.tool_calls chunks and are exposed through .tool-calls on the response before the loop executes them.

Retry Policy

LLM::Chat::Retry is the shared retry/fallback policy: five pure subs that classify a failure, decide how long to wait, sleep without ignoring a cancel, and build the attempt/telemetry records. Pair it with LLM::Chat::Retry::Exceptions for the typed failures a chain throws when it gives up.

use LLM::Chat::Retry;
use LLM::Chat::Retry::Exceptions;

my @attempts;
my Int $retries-left = 2;

given classify-error(
    error-class  => $resp.error-class,
    error-status => $resp.error-status,
) {
    when 'abort'      { die "config/account error: {$resp.err}" }
    when 'retry-same' {
        my Num $wait = retry-backoff(3 - $retries-left);   # 1s, 2s, 4s ...
        $retries-left--;
        # Returns False the moment &cancelled flips — a cancelled run
        # never sits out a 16-second backoff.
        last unless sleep-with-cancel($wait, :&cancelled);
    }
    default { }   # 'advance' — try the next backend at once
}

@attempts.push: attempt-record(
    backend-index => 0, model => $backend.model, error => "{$resp.err}",
);

X::LLM::Chat::Retry::Exhausted.new(
    :@attempts, summary => 'all backends exhausted',
).throw;

Buckets: 'abort' (HTTP 400/401/402/403/404 — config, account or access errors that repeat identically), 'retry-same' (5xx, connection drops, anything unclassifiable — usually one upstream provider having a bad minute), 'advance' (timeout, 429, malformed response, parser failure — model-specific pathologies).

The exception types are Exhausted, its subclasses Truncated (cut off by max_tokens) and TimedOut (missed a deadline), and Cancelled — which is deliberately not an Exhausted, so a user pressing Ctrl-C never lands in a dead-letter queue. Truncated declares item-retryable False, the duck-typed advice an orchestration layer reads as $ex.?item-retryable // True before re-running failed work.

LLM::Data::Inference::Task runs on this module, and its own exception types subclass these — so handlers written against either hierarchy match.

Conversation Management

use LLM::Chat::Conversation;

my $conv = LLM::Chat::Conversation.new;
$conv.add-message('system', 'You are helpful.');
$conv.add-message('user', 'Hello!');
$conv.add-message('assistant', 'Hi there!');

# Access messages
say $conv.messages;

Token Counting

use LLM::Chat::TokenCounter;

my $counter = LLM::Chat::TokenCounter.new(
    tokenizer => $tokenizer,
    template  => $template,
);

my $conversation-tokens = $counter.get-conversation-count(@messages);

# Exact selected-model request: [context head, messages, context tail],
# including the tool catalogue rendered by the model template.
my $request-tokens = $counter.get-request-count(
    @messages,
    :@tools,
    context-head => $run-context.head-message,
    context-tail => $run-context.tail-message,
);

get-conversation-count remains useful for stored-conversation and compaction accounting. Use get-request-count for a preflight that must match the complete wire prompt for one selected model. Undefined context ends and an empty tool catalogue are omitted without changing template bytes.

Dependencies

Author

Matt Doughty

License

Artistic-2.0