LLM::Chat::WireJSON

NAME

LLM::Chat::WireJSON - Deterministic JSON for request bodies, with schema property order under the caller's control

SYNOPSIS


use LLM::Chat::WireJSON;

my %schema =
    type       => 'object',
    properties => %(
        change     => %( type => 'string' ),
        confidence => %( type => 'number' ),
        evidence   => %( type => 'string' ),
        notes      => %( type => 'string' ),
    ),
    # Generation order: evidence first, then the change it supports.
    required   => [ 'evidence', 'change', 'confidence' ];

say to-wire-json(%schema);
# {"properties":{"evidence":{"type":"string"},"change":{"type":"string"},
#  "confidence":{"type":"number"},"notes":{"type":"string"}},
#  "required":["evidence","change","confidence"],"type":"object"}
# (one line on the wire; wrapped here for reading)

# Same input, same bytes: in this process, in the next one, on every
# machine.
say to-wire-json(%schema) eq to-wire-json(%schema.clone);   # True

# Plain data: sorted members everywhere, no schema rule.
say to-wire-json(%( b => 1, a => %( properties => %( z => 1, y => 2 ),
                                     required   => ['z'] ) ),
                 :!schema-order);
# {"a":{"properties":{"y":2,"z":1},"required":["z"]},"b":1}

DESCRIPTION

Raku hashes have no member order, and their iteration order is deliberately unpredictable: it differs between two hashes holding the same keys, and between two runs of the same program. A request body serialized straight from a Hash — which is what handing a Hash to Cro::HTTP::Client does — therefore goes on the wire with its members in a different order every time.

For most of a request that is cosmetic. For a JSON Schema it is not. llama.cpp and KoboldCpp compile a json_schema / response_format schema into a sampling grammar, and that grammar makes the model write an object's fields in the order the schema lists them. A schema serialized from a Hash makes the model write its fields in a random order per request, so an application cannot decide, for instance, that the model states its evidence before the conclusion that evidence supports. Random prompt bytes also defeat prompt-prefix caching and make a logged request impossible to replay exactly.

to-wire-json is the serializer every LLM::Chat::Backend::OpenAICommon request body (and so every LLM::Chat::Backend::OpenRouter and LLM::Chat::Backend::KoboldCpp body) goes through. It is exported for applications that build prompt text or schemas themselves and want the same guarantees.

THE ORDERING RULE

  • Arrays keep their order.

  • Object members are sorted in ascending order of their names by Unicode code point — the same order as comparing the names' UTF-8 bytes. For ASCII names that is digits, then uppercase, then _, then lowercase.

  • Except a schema's property map. An object member named properties whose value is an object is a JSON Schema property map. Its members are emitted in the order of the sibling required array (the required member of the same object), then every property required does not name, in ascending order. required entries that are not strings, name no property in the map, or repeat an earlier entry are skipped. With no required — or one that is not an array — the whole map is sorted.

  • At any depth. The rule applies wherever a property map occurs: nested object properties, items, prefixItems, $defs / definitions entries, anyOf / oneOf / allOf branches, additionalProperties, tool parameters, the schema inside response_format, KoboldCpp's json_schema — nothing needs to be registered for a new location.

  • Names are not keywords. Inside a property map, and inside the name-to-schema maps $defs, definitions, patternProperties and dependentSchemas, member names are names chosen by the schema author. A property called properties, required, enum or $defs is an ordinary property; its value is read as a schema like any other.

  • Data is never reordered as if it were schema. The values of the data keywords const, default, enum and examples, and the values of any member listed in :$data-members (the backends list the request's messages), are emitted with every object sorted, at every depth, and are never searched for property maps. :!schema-order applies the same treatment to the whole value.

The upshot for a schema author: list required in the order you want the model to write the fields. Optional fields follow, in alphabetical order.


my %finding =
    type       => 'object',
    properties => %(
        verdict  => %( type => 'string', enum => <keep drop> ),
        quote    => %( type => 'string' ),
        reason   => %( type => 'string' ),
        severity => %( type => 'integer' ),
    ),
    required   => <quote reason verdict>;

# properties go out as quote, reason, verdict (required order), then
# severity (optional, sorted) — so a grammar-constrained model writes
# the quote first and decides the verdict last.
say to-wire-json(%finding);

The same schema nested anywhere keeps its own order:


my %report =
    type       => 'object',
    properties => %(
        findings => %( type => 'array', items => %finding ),
        summary  => %( type => 'string' ),
    ),
    required   => <findings summary>;

say to-wire-json(%report);   # %finding's map keeps quote, reason, verdict, severity

BYTES

  • Deterministic. The same value serializes to the same bytes in every process, whatever order its hashes were built in and whatever hash seed the VM picked.

  • Semantically unchanged. Parsing the output yields the same data as parsing JSON::Fast's to-json output for the same value: only member order differs. Numbers, booleans, null, enums, allomorphs (IntStr and friends), dates and every other non-string scalar are written by JSON::Fast itself, exactly as before. An undefined value (type object, Nil, Failure) is null.

  • Compact. No whitespace between tokens.

  • Linear. Every node is visited once and each string is escaped in a single pass, so a request of several hundred kilobytes costs milliseconds. (JSON::Fast escapes by splicing, which is quadratic in the number of characters that need escaping; a long transcript full of newlines and quotes is exactly that case.)

  • Strings are written as UTF-8 with the mandatory escapes only: \" \\ \n \r \t, and \u00XX for the remaining control characters. Everything else, including characters outside the Basic Multilingual Plane, is written as itself.

  • Combining marks after an escape are escaped too. A combining mark that directly follows an escape sequence would otherwise join the escape's final letter when the text is normalized or encoded — \n followed by U+0301 becomes \ń, which is not valid JSON. Such marks (any code point with a non-zero canonical combining class) are written as \uXXXX, so the returned Str encodes to valid JSON however it is handled afterwards.

SUBROUTINES

to-wire-json


sub to-wire-json(
    Mu \value,
    :$data-members,
    Bool :$schema-order = True,
    --> Str:D
) is export

Serialize value as compact, deterministic JSON following the rule above. Encode the result with .encode('utf-8') to get wire bytes.

  • :$data-members — a name, or a list of names, of top-level members of value (when it is an object) whose values are data rather than schema: emitted with sorted members at every depth, never searched for property maps. Nested members of the same name are unaffected. The backends pass <messages> , so a conversation that happens to hold an object with a properties member is serialized as plain data:


my %body =
    messages => [ %( role => 'user', content => 'hi' ), ],
    tools    => @tool-definitions;

to-wire-json(%body, :data-members<messages>);
to-wire-json(%body, :data-members(<messages metadata>));

  • :$schema-order — defaults to True. False treats the whole value as data: every object sorted, no property-map rule.

Dies, like JSON::Fast's to-json, on a value JSON cannot express (a Supply, say).

LLM::Chat v0.10.3

Simple framework for LLM inferencing

Authors

  • Matt Doughty

License

Artistic-2.0

Dependencies

Cro::HTTP::Client:ver<0.8.11+>:auth<zef:cro>Digest::SHA256::Native:ver<1.0.0+>:auth<zef:bduggan>JSON::Fast:ver<0.21.0+>:auth<zef:timo>Template::Jinja2:ver<0.3.0+>:auth<zef:apogee>Tokenizers:ver<0.3.0+>:auth<zef:apogee>UUID::V4:ver<1.0.0+>:auth<zef:masukomi>

Provides

  • LLM::Chat
  • LLM::Chat::Backend
  • LLM::Chat::Backend::KoboldCpp
  • LLM::Chat::Backend::Mock
  • LLM::Chat::Backend::OpenAICommon
  • LLM::Chat::Backend::OpenRouter
  • LLM::Chat::Backend::Response
  • LLM::Chat::Backend::Response::OpenRouter
  • LLM::Chat::Backend::Response::OpenRouter::Augment
  • LLM::Chat::Backend::Response::OpenRouter::Stream
  • LLM::Chat::Backend::Response::Stream
  • LLM::Chat::Backend::Settings
  • LLM::Chat::Conversation
  • LLM::Chat::Conversation::Message
  • LLM::Chat::Debug
  • LLM::Chat::ExceptionText
  • LLM::Chat::Retry
  • LLM::Chat::Retry::Exceptions
  • LLM::Chat::Template
  • LLM::Chat::Template::ChatML
  • LLM::Chat::Template::DeepSeekV4
  • LLM::Chat::Template::Gemma2
  • LLM::Chat::Template::Jinja2
  • LLM::Chat::Template::Llama3
  • LLM::Chat::Template::Llama4
  • LLM::Chat::Template::MistralV7
  • LLM::Chat::Test::Kit
  • LLM::Chat::TokenCounter
  • LLM::Chat::ToolLoop
  • LLM::Chat::WireJSON

The Camelia image is copyright 2009 by Larry Wall. "Raku" is a trademark of the Yet Another Society. All rights reserved.

Built with Podlite — the markup and publishing tools behind this site.