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
propertieswhose value is an object is a JSON Schema property map. Its members are emitted in the order of the siblingrequiredarray (therequiredmember of the same object), then every propertyrequireddoes not name, in ascending order.requiredentries that are not strings, name no property in the map, or repeat an earlier entry are skipped. With norequiredā 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/definitionsentries,anyOf/oneOf/allOfbranches,additionalProperties, toolparameters, the schema insideresponse_format, KoboldCpp'sjson_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,patternPropertiesanddependentSchemas, member names are names chosen by the schema author. A property calledproperties,required,enumor$defsis 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,enumandexamples, and the values of any member listed in:$data-members(the backends list the request'smessages), are emitted with every object sorted, at every depth, and are never searched for property maps.:!schema-orderapplies 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'sto-jsonoutput for the same value: only member order differs. Numbers, booleans,null, enums, allomorphs (IntStrand friends), dates and every other non-string scalar are written byJSON::Fastitself, exactly as before. An undefined value (type object,Nil,Failure) isnull.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::Fastescapes 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\u00XXfor 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 ā
\nfollowed 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 returnedStrencodes 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 ofvalue(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 apropertiesmember 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 toTrue.Falsetreats 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).