Compare commits

...
Author SHA1 Message Date
Alde Rojas 31d903e1d2 refactor : rename common_schema_builder parse_* methods to build_* 2026-09-11 03:05:20 -05:00
Alde Rojas 88bb66a883 tests : take the schema label as const char * to satisfy gcc dangling-reference 2026-09-11 02:43:14 -05:00
Alde Rojas 92a5941014 tests : fix gcc dangling-reference warning in test-json-schema 2026-09-11 02:29:23 -05:00
Alde Rojas 3db2838488 refactor : rename common_schema_parse to common_schema_from_json 2026-09-11 01:31:33 -05:00
Alde Rojas 5fb45c827b cont : clean up 2026-09-11 00:40:34 -05:00
Alde Rojas bf31b04db8 cont : clean up 2026-09-11 00:40:34 -05:00
Alde Rojas 16a34dc5bc cont : reduce test cases 2026-09-11 00:40:34 -05:00
Alde Rojas 5f2fe7c811 cont : move enums under common_schema and add type enum 2026-09-11 00:40:34 -05:00
Alde Rojas 7d05f9f7b7 cont : cleanup 2026-09-11 00:40:33 -05:00
Alde Rojas b36bbf175b cont : pass common_schema through the json-schema-to-grammar builder 2026-09-11 00:40:33 -05:00
Alde Rojas fb46abf80d cont : simplify schema resolution 2026-09-11 00:40:32 -05:00
Alde Rojas cc6c084ee4 cont : remove common_chat_tool_parameters 2026-09-11 00:40:32 -05:00
Alde Rojas 79acad2c26 cont : cleanup 2026-09-11 00:40:32 -05:00
Alde Rojas 508e4970bf common/schema : implement type/kind resolution 2026-09-11 00:40:31 -05:00
Alde Rojas 944e408d97 common : use common_trie 2026-09-11 00:40:31 -05:00
Alde Rojas bf3e0cbd30 common : refactor json-schema-to-grammar to use common_schema 2026-09-11 00:40:31 -05:00
Alde Rojas b02ec43840 common : reduce optimizations 2026-09-11 00:40:31 -05:00
Alde Rojas ff53f0761d common : implement a json schema optimizer 2026-09-11 00:40:30 -05:00
Alde Rojas 652fd0234f common : implement common_schema types 2026-09-11 00:40:30 -05:00
40 changed files with 1723 additions and 1915 deletions
-1
View File
@@ -221,7 +221,6 @@ jobs:
# 7z x "-o${env:RUNNER_TEMP}" $env:RUNNER_TEMP/sde.tar
# $sde = $(join-path $env:RUNNER_TEMP sde-external-${env:SDE_VERSION}-win/sde.exe)
# cd build
# $env:LLAMA_SKIP_TESTS_SLOW_ON_EMULATOR = 1
# & $sde -future -- ctest -L main -C Release --verbose --timeout 900
- name: ccache-clear
+2
View File
@@ -84,6 +84,8 @@ add_library(${TARGET}
imatrix-loader.cpp
imatrix-loader.h
json-schema-to-grammar.cpp
json-schema.cpp
json-schema.h
json.cpp
json.h
llguidance.cpp
+2 -2
View File
@@ -2277,14 +2277,14 @@ common_params_context common_params_parser_init(common_params & params, llama_ex
).set_sampling());
add_opt(common_arg(
{"-j", "--json-schema"}, "SCHEMA",
"JSON schema to constrain generations (https://json-schema.org/), e.g. `{}` for any JSON object\nFor schemas w/ external $refs, use --grammar + example/json_schema_to_grammar.py instead",
"JSON schema to constrain generations (https://json-schema.org/), e.g. `{\"type\": \"object\"}` for any JSON object",
[](common_params & params, const std::string & value) {
params.sampling.grammar = {COMMON_GRAMMAR_TYPE_OUTPUT_FORMAT, json_schema_to_grammar(json::parse(value))};
}
).set_sampling());
add_opt(common_arg(
{"-jf", "--json-schema-file"}, "FILE",
"File containing a JSON schema to constrain generations (https://json-schema.org/), e.g. `{}` for any JSON object\nFor schemas w/ external $refs, use --grammar + example/json_schema_to_grammar.py instead",
"File containing a JSON schema to constrain generations (https://json-schema.org/), e.g. `{\"type\": \"object\"}` for any JSON object",
[](common_params & params, const std::string & value) {
std::ifstream file(value);
if (!file) {
+10 -40
View File
@@ -5,6 +5,7 @@
#include "common.h"
#include "json-schema-to-grammar.h"
#include "log.h"
#include "parsers/parsers.h"
#include "peg-parser.h"
#include <stdexcept>
@@ -12,16 +13,6 @@
using json = common_json;
// Helper to iterate over tools/functions
static void foreach_function(const json & tools, const std::function<void(const json &)> & fn) {
for (const auto & tool : tools) {
if (!tool.contains("type") || tool.at("type") != "function" || !tool.contains("function")) {
continue;
}
fn(tool);
}
}
namespace autoparser {
parser_build_context::parser_build_context(common_chat_peg_builder & p, const generation_params & inputs) :
@@ -87,15 +78,6 @@ common_chat_params peg_generator::generate_parser(const common_chat_template &
if (include_grammar) {
data.grammar_lazy = !has_response_format && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_AUTO;
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
builder.resolve_refs(schema);
});
if (has_response_format) {
auto schema = inputs.json_schema;
builder.resolve_refs(schema);
}
parser.build_grammar(builder, data.grammar_lazy);
});
@@ -383,43 +365,31 @@ common_peg_parser analyze_tools::build_tool_parser_tag_tagged(parser_build_conte
common_peg_parser tool_choice = p.choice();
foreach_function(inputs.tools, [&](const json & tool) {
const auto & func = tool.at("function");
std::string name = func.at("name");
auto params = func.contains("parameters") ? func.at("parameters") : json::object();
const auto & properties = params.contains("properties") ? params.at("properties") : json::object();
std::set<std::string> required;
if (params.contains("required")) {
required = params.at("required").get<std::set<std::string>>();
}
auto schema_info = common_schema_info();
schema_info.resolve_refs(params);
const auto & func = tool.at("function");
std::string name = func.at("name");
// Build parser for each argument, separating required and optional
std::vector<common_peg_parser> required_parsers;
std::vector<common_peg_parser> optional_parsers;
for (const auto & [param_name, param_schema] : properties.items()) {
bool is_required = required.find(param_name) != required.end();
foreach_parameter(func, [&](const common_schema_property & param, const common_schema_document_ptr & doc) {
auto arg =
p.tool_arg(p.tool_arg_open(arguments.name_prefix + p.tool_arg_name(p.literal(param_name)) +
p.tool_arg(p.tool_arg_open(arguments.name_prefix + p.tool_arg_name(p.literal(param.name)) +
arguments.name_suffix) +
arguments.value_prefix +
(schema_info.resolves_to_string(param_schema) ?
(param.schema->may_be_string() ?
p.ac(p.tool_arg_string_value(until_suffix) +
p.tool_arg_close(p.literal(arguments.value_suffix)), arguments.value_suffix) :
(p.tool_arg_json_value(p.schema(
p.json(), "tool-" + name + "-arg-" + param_name + "-schema", param_schema, false)) +
p.json(), "tool-" + name + "-arg-" + param.name + "-schema", doc, *param.schema)) +
p.tool_arg_close(p.literal(arguments.value_suffix)))));
auto named_arg = p.rule("tool-" + name + "-arg-" + param_name, arg);
if (is_required) {
auto named_arg = p.rule("tool-" + name + "-arg-" + param.name, arg);
if (param.required) {
required_parsers.push_back(named_arg);
} else {
optional_parsers.push_back(named_arg);
}
}
});
// Build required arg sequence in definition order
common_peg_parser args_seq = p.eps();
+180 -421
View File
@@ -1,5 +1,7 @@
#include "json-schema-to-grammar.h"
#include "common.h"
#include "trie.h"
#include "unicode.h"
#include <algorithm>
#include <limits>
@@ -338,16 +340,18 @@ static size_t gbnf_escape_length(const std::string & pattern, size_t pos) {
class common_schema_converter {
private:
friend class common_schema_info;
friend std::string build_grammar(const std::function<void(const common_grammar_builder &)> & cb, const common_grammar_options & options);
std::function<json(const std::string &)> _fetch_json;
bool _dotall;
std::map<std::string, std::string> _rules;
std::unordered_map<std::string, json> _refs;
std::unordered_set<std::string> _refs_being_resolved;
std::vector<std::string> _errors;
std::vector<std::string> _warnings;
template <typename T>
static const T & as(const common_schema & node) {
return static_cast<const T &>(node);
}
std::string _add_rule(const std::string & name, const std::string & rule) {
std::string esc_name = regex_replace(name, INVALID_RULE_CHARS_RE, "-");
if (_rules.find(esc_name) == _rules.end() || _rules[esc_name] == rule) {
@@ -363,11 +367,11 @@ private:
return key;
}
std::string _generate_union_rule(const std::string & name, const std::vector<json> & alt_schemas) {
std::string _generate_union_rule(const std::string & name, const std::vector<common_schema_ptr> & alt_schemas) {
std::vector<std::string> rules;
rules.reserve(alt_schemas.size());
for (size_t i = 0; i < alt_schemas.size(); i++) {
rules.push_back(visit(alt_schemas[i], name + (name.empty() ? "alternative-" : "-") + std::to_string(i)));
rules.push_back(visit(*alt_schemas[i], name + (name.empty() ? "alternative-" : "-") + std::to_string(i)));
}
return string_join(rules, " | ");
}
@@ -634,85 +638,68 @@ private:
-> ["] ( [a] ([l] ([s] ([o] char+ | [^"o] char*) | [^"s] char*) | [n] ([d] char+ | [^"d] char*) | [^"ln] char*) | [^"a] char* )? ["]
*/
std::string _not_strings(const std::vector<std::string> & strings) {
struct TrieNode {
std::map<char, TrieNode> children;
bool is_end_of_string;
TrieNode() : is_end_of_string(false) {}
void insert(const std::string & string) {
auto *node = this;
for (char c : string) {
node = &node->children[c];
}
node->is_end_of_string = true;
}
};
TrieNode trie;
for (const auto & s : strings) {
trie.insert(s);
}
common_trie trie(strings);
std::string char_rule = _add_primitive("char", PRIMITIVE_RULES.at("char"));
std::ostringstream out;
out << "[\"] ( ";
std::function<void(const TrieNode &)> visit = [&](const TrieNode & node) {
std::ostringstream rejects;
std::function<void(size_t)> visit = [&](size_t idx) {
const auto & node = trie.nodes[idx];
std::string rejects;
auto first = true;
for (const auto & kv : node.children) {
rejects << kv.first;
for (const auto & [cpt, child] : node.children) {
std::string c = common_unicode_cpt_to_utf8(cpt);
rejects += c;
if (first) {
first = false;
} else {
out << " | ";
}
out << "[" << kv.first << "]";
if (!kv.second.children.empty()) {
out << "[" << c << "]";
if (!trie.nodes[child].children.empty()) {
out << " (";
visit(kv.second);
visit(child);
out << ")";
} else if (kv.second.is_end_of_string) {
} else {
out << " " << char_rule << "+";
}
}
if (!node.children.empty()) {
if (!first) {
out << " | ";
}
out << "[^\"" << rejects.str() << "] " << char_rule << "*";
out << " | [^\"" << rejects << "] " << char_rule << "*";
}
};
visit(trie);
visit(0);
out << " )";
if (!trie.is_end_of_string) {
if (trie.nodes[0].pattern < 0) {
out << "?";
}
out << " [\"]";
return out.str();
}
std::string _resolve_ref(const std::string & ref) {
auto it = ref.find('#');
std::string ref_fragment = it != std::string::npos ? ref.substr(it + 1) : ref;
std::string _resolve_ref(const common_schema_ref & schema) {
auto it = schema.ref.find('#');
std::string ref_fragment = it != std::string::npos ? schema.ref.substr(it + 1) : schema.ref;
static const std::regex nonalphanumeric_regex(R"([^a-zA-Z0-9-]+)");
std::string ref_name = "ref" + std::regex_replace(ref_fragment, nonalphanumeric_regex, "-");
if (_rules.find(ref_name) == _rules.end() && _refs_being_resolved.find(ref) == _refs_being_resolved.end()) {
_refs_being_resolved.insert(ref);
json resolved = _refs[ref];
ref_name = visit(resolved, ref_name);
_refs_being_resolved.erase(ref);
if (_rules.find(ref_name) == _rules.end() && _refs_being_resolved.find(schema.ref) == _refs_being_resolved.end()) {
if (!schema.target) {
_errors.push_back("Unresolved $ref " + schema.ref);
return "";
}
_refs_being_resolved.insert(schema.ref);
ref_name = visit(*schema.target, ref_name);
_refs_being_resolved.erase(schema.ref);
}
return ref_name;
}
std::string _build_object_rule(
const std::vector<std::pair<std::string, json>> & properties,
const std::vector<std::pair<std::string, const common_schema *>> & properties,
const std::unordered_set<std::string> & required,
const std::string & name,
const json & additional_properties)
const common_schema * additional_properties)
{
std::vector<std::string> required_props;
std::vector<std::string> optional_props;
@@ -722,7 +709,7 @@ private:
const auto &prop_name = kv.first;
const auto &prop_schema = kv.second;
std::string prop_rule_name = visit(prop_schema, name + (name.empty() ? "" : "-") + prop_name);
std::string prop_rule_name = visit(*prop_schema, name + (name.empty() ? "" : "-") + prop_name);
prop_kv_rule_names[prop_name] = _add_rule(
name + (name.empty() ? "" : "-") + prop_name + "-kv",
format_literal(json(prop_name).dump()) + " space \":\" space " + prop_rule_name
@@ -734,10 +721,10 @@ private:
}
prop_names.push_back(prop_name);
}
if ((additional_properties.is_boolean() && additional_properties.get<bool>()) || additional_properties.is_object()) {
if (additional_properties) {
std::string sub_name = name + (name.empty() ? "" : "-") + "additional";
std::string value_rule =
additional_properties.is_object() ? visit(additional_properties, sub_name + "-value")
additional_properties->kind() != common_schema::KIND_ANY ? visit(*additional_properties, sub_name + "-value")
: _add_primitive("value", PRIMITIVE_RULES.at("value"));
auto key_rule =
@@ -825,267 +812,163 @@ private:
}
public:
common_schema_converter(
const std::function<json(const std::string &)> & fetch_json,
bool dotall)
: _fetch_json(fetch_json), _dotall(dotall)
{
explicit common_schema_converter(bool dotall) : _dotall(dotall) {
_rules["space"] = SPACE_RULE;
}
void resolve_refs(json & schema, const std::string & url) {
/*
* Resolves all $ref fields in the given schema, fetching any remote schemas,
* replacing each $ref with absolute reference URL and populates _refs with the
* respective referenced (sub)schema dictionaries.
*/
std::function<void(json &)> visit_refs = [&](json & n) {
if (n.is_array()) {
for (auto & x : n) {
visit_refs(x);
}
} else if (n.is_object()) {
if (n.contains("$ref")) {
std::string ref = n["$ref"];
if (_refs.find(ref) == _refs.end()) {
json target;
if (ref.find("https://") == 0) {
std::string base_url = ref.substr(0, ref.find('#'));
auto it = _refs.find(base_url);
if (it != _refs.end()) {
target = it->second;
} else {
// Fetch the referenced schema and resolve its refs
auto referenced = _fetch_json(ref);
resolve_refs(referenced, base_url);
_refs[base_url] = referenced;
}
if (ref.find('#') == std::string::npos || ref.substr(ref.find('#') + 1).empty()) {
return;
}
} else if (ref.find("#/") == 0) {
target = schema;
n["$ref"] = url + ref;
ref = url + ref;
} else {
_errors.push_back("Unsupported ref: " + ref);
return;
}
std::string pointer = ref.substr(ref.find('#') + 1);
std::vector<std::string> tokens = string_split(pointer, "/");
for (size_t i = 1; i < tokens.size(); ++i) {
const std::string& sel = tokens[i];
if (target.is_object() && target.contains(sel)) {
target = target[sel];
} else if (target.is_array()) {
size_t sel_index;
try {
sel_index = std::stoull(sel);
} catch (const std::invalid_argument & e) {
sel_index = target.size();
}
if (sel_index >= target.size()) {
_errors.push_back("Error resolving ref " + ref + ": " + sel + " not in " + target.dump());
return;
}
target = target[sel_index];
} else {
_errors.push_back("Error resolving ref " + ref + ": " + sel + " not in " + target.dump());
return;
}
}
_refs[ref] = target;
}
} else {
for (const auto & kv : n.items()) {
visit_refs(kv.value());
}
}
}
};
visit_refs(schema);
std::string add_schema(const std::string & name, const common_schema & schema) {
return visit(schema, name);
}
static std::string _generate_constant_rule(const json & value) {
return format_literal(value.dump());
}
std::string visit(const json & schema, const std::string & name) {
json schema_type = schema.contains("type") ? schema["type"] : json();
std::string schema_format = schema.contains("format") ? schema["format"].get<std::string>() : "";
std::string rule_name = is_reserved_name(name) ? name + "-" : name.empty() ? "root" : name;
std::string _visit_primitive(const std::string & rule_name, const std::string & type) {
return _add_primitive(rule_name == "root" ? "root" : type, PRIMITIVE_RULES.at(type));
}
if (schema.contains("$ref")) {
return _add_rule(rule_name, _resolve_ref(schema["$ref"]));
}
if (schema.contains("oneOf") || schema.contains("anyOf")) {
const json & alts = schema.contains("oneOf") ? schema.at("oneOf") : schema.at("anyOf");
std::vector<json> alt_schemas;
for (const auto & alt : alts) {
alt_schemas.push_back(alt);
}
return _add_rule(rule_name, _generate_union_rule(name, alt_schemas));
}
if (schema_type.is_array()) {
std::vector<json> schema_types;
for (const auto & t : schema_type) {
json schema_copy(schema);
schema_copy["type"] = t;
schema_types.push_back(schema_copy);
}
return _add_rule(rule_name, _generate_union_rule(name, schema_types));
}
if (schema.contains("const")) {
return _add_rule(rule_name, _generate_constant_rule(schema["const"]));
}
if (schema.contains("enum")) {
std::vector<std::string> enum_values;
for (const auto & v : schema["enum"]) {
enum_values.push_back(_generate_constant_rule(v));
}
return _add_rule(rule_name, "(" + string_join(enum_values, " | ") + ")");
}
if ((schema_type.is_null() || schema_type == "object")
&& (schema.contains("properties") ||
(schema.contains("additionalProperties") && schema["additionalProperties"] != true))) {
std::unordered_set<std::string> required;
if (schema.contains("required") && schema["required"].is_array()) {
for (const auto & item : schema["required"]) {
if (item.is_string()) {
required.insert(item.get<std::string>());
std::string _visit_all_of(const common_schema_all_of & schema, const std::string & name, const std::string & rule_name) {
std::unordered_set<std::string> required;
std::vector<std::pair<std::string, const common_schema *>> properties;
std::map<std::string, size_t> enum_values;
std::function<void(const common_schema &, bool)> add_component = [&](const common_schema & comp, bool is_required) {
if (comp.kind() == common_schema::KIND_REF) {
if (const auto * target = as<common_schema_ref>(comp).target) {
add_component(*target, is_required);
}
} else if (comp.kind() == common_schema::KIND_OBJECT) {
for (const auto & prop : as<common_schema_object>(comp).properties) {
properties.emplace_back(prop.name, prop.schema.get());
if (is_required) {
required.insert(prop.name);
}
}
}
std::vector<std::pair<std::string, json>> properties;
if (schema.contains("properties")) {
for (const auto & prop : schema["properties"].items()) {
properties.emplace_back(prop.key(), prop.value());
} else if (comp.kind() == common_schema::KIND_ENUM) {
for (const auto & v : as<common_schema_enum>(comp).values) {
enum_values[_generate_constant_rule(v)] += 1;
}
}
return _add_rule(rule_name,
_build_object_rule(
properties, required, name,
schema.contains("additionalProperties") ? schema["additionalProperties"] : json()));
};
for (const auto & child : schema.children) {
if (child->kind() == common_schema::KIND_ANY_OF) {
for (const auto & alt : as<common_schema_any_of>(*child).children) {
add_component(*alt, false);
}
} else {
add_component(*child, true);
}
}
if ((schema_type.is_null() || schema_type == "object" || schema_type == "string") && schema.contains("allOf")) {
std::unordered_set<std::string> required;
std::vector<std::pair<std::string, json>> properties;
std::map<std::string, size_t> enum_values;
const std::string& hybrid_name = name;
std::function<void(const json &, bool)> add_component = [&](const json & comp_schema, bool is_required) {
if (comp_schema.contains("$ref")) {
add_component(_refs[comp_schema["$ref"]], is_required);
} else if (comp_schema.contains("properties")) {
for (const auto & prop : comp_schema["properties"].items()) {
properties.emplace_back(prop.key(), prop.value());
if (is_required) {
required.insert(prop.key());
}
}
} else if (comp_schema.contains("enum")) {
for (const auto & v : comp_schema["enum"]) {
const auto rule = _generate_constant_rule(v);
if (enum_values.find(rule) == enum_values.end()) {
enum_values[rule] = 0;
}
enum_values[rule] += 1;
}
} else {
// todo warning
}
};
for (const auto & t : schema["allOf"]) {
if (t.contains("anyOf")) {
for (const auto & tt : t["anyOf"]) {
add_component(tt, false);
}
} else {
add_component(t, true);
if (!enum_values.empty()) {
std::vector<std::string> enum_intersection;
for (const auto & p : enum_values) {
if (p.second == schema.children.size()) {
enum_intersection.push_back(p.first);
}
}
if (!enum_values.empty()) {
std::vector<std::string> enum_intersection;
for (const auto & p : enum_values) {
if (p.second == schema["allOf"].size()) {
enum_intersection.push_back(p.first);
}
}
if (!enum_intersection.empty()) {
return _add_rule(rule_name, "(" + string_join(enum_intersection, " | ") + ")");
}
if (!enum_intersection.empty()) {
return _add_rule(rule_name, "(" + string_join(enum_intersection, " | ") + ")");
}
return _add_rule(rule_name, _build_object_rule(properties, required, hybrid_name, json()));
}
if ((schema_type.is_null() || schema_type == "array") && (schema.contains("items") || schema.contains("prefixItems"))) {
json items = schema.contains("items") ? schema["items"] : schema["prefixItems"];
if (items.is_array()) {
return _add_rule(rule_name, _build_object_rule(properties, required, name, nullptr));
}
std::string visit(const common_schema & schema, const std::string & name) {
std::string rule_name = is_reserved_name(name) ? name + "-" : name.empty() ? "root" : name;
std::string sub_name = name + (name.empty() ? "" : "-");
switch (schema.kind()) {
case common_schema::KIND_REF:
return _add_rule(rule_name, _resolve_ref(as<common_schema_ref>(schema)));
case common_schema::KIND_ANY_OF:
return _add_rule(rule_name, _generate_union_rule(name, as<common_schema_any_of>(schema).children));
case common_schema::KIND_ALL_OF:
return _visit_all_of(as<common_schema_all_of>(schema), name, rule_name);
case common_schema::KIND_CONST:
return _add_rule(rule_name, _generate_constant_rule(as<common_schema_const>(schema).value));
case common_schema::KIND_ENUM: {
std::vector<std::string> enum_values;
for (const auto & v : as<common_schema_enum>(schema).values) {
enum_values.push_back(_generate_constant_rule(v));
}
return _add_rule(rule_name, "(" + string_join(enum_values, " | ") + ")");
}
case common_schema::KIND_OBJECT: {
const auto & obj = as<common_schema_object>(schema);
if (obj.properties.empty() && obj.additional_properties && obj.additional_properties->kind() == common_schema::KIND_ANY) {
return _add_rule(rule_name, _add_primitive("object", PRIMITIVE_RULES.at("object")));
}
std::vector<std::pair<std::string, const common_schema *>> properties;
std::unordered_set<std::string> required;
for (const auto & prop : obj.properties) {
properties.emplace_back(prop.name, prop.schema.get());
if (prop.required) {
required.insert(prop.name);
}
}
return _add_rule(rule_name, _build_object_rule(properties, required, name, obj.additional_properties.get()));
}
case common_schema::KIND_TUPLE: {
const auto & items = as<common_schema_tuple>(schema).items;
std::string rule = "\"[\" space ";
for (size_t i = 0; i < items.size(); i++) {
if (i > 0) {
rule += " \",\" space ";
}
rule += visit(items[i], name + (name.empty() ? "" : "-") + "tuple-" + std::to_string(i));
rule += visit(*items[i], sub_name + "tuple-" + std::to_string(i));
}
rule += " space \"]\"";
return _add_rule(rule_name, rule);
}
std::string item_rule_name = visit(items, name + (name.empty() ? "" : "-") + "item");
int min_items = schema.contains("minItems") ? schema["minItems"].get<int>() : 0;
json max_items_json = schema.contains("maxItems") ? schema["maxItems"] : json();
int max_items = max_items_json.is_number_integer() ? max_items_json.get<int>() : std::numeric_limits<int>::max();
return _add_rule(rule_name, "\"[\" space " + build_repetition(item_rule_name, min_items, max_items, "\",\" space") + " space \"]\"");
}
if ((schema_type.is_null() || schema_type == "string") && schema.contains("pattern")) {
return _visit_pattern(schema["pattern"], rule_name);
}
if ((schema_type.is_null() || schema_type == "string") && std::regex_match(schema_format, std::regex("^uuid[1-5]?$"))) {
return _add_primitive(rule_name == "root" ? "root" : schema_format, PRIMITIVE_RULES.at("uuid"));
}
if ((schema_type.is_null() || schema_type == "string") && STRING_FORMAT_RULES.find(schema_format + "-string") != STRING_FORMAT_RULES.end()) {
auto prim_name = schema_format + "-string";
return _add_rule(rule_name, _add_primitive(prim_name, STRING_FORMAT_RULES.at(prim_name)));
}
if (schema_type == "string" && (schema.contains("minLength") || schema.contains("maxLength"))) {
std::string char_rule = _add_primitive("char", PRIMITIVE_RULES.at("char"));
int min_len = schema.contains("minLength") ? schema["minLength"].get<int>() : 0;
int max_len = schema.contains("maxLength") ? schema["maxLength"].get<int>() : std::numeric_limits<int>::max();
return _add_rule(rule_name, "\"\\\"\" " + build_repetition(char_rule, min_len, max_len) + " \"\\\"\"");
}
if (schema_type == "integer" && (schema.contains("minimum") || schema.contains("exclusiveMinimum") || schema.contains("maximum") || schema.contains("exclusiveMaximum"))) {
int64_t min_value = std::numeric_limits<int64_t>::min();
int64_t max_value = std::numeric_limits<int64_t>::max();
if (schema.contains("minimum")) {
min_value = schema["minimum"].get<int64_t>();
} else if (schema.contains("exclusiveMinimum")) {
min_value = schema["exclusiveMinimum"].get<int64_t>() + 1;
case common_schema::KIND_ARRAY: {
const auto & arr = as<common_schema_array>(schema);
if (arr.items->kind() == common_schema::KIND_ANY && arr.min_items == 0 && arr.max_items < 0) {
return _visit_primitive(rule_name, "array");
}
std::string item_rule_name = visit(*arr.items, sub_name + "item");
int max_items = arr.max_items < 0 ? std::numeric_limits<int>::max() : arr.max_items;
return _add_rule(rule_name, "\"[\" space " + build_repetition(item_rule_name, arr.min_items, max_items, "\",\" space") + " space \"]\"");
}
if (schema.contains("maximum")) {
max_value = schema["maximum"].get<int64_t>();
} else if (schema.contains("exclusiveMaximum")) {
max_value = schema["exclusiveMaximum"].get<int64_t>() - 1;
case common_schema::KIND_STRING: {
const auto & str = as<common_schema_string>(schema);
if (!str.pattern.empty()) {
return _visit_pattern(str.pattern, rule_name);
}
if (str.format == common_schema::FORMAT_UUID) {
return _visit_primitive(rule_name, "uuid");
}
if (str.format != common_schema::FORMAT_NONE) {
std::string prim_name = std::string(str.format == common_schema::FORMAT_DATE ? "date" : str.format == common_schema::FORMAT_TIME ? "time" : "date-time") + "-string";
return _add_rule(rule_name, _add_primitive(prim_name, STRING_FORMAT_RULES.at(prim_name)));
}
if (str.min_length > 0 || str.max_length >= 0) {
std::string char_rule = _add_primitive("char", PRIMITIVE_RULES.at("char"));
int max_len = str.max_length < 0 ? std::numeric_limits<int>::max() : str.max_length;
return _add_rule(rule_name, "\"\\\"\" " + build_repetition(char_rule, str.min_length, max_len) + " \"\\\"\"");
}
return _visit_primitive(rule_name, "string");
}
std::stringstream out;
out << "(";
build_min_max_int(min_value, max_value, out);
out << ")";
return _add_rule(rule_name, out.str());
case common_schema::KIND_INTEGER: {
const auto & i = as<common_schema_integer>(schema);
if (i.minimum == std::numeric_limits<int64_t>::min() && i.maximum == std::numeric_limits<int64_t>::max()) {
return _visit_primitive(rule_name, "integer");
}
std::stringstream out;
out << "(";
build_min_max_int(i.minimum, i.maximum, out);
out << ")";
return _add_rule(rule_name, out.str());
}
case common_schema::KIND_NUMBER:
return _visit_primitive(rule_name, "number");
case common_schema::KIND_BOOLEAN:
return _visit_primitive(rule_name, "boolean");
case common_schema::KIND_NULL:
return _visit_primitive(rule_name, "null");
case common_schema::KIND_ANY:
return _add_rule(rule_name, _add_primitive("value", PRIMITIVE_RULES.at("value")));
}
if (schema.empty() || schema_type == "object") {
return _add_rule(rule_name, _add_primitive("object", PRIMITIVE_RULES.at("object")));
}
if (schema_type.is_null() && schema.is_object()) {
// No type constraint and no recognized structural keywords (e.g. {"description": "..."}).
// Per JSON Schema semantics this is equivalent to {} and accepts any value.
return _add_rule(rule_name, _add_primitive("value", PRIMITIVE_RULES.at("value")));
}
if (!schema_type.is_string() || PRIMITIVE_RULES.find(schema_type.get<std::string>()) == PRIMITIVE_RULES.end()) {
_errors.push_back("Unrecognized schema: " + schema.dump());
return "";
}
// TODO: support minimum, maximum, exclusiveMinimum, exclusiveMaximum at least for zero
return _add_primitive(rule_name == "root" ? "root" : schema_type.get<std::string>(), PRIMITIVE_RULES.at(schema_type.get<std::string>()));
return "";
}
void check_errors() {
@@ -1106,134 +989,6 @@ public:
}
};
// common_schema_info implementation (pimpl)
common_schema_info::common_schema_info()
: impl_(std::make_unique<common_schema_converter>(
[](const std::string &) { return json(); },
false)) {}
common_schema_info::~common_schema_info() = default;
common_schema_info::common_schema_info(common_schema_info &&) noexcept = default;
common_schema_info & common_schema_info::operator=(common_schema_info &&) noexcept = default;
void common_schema_info::resolve_refs(common_json & schema) {
impl_->resolve_refs(schema, "");
}
// Determines if a JSON schema can resolve to a string type through any path.
// Some models emit raw string values rather than JSON-encoded strings for string parameters.
// If any branch of the schema (via oneOf, anyOf, $ref, etc.) permits a string, this returns
// true, allowing callers to handle the value as a raw string for simplicity.
bool common_schema_info::resolves_to_string(const common_json & schema) {
std::unordered_set<std::string> visited_refs;
std::function<bool(const json &)> check = [&](const json & s) -> bool {
if (!s.is_object()) {
return false;
}
// Handle $ref
if (s.contains("$ref")) {
const std::string & ref = s["$ref"];
if (visited_refs.find(ref) != visited_refs.end()) {
// Circular reference, assume not a string to be safe
return false;
}
visited_refs.insert(ref);
auto it = impl_->_refs.find(ref);
if (it != impl_->_refs.end()) {
return check(it->second);
}
return false;
}
// Check type field
if (s.contains("type")) {
const json & schema_type = s["type"];
if (schema_type.is_string()) {
if (schema_type == "string") {
return true;
}
} else if (schema_type.is_array()) {
// Type can be an array like ["string", "null"]
for (const auto & t : schema_type) {
if (t == "string") {
return true;
}
}
}
}
// Check oneOf/anyOf - if any alternative can be a string
if (s.contains("oneOf")) {
for (const auto & alt : s["oneOf"]) {
if (check(alt)) {
return true;
}
}
}
if (s.contains("anyOf")) {
for (const auto & alt : s["anyOf"]) {
if (check(alt)) {
return true;
}
}
}
// Check allOf - all components must be compatible with string type
if (s.contains("allOf")) {
bool all_string = true;
for (const auto & component : s["allOf"]) {
if (!check(component)) {
all_string = false;
break;
}
}
if (all_string) {
return true;
}
}
// Check const - if the constant value is a string
if (s.contains("const")) {
if (s["const"].is_string()) {
return true;
}
}
// Check enum - if any enum value is a string
if (s.contains("enum")) {
for (const auto & val : s["enum"]) {
if (val.is_string()) {
return true;
}
}
}
// String-specific keywords imply string type
if (s.contains("pattern") || s.contains("minLength") || s.contains("maxLength")) {
return true;
}
// Check format - many formats imply string
if (s.contains("format")) {
const std::string & fmt = s["format"];
if (fmt == "date" || fmt == "time" || fmt == "date-time" ||
fmt == "uri" || fmt == "email" || fmt == "hostname" ||
fmt == "ipv4" || fmt == "ipv6" || fmt == "uuid" ||
fmt.find("uuid") == 0) {
return true;
}
}
return false;
};
return check(schema);
}
std::string json_schema_to_grammar(const common_json & schema, bool force_gbnf) {
#ifdef LLAMA_USE_LLGUIDANCE
if (!force_gbnf) {
@@ -1242,25 +997,29 @@ std::string json_schema_to_grammar(const common_json & schema, bool force_gbnf)
#else
(void)force_gbnf;
#endif // LLAMA_USE_LLGUIDANCE
return build_grammar([&](const common_grammar_builder & callbacks) {
auto copy = schema;
callbacks.resolve_refs(copy);
callbacks.add_schema("", copy);
});
try {
return json_schema_to_grammar(common_schema_from_json(schema));
} catch (const std::runtime_error & e) {
throw std::invalid_argument(std::string("JSON schema conversion failed:\n") + e.what());
}
}
std::string json_schema_to_grammar(const common_schema_document & schema) {
common_schema_converter converter(false);
converter.visit(*schema.root, "");
converter.check_errors();
return converter.format_grammar();
}
std::string build_grammar(const std::function<void(const common_grammar_builder &)> & cb, const common_grammar_options & options) {
common_schema_converter converter([&](const std::string &) { return json(); }, options.dotall);
common_schema_converter converter(options.dotall);
common_grammar_builder builder {
/* .add_rule = */ [&](const std::string & name, const std::string & rule) {
return converter._add_rule(name, rule);
},
/* .add_schema = */ [&](const std::string & name, const common_json & schema) {
return converter.visit(schema, name == "root" ? "" : name);
/* .add_schema = */ [&](const std::string & name, const common_schema & schema) {
return converter.add_schema(name == "root" ? "" : name, schema);
},
/* .resolve_refs = */ [&](common_json & schema) {
converter.resolve_refs(schema, "");
}
};
cb(builder);
converter.check_errors();
+5 -25
View File
@@ -1,37 +1,17 @@
#pragma once
#include "json-schema.h"
#include "json.h"
#include <functional>
#include <memory>
#include <string>
std::string json_schema_to_grammar(const common_json & schema,
bool force_gbnf = false);
class common_schema_converter;
// Probes a JSON schema to extract information about its structure and type constraints.
class common_schema_info {
std::unique_ptr<common_schema_converter> impl_;
public:
common_schema_info();
~common_schema_info();
common_schema_info(const common_schema_info &) = delete;
common_schema_info & operator=(const common_schema_info &) = delete;
common_schema_info(common_schema_info &&) noexcept;
common_schema_info & operator=(common_schema_info &&) noexcept;
void resolve_refs(common_json & schema);
bool resolves_to_string(const common_json & schema);
};
std::string json_schema_to_grammar(const common_json & schema, bool force_gbnf = false);
std::string json_schema_to_grammar(const common_schema_document & schema);
struct common_grammar_builder {
std::function<std::string(const std::string &, const std::string &)> add_rule;
std::function<std::string(const std::string &, const common_json &)> add_schema;
std::function<void(common_json &)> resolve_refs;
std::function<std::string(const std::string &, const std::string &)> add_rule;
std::function<std::string(const std::string &, const common_schema &)> add_schema;
};
struct common_grammar_options {
+513
View File
@@ -0,0 +1,513 @@
#include "json-schema.h"
#include "common.h"
#include <cmath>
#include <map>
#include <stdexcept>
#include <string>
#include <unordered_set>
#include <utility>
#include <vector>
class common_schema_builder {
const common_json & root_;
common_schema_document & doc_;
// the targets built here, moved into doc_ once the whole schema is built
std::map<std::string, common_schema_ptr> refs_;
// ref nodes get their target once every $ref is built, a cycle would otherwise need it too early
std::vector<common_schema_ref *> pending_;
[[noreturn]] static void fail(const std::string & path, const std::string & msg) {
throw std::runtime_error("JSON schema error at " + path + ": " + msg);
}
static int get_count(const common_json & schema, const std::string & key, const std::string & path, int def) {
if (!schema.contains(key)) {
return def;
}
const common_json & value = schema.at(key);
if (!value.is_number_integer() || value.get<int>() < 0) {
fail(path, key + " must be a non-negative integer");
}
return value.get<int>();
}
// a fractional bound is rounded inwards, towards the integers it still admits
static int64_t get_bound(const common_json & schema, const std::string & key, const std::string & path, bool round_up) {
const common_json & value = schema.at(key);
if (value.is_number_integer()) {
return value.get<int64_t>();
}
if (!value.is_number()) {
fail(path, key + " must be a number");
}
double d = value.get<double>();
return (int64_t) (round_up ? std::ceil(d) : std::floor(d));
}
static common_schema::string_format get_format(const common_json & schema, const std::string & path) {
if (!schema.contains("format")) {
return common_schema::FORMAT_NONE;
}
const common_json & value = schema.at("format");
if (!value.is_string()) {
fail(path, "format must be a string");
}
std::string format = value.get<std::string>();
if (format == "date") {
return common_schema::FORMAT_DATE;
}
if (format == "time") {
return common_schema::FORMAT_TIME;
}
if (format == "date-time") {
return common_schema::FORMAT_DATE_TIME;
}
if (format == "uuid" || (format.size() == 5 && format.compare(0, 4, "uuid") == 0 && format[4] >= '1' && format[4] <= '5')) {
return common_schema::FORMAT_UUID;
}
return common_schema::FORMAT_NONE;
}
const common_json & resolve_ref(const std::string & ref, const std::string & path) {
const common_json * target = &root_;
auto tokens = string_split(ref.substr(1), "/");
for (size_t i = 1; i < tokens.size(); i++) {
const std::string & sel = tokens[i];
if (target->is_object() && target->contains(sel)) {
target = &target->at(sel);
} else if (target->is_array()) {
size_t idx;
try {
idx = std::stoull(sel);
} catch (const std::logic_error &) {
idx = target->size();
}
if (idx >= target->size()) {
fail(path, "cannot resolve $ref " + ref + ", " + sel + " is out of range");
}
target = &target->at(idx);
} else {
fail(path, "cannot resolve $ref " + ref + ", " + sel + " not found");
}
}
return *target;
}
common_schema_ptr build_ref(const common_json & value, const std::string & path) {
if (!value.is_string()) {
fail(path, "$ref must be a string");
}
std::string ref = value.get<std::string>();
if (ref.compare(0, 2, "#/") != 0) {
fail(path, "unsupported $ref " + ref + ", only references into the same document are supported");
}
if (doc_.refs.find(ref) == doc_.refs.end() && refs_.find(ref) == refs_.end()) {
// reserve the key first, so that a cycle back to this $ref stops here
refs_[ref] = nullptr;
refs_[ref] = build_node(resolve_ref(ref, path), ref);
}
auto node = std::make_unique<common_schema_ref>(ref);
pending_.push_back(node.get());
return node;
}
template <typename T>
common_schema_ptr build_alternatives(const common_json & alts, const std::string & path) {
if (!alts.is_array()) {
fail(path, "must be an array of schemas");
}
if (alts.empty()) {
fail(path, "must not be empty");
}
auto node = std::make_unique<T>();
size_t i = 0;
for (const auto & alt : alts) {
node->children.push_back(build_node(alt, path + "/" + std::to_string(i++)));
}
return node;
}
common_schema_ptr build_object(const common_json & schema, const std::string & path) {
auto node = std::make_unique<common_schema_object>();
std::unordered_set<std::string> required;
if (schema.contains("required") && schema.at("required").is_array()) {
for (const auto & name : schema.at("required")) {
if (name.is_string()) {
required.insert(name.get<std::string>());
}
}
}
if (schema.contains("properties")) {
const common_json & properties = schema.at("properties");
if (!properties.is_object()) {
fail(path, "properties must be an object");
}
for (const auto & [name, prop] : properties.items()) {
node->properties.push_back({name, build_node(prop, path + "/properties/" + name), required.count(name) > 0});
}
}
if (schema.contains("additionalProperties")) {
const common_json & additional = schema.at("additionalProperties");
if (additional.is_boolean()) {
if (additional.get<bool>()) {
node->additional_properties = std::make_unique<common_schema_any>();
}
} else if (additional.is_object()) {
node->additional_properties = build_node(additional, path + "/additionalProperties");
} else {
fail(path, "additionalProperties must be a boolean or a schema");
}
} else if (!schema.contains("properties")) {
// {"type": "object"} on its own accepts any object
node->additional_properties = std::make_unique<common_schema_any>();
}
return node;
}
common_schema_ptr build_array(const common_json & schema, const std::string & path) {
auto node = std::make_unique<common_schema_array>();
if (schema.contains("items") || schema.contains("prefixItems")) {
// "items" wins when both are present; as in the converter, a schema instead of an array is the item schema
const std::string key = schema.contains("items") ? "items" : "prefixItems";
const common_json & items = schema.at(key);
if (items.is_array()) {
auto tuple = std::make_unique<common_schema_tuple>();
size_t i = 0;
for (const auto & item : items) {
tuple->items.push_back(build_node(item, path + "/" + key + "/" + std::to_string(i++)));
}
return tuple;
}
node->items = build_node(items, path + "/" + key);
} else {
node->items = std::make_unique<common_schema_any>();
}
node->min_items = get_count(schema, "minItems", path, 0);
node->max_items = get_count(schema, "maxItems", path, -1);
return node;
}
common_schema_ptr build_string(const common_json & schema, const std::string & path) {
auto node = std::make_unique<common_schema_string>();
if (schema.contains("pattern")) {
const common_json & pattern = schema.at("pattern");
if (!pattern.is_string()) {
fail(path, "pattern must be a string");
}
node->pattern = pattern.get<std::string>();
}
node->format = get_format(schema, path);
node->min_length = get_count(schema, "minLength", path, 0);
node->max_length = get_count(schema, "maxLength", path, -1);
return node;
}
common_schema_ptr build_integer(const common_json & schema, const std::string & path) {
auto node = std::make_unique<common_schema_integer>();
if (schema.contains("minimum")) {
node->minimum = get_bound(schema, "minimum", path, /* round_up */ true);
} else if (schema.contains("exclusiveMinimum")) {
node->minimum = get_bound(schema, "exclusiveMinimum", path, /* round_up */ false) + 1;
}
if (schema.contains("maximum")) {
node->maximum = get_bound(schema, "maximum", path, /* round_up */ false);
} else if (schema.contains("exclusiveMaximum")) {
node->maximum = get_bound(schema, "exclusiveMaximum", path, /* round_up */ true) - 1;
}
return node;
}
common_schema_ptr build_node(const common_json & schema, const std::string & path) {
if (!schema.is_object()) {
fail(path, "schema must be an object");
}
if (schema.contains("$ref")) {
return build_ref(schema.at("$ref"), path);
}
if (schema.contains("oneOf") || schema.contains("anyOf")) {
const std::string key = schema.contains("oneOf") ? "oneOf" : "anyOf";
return build_alternatives<common_schema_any_of>(schema.at(key), path + "/" + key);
}
common_json type;
if (schema.contains("type")) {
type = schema.at("type");
}
if (type.is_array()) {
// {"type": ["a", "b"], ...} is {"anyOf": [{"type": "a", ...}, {"type": "b", ...}]}
if (type.empty()) {
fail(path, "type must not be empty");
}
auto node = std::make_unique<common_schema_any_of>();
size_t i = 0;
for (const auto & t : type) {
common_json alt = schema;
alt["type"] = t;
node->children.push_back(build_node(alt, path + "/type/" + std::to_string(i++)));
}
return node;
}
if (schema.contains("const")) {
return std::make_unique<common_schema_const>(schema.at("const"));
}
if (schema.contains("enum")) {
const common_json & values = schema.at("enum");
if (!values.is_array() || values.empty()) {
fail(path, "enum must be a non-empty array");
}
auto node = std::make_unique<common_schema_enum>();
for (const auto & value : values) {
node->values.push_back(value);
}
return node;
}
if (!type.is_null() && !type.is_string()) {
fail(path, "type must be a string or an array of strings");
}
const std::string type_name = type.is_string() ? type.get<std::string>() : "";
const bool has_properties = schema.contains("properties") ||
(schema.contains("additionalProperties") && schema.at("additionalProperties") != true);
if (type_name.empty()) {
// without a type the structural keywords decide, in the same order as the converter
if (has_properties) {
return build_object(schema, path);
}
if (schema.contains("allOf")) {
return build_alternatives<common_schema_all_of>(schema.at("allOf"), path + "/allOf");
}
if (schema.contains("items") || schema.contains("prefixItems")) {
return build_array(schema, path);
}
if (schema.contains("pattern") || get_format(schema, path) != common_schema::FORMAT_NONE) {
return build_string(schema, path);
}
return std::make_unique<common_schema_any>();
}
if (type_name == "object") {
if (!has_properties && schema.contains("allOf")) {
return build_alternatives<common_schema_all_of>(schema.at("allOf"), path + "/allOf");
}
return build_object(schema, path);
}
if (type_name == "string") {
if (schema.contains("allOf")) {
return build_alternatives<common_schema_all_of>(schema.at("allOf"), path + "/allOf");
}
return build_string(schema, path);
}
if (type_name == "array") {
return build_array(schema, path);
}
if (type_name == "integer") {
return build_integer(schema, path);
}
if (type_name == "number") {
return std::make_unique<common_schema_number>();
}
if (type_name == "boolean") {
return std::make_unique<common_schema_boolean>();
}
if (type_name == "null") {
return std::make_unique<common_schema_null>();
}
fail(path, "unrecognized type " + type_name);
}
public:
common_schema_builder(const common_json & root, common_schema_document & doc) : root_(root), doc_(doc) {}
common_schema_ptr build() {
auto node = build_node(root_, "#");
for (auto & entry : refs_) {
doc_.refs[entry.first] = std::move(entry.second);
}
for (auto * ref : pending_) {
ref->target = doc_.refs.at(ref->ref).get();
}
return node;
}
};
common_schema_document common_schema_from_json(const common_json & schema) {
common_schema_document doc;
doc.root = common_schema_builder(schema, doc).build();
return doc;
}
common_schema_ptr common_schema_from_json(const common_json & schema, common_schema_document & doc) {
return common_schema_builder(schema, doc).build();
}
static common_schema::value_type json_type(const common_json & value) {
if (value.is_null()) {
return common_schema::TYPE_NULL;
}
if (value.is_boolean()) {
return common_schema::TYPE_BOOLEAN;
}
if (value.is_number_integer()) {
return common_schema::TYPE_INTEGER;
}
if (value.is_number()) {
return common_schema::TYPE_NUMBER;
}
if (value.is_string()) {
return common_schema::TYPE_STRING;
}
if (value.is_array()) {
return common_schema::TYPE_ARRAY;
}
return common_schema::TYPE_OBJECT;
}
static common_schema::type_set value_types_impl(const common_schema & s, std::unordered_set<const common_schema *> & visited) {
switch (s.kind()) {
case common_schema::KIND_ANY:
return common_schema::type_set::all();
case common_schema::KIND_NULL:
return { common_schema::TYPE_NULL };
case common_schema::KIND_BOOLEAN:
return { common_schema::TYPE_BOOLEAN };
case common_schema::KIND_NUMBER:
return { common_schema::TYPE_NUMBER, common_schema::TYPE_INTEGER };
case common_schema::KIND_INTEGER:
return { common_schema::TYPE_INTEGER };
case common_schema::KIND_STRING:
return { common_schema::TYPE_STRING };
case common_schema::KIND_ARRAY:
case common_schema::KIND_TUPLE:
return { common_schema::TYPE_ARRAY };
case common_schema::KIND_OBJECT:
return { common_schema::TYPE_OBJECT };
case common_schema::KIND_CONST:
return { json_type(static_cast<const common_schema_const &>(s).value) };
case common_schema::KIND_ENUM: {
common_schema::type_set types;
for (const auto & value : static_cast<const common_schema_enum &>(s).values) {
types.add(json_type(value));
}
return types;
}
case common_schema::KIND_REF: {
const auto * target = static_cast<const common_schema_ref &>(s).target;
if (!target || !visited.insert(target).second) {
// a cycle contributes no type, to be safe
return {};
}
auto types = value_types_impl(*target, visited);
visited.erase(target);
return types;
}
case common_schema::KIND_ANY_OF: {
common_schema::type_set types;
for (const auto & child : static_cast<const common_schema_any_of &>(s).children) {
types |= value_types_impl(*child, visited);
}
return types;
}
case common_schema::KIND_ALL_OF: {
auto types = common_schema::type_set::all();
for (const auto & child : static_cast<const common_schema_all_of &>(s).children) {
types &= value_types_impl(*child, visited);
}
return types;
}
}
return {};
}
common_schema::type_set common_schema::value_types() const {
std::unordered_set<const common_schema *> visited;
return value_types_impl(*this, visited);
}
static bool may_be_string_impl(const common_schema & s, std::unordered_set<const common_schema *> & visited) {
switch (s.kind()) {
case common_schema::KIND_STRING:
return true;
case common_schema::KIND_CONST:
return static_cast<const common_schema_const &>(s).value.is_string();
case common_schema::KIND_ENUM:
for (const auto & v : static_cast<const common_schema_enum &>(s).values) {
if (v.is_string()) {
return true;
}
}
return false;
case common_schema::KIND_REF: {
// a cycle is taken as not a string, to be safe
const auto * target = static_cast<const common_schema_ref &>(s).target;
return target && visited.insert(target).second && may_be_string_impl(*target, visited);
}
case common_schema::KIND_ANY_OF:
for (const auto & child : static_cast<const common_schema_any_of &>(s).children) {
if (may_be_string_impl(*child, visited)) {
return true;
}
}
return false;
case common_schema::KIND_ALL_OF: {
// every child must allow a string, an any child constrains nothing
bool any_string = false;
for (const auto & child : static_cast<const common_schema_all_of &>(s).children) {
if (child->kind() == common_schema::KIND_ANY) {
continue;
}
if (!may_be_string_impl(*child, visited)) {
return false;
}
any_string = true;
}
return any_string;
}
default:
return false;
}
}
bool common_schema::may_be_string() const {
std::unordered_set<const common_schema *> visited;
return may_be_string_impl(*this, visited);
}
const char * common_schema::kind_name(node_kind kind) {
switch (kind) {
case KIND_ANY: return "any";
case KIND_REF: return "ref";
case KIND_ANY_OF: return "anyOf";
case KIND_ALL_OF: return "allOf";
case KIND_CONST: return "const";
case KIND_ENUM: return "enum";
case KIND_NULL: return "null";
case KIND_BOOLEAN: return "boolean";
case KIND_NUMBER: return "number";
case KIND_INTEGER: return "integer";
case KIND_STRING: return "string";
case KIND_ARRAY: return "array";
case KIND_TUPLE: return "tuple";
case KIND_OBJECT: return "object";
}
return "?";
}
const char * common_schema::type_name(value_type type) {
switch (type) {
case TYPE_NULL: return "null";
case TYPE_BOOLEAN: return "boolean";
case TYPE_NUMBER: return "number";
case TYPE_INTEGER: return "integer";
case TYPE_STRING: return "string";
case TYPE_ARRAY: return "array";
case TYPE_OBJECT: return "object";
}
return "?";
}
+203
View File
@@ -0,0 +1,203 @@
#pragma once
#include "json.h"
#include <cstdint>
#include <initializer_list>
#include <map>
#include <memory>
#include <string>
#include <vector>
// JSON schema, covering the subset that json_schema_to_grammar() can convert.
struct common_schema {
enum node_kind {
KIND_ANY,
KIND_REF,
KIND_ANY_OF,
KIND_ALL_OF,
KIND_CONST,
KIND_ENUM,
KIND_NULL,
KIND_BOOLEAN,
KIND_NUMBER,
KIND_INTEGER,
KIND_STRING,
KIND_ARRAY,
KIND_TUPLE,
KIND_OBJECT,
};
enum value_type {
TYPE_NULL,
TYPE_BOOLEAN,
TYPE_NUMBER,
TYPE_INTEGER,
TYPE_STRING,
TYPE_ARRAY,
TYPE_OBJECT,
};
enum string_format {
FORMAT_NONE,
FORMAT_UUID, // uuid, uuid1 .. uuid5
FORMAT_DATE,
FORMAT_TIME,
FORMAT_DATE_TIME,
};
class type_set {
uint32_t mask_ = 0;
public:
type_set() = default;
type_set(std::initializer_list<value_type> types) {
for (auto type : types) {
add(type);
}
}
static type_set all() {
return { TYPE_NULL, TYPE_BOOLEAN, TYPE_NUMBER, TYPE_INTEGER, TYPE_STRING, TYPE_ARRAY, TYPE_OBJECT };
}
void add(value_type type) { mask_ |= 1u << type; }
bool has(value_type type) const { return (mask_ & (1u << type)) != 0; }
bool is_only(value_type type) const { return mask_ == (1u << type); }
bool empty() const { return mask_ == 0; }
type_set & operator|=(const type_set & other) { mask_ |= other.mask_; return *this; }
type_set & operator&=(const type_set & other) { mask_ &= other.mask_; return *this; }
bool operator==(const type_set & other) const { return mask_ == other.mask_; }
bool operator!=(const type_set & other) const { return mask_ != other.mask_; }
};
virtual ~common_schema() = default;
virtual node_kind kind() const = 0;
type_set value_types() const;
// Whether a value matching the schema may be a string, through any branch of it.
bool may_be_string() const;
static const char * kind_name(node_kind kind);
static const char * type_name(value_type type);
};
using common_schema_ptr = std::unique_ptr<common_schema>;
struct common_schema_any : common_schema {
node_kind kind() const override { return KIND_ANY; }
};
// {"$ref": "#/..."}, only references into the same document are supported
struct common_schema_ref : common_schema {
std::string ref;
const common_schema * target = nullptr; // owned by common_schema_document::refs
explicit common_schema_ref(std::string ref) : ref(std::move(ref)) {}
node_kind kind() const override { return KIND_REF; }
};
// oneOf / anyOf, or a "type" array expanded to one alternative per type
struct common_schema_any_of : common_schema {
std::vector<common_schema_ptr> children;
node_kind kind() const override { return KIND_ANY_OF; }
};
struct common_schema_all_of : common_schema {
std::vector<common_schema_ptr> children;
node_kind kind() const override { return KIND_ALL_OF; }
};
struct common_schema_const : common_schema {
common_json value;
explicit common_schema_const(common_json value) : value(std::move(value)) {}
node_kind kind() const override { return KIND_CONST; }
};
struct common_schema_enum : common_schema {
std::vector<common_json> values;
node_kind kind() const override { return KIND_ENUM; }
};
struct common_schema_null : common_schema {
node_kind kind() const override { return KIND_NULL; }
};
struct common_schema_boolean : common_schema {
node_kind kind() const override { return KIND_BOOLEAN; }
};
struct common_schema_number : common_schema {
node_kind kind() const override { return KIND_NUMBER; }
};
// bounds are inclusive, exclusiveMinimum / exclusiveMaximum are folded in
struct common_schema_integer : common_schema {
int64_t minimum = INT64_MIN; // INT64_MIN for unbounded
int64_t maximum = INT64_MAX; // INT64_MAX for unbounded
node_kind kind() const override { return KIND_INTEGER; }
};
struct common_schema_string : common_schema {
std::string pattern; // empty when absent
string_format format = FORMAT_NONE;
int min_length = 0;
int max_length = -1; // -1 for unbounded
node_kind kind() const override { return KIND_STRING; }
};
struct common_schema_array : common_schema {
common_schema_ptr items; // a common_schema_any when "items" is absent
int min_items = 0;
int max_items = -1; // -1 for unbounded
node_kind kind() const override { return KIND_ARRAY; }
};
struct common_schema_tuple : common_schema {
std::vector<common_schema_ptr> items;
node_kind kind() const override { return KIND_TUPLE; }
};
struct common_schema_property {
std::string name;
common_schema_ptr schema;
bool required = false;
};
struct common_schema_object : common_schema {
std::vector<common_schema_property> properties; // in schema order
common_schema_ptr additional_properties; // null when not allowed
node_kind kind() const override { return KIND_OBJECT; }
};
struct common_schema_document {
common_schema_ptr root;
std::map<std::string, common_schema_ptr> refs;
};
// A document shared by the PEG parsers built from its nodes, which it keeps alive
using common_schema_document_ptr = std::shared_ptr<const common_schema_document>;
// Throws std::runtime_error when the schema falls outside the supported subset.
common_schema_document common_schema_from_json(const common_json & schema);
// Builds a schema that belongs to a document built earlier, e.g. one property of it.
// A $ref it cannot resolve on its own is looked up in doc.refs, the targets it resolves itself are added there.
// doc is unchanged when the schema is rejected.
common_schema_ptr common_schema_from_json(const common_json & schema, common_schema_document & doc);
-9
View File
@@ -129,15 +129,6 @@ common_chat_params common_chat_params_init_cohere2moe(const common_chat_template
if (include_grammar) {
data.grammar_lazy = !has_response_format && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_AUTO;
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.at("parameters");
builder.resolve_refs(schema);
});
if (has_response_format) {
auto schema = inputs.json_schema;
builder.resolve_refs(schema);
}
parser.build_grammar(builder, data.grammar_lazy);
});
+8 -28
View File
@@ -149,39 +149,28 @@ common_chat_params common_chat_params_init_deepseek_v3_2(const common_chat_templ
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
std::string name = function.at("name");
auto params = function.contains("parameters") ? function.at("parameters") : json::object();
const auto & props = params.contains("properties") ? params.at("properties") : json::object();
std::set<std::string> required;
if (params.contains("required")) {
required = params.at("required").get<std::set<std::string>>();
}
auto schema_info = common_schema_info();
schema_info.resolve_refs(params);
std::vector<common_peg_parser> required_parsers;
std::vector<common_peg_parser> optional_parsers;
for (const auto & [param_name, param_schema] : props.items()) {
bool is_required = required.find(param_name) != required.end();
bool is_string = schema_info.resolves_to_string(param_schema);
foreach_parameter(function, [&](const common_schema_property & param, const common_schema_document_ptr & doc) {
bool is_string = param.schema->may_be_string();
auto arg = p.tool_arg(
p.tool_arg_open(p.literal(PARAM_START + " name=\"") + p.tool_arg_name(p.literal(param_name)) +
p.tool_arg_open(p.literal(PARAM_START + " name=\"") + p.tool_arg_name(p.literal(param.name)) +
p.literal("\" string=\"" + std::string(is_string ? "true" : "false") + "\">")) +
(is_string ?
p.tool_arg_string_value(p.until(PARAM_END)) :
p.tool_arg_json_value(p.schema(p.json(), "tool-" + name + "-arg-" + param_name + "-schema",
param_schema, false))) +
p.tool_arg_json_value(p.schema(p.json(), "tool-" + name + "-arg-" + param.name + "-schema",
doc, *param.schema))) +
p.tool_arg_close(p.literal(PARAM_END)));
auto named_arg = p.rule("tool-" + name + "-arg-" + param_name, arg);
if (is_required) {
auto named_arg = p.rule("tool-" + name + "-arg-" + param.name, arg);
if (param.required) {
required_parsers.push_back(named_arg);
} else {
optional_parsers.push_back(named_arg);
}
}
});
common_peg_parser args_seq = p.eps();
for (size_t i = 0; i < required_parsers.size(); i++) {
@@ -266,15 +255,6 @@ common_chat_params common_chat_params_init_deepseek_v3_2(const common_chat_templ
if (include_grammar) {
data.grammar_lazy = has_tools && !require_tools;
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
builder.resolve_refs(schema);
});
if (has_response_format) {
auto schema = inputs.json_schema;
builder.resolve_refs(schema);
}
parser.build_grammar(builder, data.grammar_lazy);
});
-5
View File
@@ -82,11 +82,6 @@ common_chat_params common_chat_params_init_functionary_v3_2(const common_chat_te
data.grammar_lazy = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_AUTO;
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.at("parameters");
builder.resolve_refs(schema);
});
parser.build_grammar(builder, data.grammar_lazy);
});
-9
View File
@@ -291,15 +291,6 @@ common_chat_params common_chat_params_init_gemma4(const common_chat_template &
if (include_grammar) {
data.grammar_lazy = !(has_response_format || (has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED));
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.at("parameters");
builder.resolve_refs(schema);
});
if (has_response_format) {
auto schema = inputs.json_schema;
builder.resolve_refs(schema);
}
parser.build_grammar(builder, data.grammar_lazy);
});
-5
View File
@@ -65,11 +65,6 @@ common_chat_params common_chat_params_init_gigachat_v3(
data.grammar_lazy = has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_AUTO;
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.at("parameters");
builder.resolve_refs(schema);
});
parser.build_grammar(builder, data.grammar_lazy);
});
-9
View File
@@ -143,15 +143,6 @@ common_chat_params common_chat_params_init_gpt_oss(const common_chat_template &
if (include_grammar) {
data.grammar_lazy = !(has_response_format || (has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED));
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.at("parameters");
builder.resolve_refs(schema);
});
if (has_response_format) {
auto schema = inputs.json_schema;
builder.resolve_refs(schema);
}
parser.build_grammar(builder, data.grammar_lazy);
});
-5
View File
@@ -116,11 +116,6 @@ common_chat_params common_chat_params_init_kimi_k2(const common_chat_template &
if (include_grammar) {
data.grammar_lazy = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_AUTO;
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.at("parameters");
builder.resolve_refs(schema);
});
parser.build_grammar(builder, data.grammar_lazy);
});
-7
View File
@@ -155,13 +155,6 @@ common_chat_params common_chat_params_init_kimi_k3(const common_chat_template &
if (include_grammar) {
data.grammar_lazy = inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED;
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
if (function.contains("parameters")) {
auto schema = function.at("parameters");
builder.resolve_refs(schema);
}
});
parser.build_grammar(builder, data.grammar_lazy);
});
-9
View File
@@ -98,15 +98,6 @@ common_chat_params common_chat_params_init_lfm2(const common_chat_template &
if (include_grammar) {
data.grammar_lazy = !(has_response_format || (has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED));
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.at("parameters");
builder.resolve_refs(schema);
});
if (has_response_format) {
auto schema = inputs.json_schema;
builder.resolve_refs(schema);
}
parser.build_grammar(builder, data.grammar_lazy);
});
+19 -33
View File
@@ -71,32 +71,27 @@ common_chat_params common_chat_params_init_minicpm5(const common_chat_template &
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
const std::string name = function.at("name");
auto params = function.contains("parameters") ? function.at("parameters") : json::object();
std::vector<common_peg_parser> arg_rules;
foreach_parameter(function, [&](const common_schema_property & prop, const common_schema_document_ptr & doc) {
auto value_parser = p.eps();
if (prop.schema->may_be_string()) {
value_parser = string_value;
} else {
value_parser = p.tool_arg_json_value(
p.schema(p.json(), "tool-" + name + "-arg-" + prop.name + "-schema", doc, *prop.schema)
) + p.tool_arg_close(p.literal("</param>"));
}
arg_rules.push_back(p.tool_arg(
p.tool_arg_open(p.literal("<param name=\"") + p.tool_arg_name(p.literal(prop.name)) + p.literal("\">")) +
value_parser
));
});
auto args = p.eps();
if (params.contains("properties") && params.at("properties").is_object() && !params.at("properties").empty()) {
auto schema_info = common_schema_info();
schema_info.resolve_refs(params);
auto arg_choice = p.choice();
for (const auto & [prop_name, prop_schema] : params.at("properties").items()) {
auto value_parser = p.eps();
if (schema_info.resolves_to_string(prop_schema)) {
value_parser = string_value;
} else {
value_parser = p.tool_arg_json_value(
p.schema(p.json(), "tool-" + name + "-arg-" + prop_name + "-schema", prop_schema, false)
) + p.tool_arg_close(p.literal("</param>"));
}
auto arg_rule = p.tool_arg(
p.tool_arg_open(p.literal("<param name=\"") + p.tool_arg_name(p.literal(prop_name)) + p.literal("\">")) +
value_parser
);
arg_choice |= arg_rule;
}
args = p.zero_or_more(arg_choice + p.space());
if (!arg_rules.empty()) {
args = p.zero_or_more(p.choice(arg_rules) + p.space());
}
auto tool_parser = p.tool(
@@ -123,15 +118,6 @@ common_chat_params common_chat_params_init_minicpm5(const common_chat_template &
if (include_grammar) {
data.grammar_lazy = !(has_response_format || (has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED));
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
builder.resolve_refs(schema);
});
if (has_response_format) {
auto schema = inputs.json_schema;
builder.resolve_refs(schema);
}
parser.build_grammar(builder, data.grammar_lazy);
});
+25 -55
View File
@@ -84,29 +84,18 @@ common_chat_params common_chat_params_init_minimax_m3(const common_chat_template
return generation_prompt + reasoning + p.content(p.rest()) + end;
}
auto alternatives_of = [](const json & schema) -> std::optional<json> {
for (const auto * keyword : { "oneOf", "anyOf" }) {
if (schema.contains(keyword) && schema.at(keyword).is_array() && !schema.at(keyword).empty()) {
return schema.at(keyword);
}
}
return std::nullopt;
};
auto tool_choice = p.choice();
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
std::string name = function.at("name");
auto params = function.contains("parameters") ? function.at("parameters") : json::object();
auto schema_info = common_schema_info();
schema_info.resolve_refs(params);
auto doc = std::make_shared<const common_schema_document>(common_schema_from_json(params));
// The template expands argument values recursively in XML (see the to_xml() macro)
std::function<common_peg_parser(const json &, const std::string &, const std::string &)> value_of;
std::function<common_peg_parser(const json &, const std::string &)> members_of;
std::function<common_peg_parser(const common_schema &, const std::string &, const std::string &)> value_of;
std::function<common_peg_parser(const common_schema_object &, const std::string &)> members_of;
auto element_of = [&](const std::string & tag, const json & schema, const std::string & rule_name) {
auto element_of = [&](const std::string & tag, const common_schema & schema, const std::string & rule_name) {
const std::string close = NS + "</" + tag + ">";
return p.rule(rule_name,
p.tool_arg(
@@ -117,69 +106,57 @@ common_chat_params common_chat_params_init_minimax_m3(const common_chat_template
value_of(schema, rule_name, close)));
};
value_of = [&](const json & schema,
value_of = [&](const common_schema & schema,
const std::string & rule_name,
const std::string & close) -> common_peg_parser {
auto close_tag = p.tool_arg_close(p.literal(close));
// A string accepts anything, so a union with a string alternative is a string
if (schema_info.resolves_to_string(schema)) {
if (schema.may_be_string()) {
return p.ac(p.tool_arg_string_value(p.until(close)) + close_tag, close);
}
if (auto alternatives = alternatives_of(schema)) {
if (schema.kind() == common_schema::KIND_ANY_OF) {
std::vector<common_peg_parser> choices;
size_t index = 0;
for (const auto & alternative : *alternatives) {
for (const auto & alternative : static_cast<const common_schema_any_of &>(schema).children) {
const std::string alt_name = rule_name + "-" + std::to_string(index++);
// There is a risk that this breaks streaming deltas, but that's a risk we
// assume to provide tool arg streaming.
choices.push_back(value_of(alternative, alt_name, close));
choices.push_back(value_of(*alternative, alt_name, close));
}
return p.choice(choices);
}
const std::string type = schema.contains("type") && schema.at("type").is_string()
? schema.at("type").get<std::string>()
: "";
if (type == "object" && schema.contains("properties")) {
return p.tag(mm3::TOOL_ARG_OBJECT, members_of(schema, rule_name)) + p.space() + close_tag;
if (schema.kind() == common_schema::KIND_OBJECT) {
const auto & object = static_cast<const common_schema_object &>(schema);
if (!object.properties.empty()) {
return p.tag(mm3::TOOL_ARG_OBJECT, members_of(object, rule_name)) + p.space() + close_tag;
}
}
if (type == "array" && schema.contains("items")) {
if (schema.kind() == common_schema::KIND_ARRAY) {
const std::string item_close = NS + "</item>";
auto item = p.rule(rule_name + "-item",
p.tag(mm3::TOOL_ARG_ITEM,
p.literal(NS + "<item>") +
value_of(schema.at("items"), rule_name + "-item", item_close)));
value_of(*static_cast<const common_schema_array &>(schema).items, rule_name + "-item", item_close)));
return p.tag(mm3::TOOL_ARG_ARRAY, p.repeat(p.space() + item, 0, -1)) + p.space() + close_tag;
}
return p.tool_arg_json_value(p.schema(p.json(), rule_name + "-schema", schema, false)) + close_tag;
return p.tool_arg_json_value(p.schema(p.json(), rule_name + "-schema", doc, schema)) + close_tag;
};
// Required properties in schema order, then any number of optional ones in any order.
members_of = [&](const json & schema, const std::string & rule_prefix) -> common_peg_parser {
const auto & props = schema.at("properties");
std::set<std::string> required;
if (schema.contains("required")) {
required = schema.at("required").get<std::set<std::string>>();
}
members_of = [&](const common_schema_object & object, const std::string & rule_prefix) -> common_peg_parser {
std::vector<common_peg_parser> required_elements;
std::vector<common_peg_parser> optional_elements;
for (const auto & [key, key_schema] : props.items()) {
auto element = element_of(key, key_schema, rule_prefix + "-" + key);
if (required.find(key) != required.end()) {
required_elements.push_back(element);
} else {
optional_elements.push_back(element);
}
for (const auto & prop : object.properties) {
auto element = element_of(prop.name, *prop.schema, rule_prefix + "-" + prop.name);
(prop.required ? required_elements : optional_elements).push_back(element);
}
common_peg_parser members = p.eps();
@@ -201,8 +178,10 @@ common_chat_params common_chat_params_init_minimax_m3(const common_chat_template
return members;
};
common_peg_parser invoke_body =
params.contains("properties") ? members_of(params, "tool-" + name + "-arg") : p.eps();
common_peg_parser invoke_body = p.eps();
if (doc->root->kind() == common_schema::KIND_OBJECT) {
invoke_body = members_of(static_cast<const common_schema_object &>(*doc->root), "tool-" + name + "-arg");
}
auto func_parser = p.tool(
p.tool_open(p.literal(NS + "<invoke name=\"") +
@@ -238,15 +217,6 @@ common_chat_params common_chat_params_init_minimax_m3(const common_chat_template
if (include_grammar) {
data.grammar_lazy = !(has_response_format || (has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED));
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
builder.resolve_refs(schema);
});
if (has_response_format) {
auto schema = inputs.json_schema;
builder.resolve_refs(schema);
}
parser.build_grammar(builder, data.grammar_lazy);
});
-9
View File
@@ -114,15 +114,6 @@ common_chat_params common_chat_params_init_ministral_3(const common_chat_templat
data.grammar_lazy = has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_AUTO;
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.at("parameters");
builder.resolve_refs(schema);
});
if (has_response_format) {
auto schema = inputs.json_schema;
builder.resolve_refs(schema);
}
parser.build_grammar(builder, data.grammar_lazy);
});
+18 -28
View File
@@ -74,31 +74,26 @@ common_chat_params common_chat_params_init_muse_glimmer(const common_chat_templa
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
const std::string name = function.at("name");
auto params = function.contains("parameters") ? function.at("parameters") : json::object();
std::vector<common_peg_parser> arg_rules;
foreach_parameter(function, [&](const common_schema_property & prop, const common_schema_document_ptr & doc) {
auto value_parser = p.eps();
if (prop.schema->may_be_string()) {
value_parser = string_value;
} else {
value_parser = p.tool_arg_json_value(
p.schema(p.json(), "tool-" + name + "-arg-" + prop.name + "-schema", doc, *prop.schema))
+ p.tool_arg_close(p.literal("</atem:parameter>"));
}
arg_rules.push_back(p.tool_arg(
p.tool_arg_open(p.literal("<atem:parameter name=\"") + p.tool_arg_name(p.literal(prop.name)) + p.literal("\">")) +
value_parser));
});
auto args = p.eps();
if (params.contains("properties") && params.at("properties").is_object() && !params.at("properties").empty()) {
auto schema_info = common_schema_info();
schema_info.resolve_refs(params);
auto arg_choice = p.choice();
for (const auto & [prop_name, prop_schema] : params.at("properties").items()) {
auto value_parser = p.eps();
if (schema_info.resolves_to_string(prop_schema)) {
value_parser = string_value;
} else {
value_parser = p.tool_arg_json_value(
p.schema(p.json(), "tool-" + name + "-arg-" + prop_name + "-schema", prop_schema, false))
+ p.tool_arg_close(p.literal("</atem:parameter>"));
}
auto arg_rule = p.tool_arg(
p.tool_arg_open(p.literal("<atem:parameter name=\"") + p.tool_arg_name(p.literal(prop_name)) + p.literal("\">")) +
value_parser);
arg_choice |= arg_rule;
}
args = p.zero_or_more(arg_choice + p.space());
if (!arg_rules.empty()) {
args = p.zero_or_more(p.choice(arg_rules) + p.space());
}
auto tool_parser = p.tool(
@@ -131,11 +126,6 @@ common_chat_params common_chat_params_init_muse_glimmer(const common_chat_templa
if (include_grammar) {
data.grammar_lazy = inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED;
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
builder.resolve_refs(schema);
});
parser.build_grammar(builder, data.grammar_lazy);
});
data.grammar_triggers = {
+7 -16
View File
@@ -2,8 +2,6 @@
#include "log.h"
#include <set>
void foreach_function(const json & tools, const std::function<void(const json &)> & fn) {
for (const auto & tool : tools) {
if (!tool.contains("type") || tool.at("type") != "function" || !tool.contains("function")) {
@@ -14,21 +12,14 @@ void foreach_function(const json & tools, const std::function<void(const json &)
}
}
void foreach_parameter(const json & function, const std::function<void(const std::string &, const json &, bool)> & fn) {
if (!function.contains("parameters") || !function.at("parameters").is_object()) {
void foreach_parameter(const json & function, const std::function<void(const common_schema_property &, const common_schema_document_ptr &)> & fn) {
auto params = function.contains("parameters") ? function.at("parameters") : json::object();
auto doc = std::make_shared<const common_schema_document>(common_schema_from_json(params));
const auto * object = dynamic_cast<const common_schema_object *>(doc->root.get());
if (!object) {
return;
}
const auto & params = function.at("parameters");
if (!params.contains("properties") || !params.at("properties").is_object()) {
return;
}
const auto & props = params.at("properties");
std::set<std::string> required;
if (params.contains("required") && params.at("required").is_array()) {
required = params.at("required").get<std::set<std::string>>();
}
for (const auto & [name, prop] : props.items()) {
bool is_required = (required.find(name) != required.end());
fn(name, prop, is_required);
for (const auto & prop : object->properties) {
fn(prop, doc);
}
}
+2 -2
View File
@@ -20,8 +20,8 @@ using json = common_json;
// iterate over the function tools of an OpenAI-style tools array
void foreach_function(const json & tools, const std::function<void(const json &)> & fn);
// iterate over the parameters of a function tool, flagging the ones listed as required
void foreach_parameter(const json & function, const std::function<void(const std::string &, const json &, bool)> & fn);
// iterate over the parameters of a function tool, with the document that owns them
void foreach_parameter(const json & function, const std::function<void(const common_schema_property &, const common_schema_document_ptr &)> & fn);
// render a template; the override arguments let a parser feed in messages, tools or context it has rewritten
std::string common_chat_template_direct_apply_impl(
+8 -21
View File
@@ -93,28 +93,24 @@ common_chat_params common_chat_params_init_qwen3_coder(const common_chat_templat
auto tool_choice = p.choice();
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
std::string name = function.at("name");
auto parameters = function.contains("parameters") ? function.at("parameters") : json::object();
auto schema_info = common_schema_info();
schema_info.resolve_refs(parameters);
const auto & function = tool.at("function");
std::string name = function.at("name");
std::vector<common_peg_parser> required_args;
std::vector<common_peg_parser> optional_args;
foreach_parameter(function, [&](const std::string & param_name, const json & param_schema, bool is_required) {
auto rule_name = "tool-" + name + "-arg-" + param_name;
foreach_parameter(function, [&](const common_schema_property & param, const common_schema_document_ptr & doc) {
auto rule_name = "tool-" + name + "-arg-" + param.name;
auto arg_open = p.tool_arg_open("<parameter=" + p.tool_arg_name(p.literal(param_name)) + ">\n");
auto arg_open = p.tool_arg_open("<parameter=" + p.tool_arg_name(p.literal(param.name)) + ">\n");
auto arg_value = schema_info.resolves_to_string(param_schema) ?
auto arg_value = param.schema->may_be_string() ?
arg_string :
p.tool_arg_json_value(p.schema(p.json(), rule_name + "-schema", param_schema)) + arg_close;
p.tool_arg_json_value(p.schema(p.json(), rule_name + "-schema", doc, *param.schema)) + arg_close;
auto arg_rule = p.rule(rule_name, p.tool_arg(arg_open + arg_value));
(is_required ? required_args : optional_args).push_back(arg_rule);
(param.required ? required_args : optional_args).push_back(arg_rule);
});
// Accept required arguments in any order, as Qwen does not always adhere to the
@@ -158,15 +154,6 @@ common_chat_params common_chat_params_init_qwen3_coder(const common_chat_templat
data.grammar_lazy = has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_AUTO;
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
foreach_function(inputs.tools, [&](const json & tool) {
const auto & function = tool.at("function");
auto schema = function.contains("parameters") ? function.at("parameters") : json::object();
builder.resolve_refs(schema);
});
if (has_response_format) {
auto schema = inputs.json_schema;
builder.resolve_refs(schema);
}
parser.build_grammar(builder, data.grammar_lazy);
});
+11 -31
View File
@@ -953,7 +953,7 @@ std::string common_peg_arena::dump_impl(common_peg_parser_id
} else if constexpr (std::is_same_v<T, common_peg_until_parser>) {
return "Until(" + string_join(p.delimiters, " | ") + ")";
} else if constexpr (std::is_same_v<T, common_peg_schema_parser>) {
return "Schema(" + dump_impl(p.child, visited) + ", " + (p.schema ? p.schema->dump() : "null") + ")";
return "Schema(" + dump_impl(p.child, visited) + ", " + (p.node ? common_schema::kind_name(p.node->kind()) : "null") + ")";
} else if constexpr (std::is_same_v<T, common_peg_rule_parser>) {
return "Rule(" + p.name + ", " + dump_impl(p.child, visited) + ")";
} else if constexpr (std::is_same_v<T, common_peg_ref_parser>) {
@@ -1119,8 +1119,13 @@ common_peg_parser common_peg_parser_builder::chars(const std::string & classes,
return wrap(arena_.add_parser(common_peg_chars_parser{classes, ranges, negated, min, max}));
}
common_peg_parser common_peg_parser_builder::schema(const common_peg_parser & p, const std::string & name, common_schema_document_ptr doc, const common_schema & node, bool raw) {
return wrap(arena_.add_parser(common_peg_schema_parser{p.id(), name, std::move(doc), &node, raw}));
}
common_peg_parser common_peg_parser_builder::schema(const common_peg_parser & p, const std::string & name, const common_json & schema, bool raw) {
return wrap(arena_.add_parser(common_peg_schema_parser{p.id(), name, std::make_shared<common_json>(schema), raw}));
auto doc = std::make_shared<const common_schema_document>(common_schema_from_json(schema));
return this->schema(p, name, doc, *doc->root, raw);
}
common_peg_parser common_peg_parser_builder::rule(const std::string & name, const common_peg_parser & p, bool trigger) {
@@ -1573,30 +1578,9 @@ static std::set<std::string> collect_reachable_rules(
// GBNF generation implementation
void common_peg_arena::build_grammar(const common_grammar_builder & builder, bool lazy) const {
// A raw string value is parsed by the child rather than constrained by the schema
auto schema_delegates = [](const common_peg_schema_parser & s) -> bool {
if (!s.schema) {
return true;
}
if (s.raw && s.schema->contains("type")) {
const auto & type_val = s.schema->at("type");
if (type_val.is_string() && type_val == "string") {
return true;
}
// Handle nullable types like ["string", "null"] - delegate when the
// non-null type is string, since the tagged format uses raw text
if (type_val.is_array()) {
for (const auto & t : type_val) {
if (t.is_string() && t.get<std::string>() != "null") {
return t.get<std::string>() == "string";
}
}
}
}
// Delegate for enum schemas in raw mode - enum values are literal strings
if (s.raw && !s.schema->contains("type") && s.schema->contains("enum")) {
return true;
}
return false;
return !s.node || (s.raw && s.node->may_be_string());
};
// Unwrap the parser so we can properly check if it's a sequence or choice
@@ -1731,7 +1715,7 @@ void common_peg_arena::build_grammar(const common_grammar_builder & builder, boo
if (schema_delegates(p)) {
return to_gbnf(p.child);
}
return builder.add_schema(p.name, *p.schema);
return builder.add_schema(p.name, *p.node);
} else if constexpr (std::is_same_v<T, common_peg_rule_parser>) {
return p.name;
} else if constexpr (std::is_same_v<T, common_peg_ref_parser>) {
@@ -1859,7 +1843,6 @@ static common_json serialize_parser_variant(const common_peg_parser_variant & va
{"type", "schema"},
{"child", p.child},
{"name", p.name},
{"schema", p.schema ? *p.schema : json(nullptr)},
{"raw", p.raw}
};
} else if constexpr (std::is_same_v<T, common_peg_rule_parser>) {
@@ -1999,15 +1982,12 @@ static common_peg_parser_variant deserialize_parser_variant(const common_json &
return common_peg_until_parser{j["delimiters"].get<std::vector<std::string>>()};
}
if (type == "schema") {
if (!j.contains("child") || !j.contains("name") || !j.contains("schema") || !j.contains("raw")) {
if (!j.contains("child") || !j.contains("name") || !j.contains("raw")) {
throw std::runtime_error("schema parser missing required fields");
}
common_peg_schema_parser parser;
parser.child = j["child"].get<common_peg_parser_id>();
parser.name = j["name"];
if (!j["schema"].is_null()) {
parser.schema = std::make_shared<common_json>(j["schema"]);
}
parser.raw = j["raw"].get<bool>();
return parser;
}
+7 -3
View File
@@ -1,5 +1,6 @@
#pragma once
#include "json-schema.h"
#include "json.h"
#include <memory>
@@ -245,7 +246,8 @@ struct common_peg_until_parser {
struct common_peg_schema_parser {
common_peg_parser_id child;
std::string name;
std::shared_ptr<common_json> schema;
common_schema_document_ptr doc; // owns node
const common_schema * node = nullptr;
// Indicates if the GBNF should accept a raw string that matches the schema.
bool raw;
@@ -488,8 +490,10 @@ class common_peg_parser_builder {
// A marker, i.e. text delimited by a pair of <> or []
common_peg_parser marker();
// Wraps a parser with JSON schema metadata for grammar generation.
// Used internally to convert JSON schemas to GBNF grammar rules.
// Wraps a parser with the schema its GBNF is generated from, a node of the document that owns it
common_peg_parser schema(const common_peg_parser & p, const std::string & name, common_schema_document_ptr doc, const common_schema & node, bool raw = false);
// Parses the JSON schema into a document of its own
common_peg_parser schema(const common_peg_parser & p, const std::string & name, const common_json & schema, bool raw = false);
// Creates a named rule, stores it in the grammar, and returns a ref.
-842
View File
@@ -1,842 +0,0 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import itertools
import json
import re
import sys
from typing import Any, List, Optional, Set, Tuple, Union
def _build_repetition(item_rule, min_items, max_items, separator_rule=None):
if max_items == 0:
return ""
if min_items == 0 and max_items == 1:
return f'{item_rule}?'
if not separator_rule:
if min_items == 1 and max_items is None:
return f'{item_rule}+'
elif min_items == 0 and max_items is None:
return f'{item_rule}*'
else:
return f'{item_rule}{{{min_items},{max_items if max_items is not None else ""}}}'
result = item_rule + ' ' + _build_repetition(f'({separator_rule} {item_rule})', min_items - 1 if min_items > 0 else 0, max_items - 1 if max_items is not None else None)
return f'({result})?' if min_items == 0 else result
def _generate_min_max_int(min_value: Optional[int], max_value: Optional[int], out: list, decimals_left: int = 16, top_level: bool = True):
def digit_range(from_char: str, to_char: str):
out.append("[")
if from_char == to_char:
out.append(from_char)
else:
out.append(from_char)
out.append("-")
out.append(to_char)
out.append("]")
def more_digits(min_digits: int, max_digits: int):
out.append("[0-9]")
if min_digits == max_digits and min_digits == 1:
return
out.append("{")
out.append(str(min_digits))
if max_digits != min_digits:
out.append(",")
if max_digits != sys.maxsize:
out.append(str(max_digits))
out.append("}")
def uniform_range(from_str: str, to_str: str):
i = 0
while i < len(from_str) and from_str[i] == to_str[i]:
i += 1
if i > 0:
out.append("\"")
out.append(from_str[:i])
out.append("\"")
if i < len(from_str):
if i > 0:
out.append(" ")
sub_len = len(from_str) - i - 1
if sub_len > 0:
from_sub = from_str[i+1:]
to_sub = to_str[i+1:]
sub_zeros = "0" * sub_len
sub_nines = "9" * sub_len
to_reached = False
out.append("(")
if from_sub == sub_zeros:
digit_range(from_str[i], chr(ord(to_str[i]) - 1))
out.append(" ")
more_digits(sub_len, sub_len)
else:
out.append("[")
out.append(from_str[i])
out.append("] ")
out.append("(")
uniform_range(from_sub, sub_nines)
out.append(")")
if ord(from_str[i]) < ord(to_str[i]) - 1:
out.append(" | ")
if to_sub == sub_nines:
digit_range(chr(ord(from_str[i]) + 1), to_str[i])
to_reached = True
else:
digit_range(chr(ord(from_str[i]) + 1), chr(ord(to_str[i]) - 1))
out.append(" ")
more_digits(sub_len, sub_len)
if not to_reached:
out.append(" | ")
digit_range(to_str[i], to_str[i])
out.append(" ")
uniform_range(sub_zeros, to_sub)
out.append(")")
else:
out.append("[")
out.append(from_str[i])
out.append("-")
out.append(to_str[i])
out.append("]")
if min_value is not None and max_value is not None:
if min_value < 0 and max_value < 0:
out.append("\"-\" (")
_generate_min_max_int(-max_value, -min_value, out, decimals_left, top_level=True)
out.append(")")
return
if min_value < 0:
out.append("\"-\" (")
_generate_min_max_int(0, -min_value, out, decimals_left, top_level=True)
out.append(") | ")
min_value = 0
min_s = str(min_value)
max_s = str(max_value)
min_digits = len(min_s)
max_digits = len(max_s)
for digits in range(min_digits, max_digits):
uniform_range(min_s, "9" * digits)
min_s = "1" + "0" * digits
out.append(" | ")
uniform_range(min_s, max_s)
return
less_decimals = max(decimals_left - 1, 1)
if min_value is not None:
if min_value < 0:
out.append("\"-\" (")
_generate_min_max_int(None, -min_value, out, decimals_left, top_level=False)
out.append(") | [0] | [1-9] ")
more_digits(0, decimals_left - 1)
elif min_value == 0:
if top_level:
out.append("[0] | [1-9] ")
more_digits(0, less_decimals)
else:
more_digits(1, decimals_left)
elif min_value <= 9:
c = str(min_value)
range_start = '1' if top_level else '0'
if c > range_start:
digit_range(range_start, chr(ord(c) - 1))
out.append(" ")
more_digits(1, less_decimals)
out.append(" | ")
digit_range(c, "9")
out.append(" ")
more_digits(0, less_decimals)
else:
min_s = str(min_value)
length = len(min_s)
c = min_s[0]
if c > "1":
digit_range("1" if top_level else "0", chr(ord(c) - 1))
out.append(" ")
more_digits(length, less_decimals)
out.append(" | ")
digit_range(c, c)
out.append(" (")
_generate_min_max_int(int(min_s[1:]), None, out, less_decimals, top_level=False)
out.append(")")
if c < "9":
out.append(" | ")
digit_range(chr(ord(c) + 1), "9")
out.append(" ")
more_digits(length - 1, less_decimals)
return
if max_value is not None:
if max_value >= 0:
if top_level:
out.append("\"-\" [1-9] ")
more_digits(0, less_decimals)
out.append(" | ")
_generate_min_max_int(0, max_value, out, decimals_left, top_level=True)
else:
out.append("\"-\" (")
_generate_min_max_int(-max_value, None, out, decimals_left, top_level=False)
out.append(")")
return
raise RuntimeError("At least one of min_value or max_value must be set")
class BuiltinRule:
def __init__(self, content: str, deps: list | None = None):
self.content = content
self.deps = deps or []
# Constraining spaces to prevent model "running away".
SPACE_RULE = '| " " | "\\n"{1,2} [ \\t]{0,20}'
PRIMITIVE_RULES = {
'boolean' : BuiltinRule('("true" | "false")', []),
'decimal-part' : BuiltinRule('[0-9]{1,16}', []),
'integral-part': BuiltinRule('[0] | [1-9] [0-9]{0,15}', []),
'number' : BuiltinRule('("-"? integral-part) ("." decimal-part)? ([eE] [-+]? integral-part)?', ['integral-part', 'decimal-part']),
'integer' : BuiltinRule('("-"? integral-part)', ['integral-part']),
'value' : BuiltinRule('object | array | string | number | boolean | null', ['object', 'array', 'string', 'number', 'boolean', 'null']),
'object' : BuiltinRule('"{" space ( string ":" space value ("," space string ":" space value)* )? space "}"', ['string', 'value']),
'array' : BuiltinRule('"[" space ( value ("," space value)* )? space "]"', ['value']),
'uuid' : BuiltinRule(r'"\"" [0-9a-fA-F]{8} "-" [0-9a-fA-F]{4} "-" [0-9a-fA-F]{4} "-" [0-9a-fA-F]{4} "-" [0-9a-fA-F]{12} "\""', []),
'char' : BuiltinRule(r'[^"\\\x7F\x00-\x1F] | [\\] (["\\bfnrt] | "u" [0-9a-fA-F]{4})', []),
'string' : BuiltinRule(r'"\"" char* "\""', ['char']),
'null' : BuiltinRule('"null"', []),
}
# TODO: support "uri", "email" string formats
STRING_FORMAT_RULES = {
'date' : BuiltinRule('[0-9]{4} "-" ( "0" [1-9] | "1" [0-2] ) "-" ( \"0\" [1-9] | [1-2] [0-9] | "3" [0-1] )', []),
'time' : BuiltinRule('([01] [0-9] | "2" [0-3]) ":" [0-5] [0-9] ":" [0-5] [0-9] ( "." [0-9]{3} )? ( "Z" | ( "+" | "-" ) ( [01] [0-9] | "2" [0-3] ) ":" [0-5] [0-9] )', []),
'date-time' : BuiltinRule('date "T" time', ['date', 'time']),
'date-string' : BuiltinRule('"\\"" date "\\""', ['date']),
'time-string' : BuiltinRule('"\\"" time "\\""', ['time']),
'date-time-string': BuiltinRule('"\\"" date-time "\\""', ['date-time']),
}
DOTALL = '[\\U00000000-\\U0010FFFF]'
DOT = '[^\\x0A\\x0D]'
RESERVED_NAMES = set(["root", "dot", *PRIMITIVE_RULES.keys(), *STRING_FORMAT_RULES.keys()])
INVALID_RULE_CHARS_RE = re.compile(r'[^a-zA-Z0-9-]+')
GRAMMAR_LITERAL_ESCAPE_RE = re.compile(r'[\r\n"\\]')
GRAMMAR_RANGE_LITERAL_ESCAPE_RE = re.compile(r'[\r\n"\]\-\\]')
GRAMMAR_LITERAL_ESCAPES = {'\r': '\\r', '\n': '\\n', '"': '\\"', '-': '\\-', ']': '\\]', '\\': '\\\\'}
NON_LITERAL_SET = set('|.()[]{}*+?')
ESCAPED_IN_REGEXPS_BUT_NOT_IN_LITERALS = set('^$.[]()|{}*+?')
class SchemaConverter:
def __init__(self, *, prop_order, allow_fetch, dotall, raw_pattern):
self._prop_order = prop_order
self._allow_fetch = allow_fetch
self._dotall = dotall
self._raw_pattern = raw_pattern
self._rules = {
'space': SPACE_RULE,
}
self._refs = {}
self._refs_being_resolved = set()
def _format_literal(self, literal):
escaped = GRAMMAR_LITERAL_ESCAPE_RE.sub(
lambda m: GRAMMAR_LITERAL_ESCAPES.get(m.group(0)) or m.group(0), literal
)
return f'"{escaped}"'
def not_literal(self, literal: str, dotall: bool = True, maybe_escaped_underscores = False) -> str:
'''
not_literal('a') -> '[^a]'
not_literal('abc') -> '([^a] | "a" ([^b] | "b" ([^c])?)?)?'
'''
assert len(literal) > 0, 'Empty literal not supported'
def recurse(i: int):
c = literal[i]
if maybe_escaped_underscores and c == '_':
yield f'[^{c}\\\\]'
yield ' | '
yield f'"\\\\"? "{c}"'
else:
yield f'[^{c}]'
if i < len(literal) - 1:
yield ' | '
yield self._format_literal(c)
yield ' ('
yield from recurse(i + 1)
yield ')?'
return ''.join(('(', *recurse(0), ')'))
def _not_strings(self, strings):
class TrieNode:
def __init__(self):
self.children = {}
self.is_end_of_string = False
def insert(self, string):
node = self
for c in string:
node = node.children.setdefault(c, TrieNode())
node.is_end_of_string = True
trie = TrieNode()
for s in strings:
trie.insert(s)
char_rule = self._add_primitive('char', PRIMITIVE_RULES['char'])
out = ['["] ( ']
def visit(node):
rejects = []
first = True
for c in sorted(node.children.keys()):
child = node.children[c]
rejects.append(c)
if first:
first = False
else:
out.append(' | ')
out.append(f'[{c}]')
if child.children:
out.append(f' (')
visit(child)
out.append(')')
elif child.is_end_of_string:
out.append(f' {char_rule}+')
if node.children:
if not first:
out.append(' | ')
out.append(f'[^"{"".join(rejects)}] {char_rule}*')
visit(trie)
out.append(f' ){"" if trie.is_end_of_string else "?"} ["]')
return ''.join(out)
def _add_rule(self, name, rule):
esc_name = INVALID_RULE_CHARS_RE.sub('-', name)
if esc_name not in self._rules or self._rules[esc_name] == rule:
key = esc_name
else:
i = 0
while f'{esc_name}{i}' in self._rules and self._rules[f'{esc_name}{i}'] != rule:
i += 1
key = f'{esc_name}{i}'
self._rules[key] = rule
return key
def resolve_refs(self, schema: dict, url: str):
'''
Resolves all $ref fields in the given schema, fetching any remote schemas,
replacing $ref with absolute reference URL and populating self._refs with the
respective referenced (sub)schema dictionaries.
'''
def visit(n: dict):
if isinstance(n, list):
return [visit(x) for x in n]
elif isinstance(n, dict):
ref = n.get('$ref')
if ref is not None and ref not in self._refs:
if ref.startswith('https://'):
assert self._allow_fetch, 'Fetching remote schemas is not allowed (use --allow-fetch for force)'
import requests
frag_split = ref.split('#')
base_url = frag_split[0]
target = self._refs.get(base_url)
if target is None:
target = self.resolve_refs(requests.get(ref).json(), base_url)
self._refs[base_url] = target
if len(frag_split) == 1 or frag_split[-1] == '':
return target
elif ref.startswith('#/'):
target = schema
ref = f'{url}{ref}'
n['$ref'] = ref
else:
raise ValueError(f'Unsupported ref {ref}')
for sel in ref.split('#')[-1].split('/')[1:]:
assert target is not None, f'Error resolving ref {ref}: {sel} not in {target}'
if isinstance(target, list):
try:
sel_index = int(sel)
except ValueError:
raise ValueError(f'Error resolving ref {ref}: {sel} not in {target}')
assert 0 <= sel_index < len(target), f'Error resolving ref {ref}: {sel} not in {target}'
target = target[sel_index]
else:
assert sel in target, f'Error resolving ref {ref}: {sel} not in {target}'
target = target[sel]
self._refs[ref] = target
else:
for v in n.values():
visit(v)
return n
return visit(schema)
def _generate_union_rule(self, name, alt_schemas):
return ' | '.join((
self.visit(alt_schema, f'{name}{"-" if name else "alternative-"}{i}')
for i, alt_schema in enumerate(alt_schemas)
))
def _visit_pattern(self, pattern, name):
'''
Transforms a regular expression pattern into a GBNF rule.
Input: https://json-schema.org/understanding-json-schema/reference/regular_expressions
Output: https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md
Unsupported features: negative/positive lookaheads, greedy/non-greedy modifiers.
Mostly a 1:1 translation, except for {x} / {x,} / {x,y} quantifiers for which
we define sub-rules to keep the output lean.
'''
assert pattern.startswith('^') and pattern.endswith('$'), 'Pattern must start with "^" and end with "$"'
pattern = pattern[1:-1]
sub_rule_ids = {}
i = 0
length = len(pattern)
def to_rule(s: tuple[str, bool]) -> str:
(txt, is_literal) = s
return "\"" + txt + "\"" if is_literal else txt
def transform() -> tuple[str, bool]:
'''
Parse a unit at index i (advancing it), and return its string representation + whether it's a literal.
'''
nonlocal i
nonlocal pattern
nonlocal sub_rule_ids
start = i
# For each component of this sequence, store its string representation and whether it's a literal.
# We only need a flat structure here to apply repetition operators to the last item, and
# to merge literals at the and (we're parsing grouped ( sequences ) recursively and don't treat '|' specially
# (GBNF's syntax is luckily very close to regular expressions!)
seq: list[tuple[str, bool]] = []
def get_dot():
if self._dotall:
rule = DOTALL
else:
# Accept any character... except \n and \r line break chars (\x0A and \xOD)
rule = DOT
return self._add_rule(f'dot', rule)
def join_seq():
nonlocal seq
ret = []
for is_literal, g in itertools.groupby(seq, lambda x: x[1]):
if is_literal:
ret.append((''.join(x[0] for x in g), True))
else:
ret.extend(g)
if len(ret) == 1:
return ret[0]
return (' '.join(to_rule(x) for x in seq), False)
while i < length:
c = pattern[i]
if c == '.':
seq.append((get_dot(), False))
i += 1
elif c == '(':
i += 1
if i < length:
assert pattern[i] != '?', f'Unsupported pattern syntax "{pattern[i]}" at index {i} of /{pattern}/'
seq.append((f'({to_rule(transform())})', False))
elif c == ')':
i += 1
assert start > 0 and pattern[start-1] == '(', f'Unbalanced parentheses; start = {start}, i = {i}, pattern = {pattern}'
return join_seq()
elif c == '[':
square_brackets = c
i += 1
while i < length and pattern[i] != ']':
if pattern[i] == '\\':
square_brackets += pattern[i:i+2]
i += 2
else:
square_brackets += pattern[i]
i += 1
assert i < length, f'Unbalanced square brackets; start = {start}, i = {i}, pattern = {pattern}'
square_brackets += ']'
i += 1
seq.append((square_brackets, False))
elif c == '|':
seq.append(('|', False))
i += 1
elif c in ('*', '+', '?'):
seq[-1] = (to_rule(seq[-1]) + c, False)
i += 1
elif c == '{':
curly_brackets = c
i += 1
while i < length and pattern[i] != '}':
curly_brackets += pattern[i]
i += 1
assert i < length, f'Unbalanced curly brackets; start = {start}, i = {i}, pattern = {pattern}'
curly_brackets += '}'
i += 1
nums = [s.strip() for s in curly_brackets[1:-1].split(',')]
min_times = 0
max_times = None
try:
if len(nums) == 1:
min_times = int(nums[0])
max_times = min_times
else:
assert len(nums) == 2
min_times = int(nums[0]) if nums[0] else 0
max_times = int(nums[1]) if nums[1] else None
except ValueError:
raise ValueError(f'Invalid quantifier {curly_brackets} in /{pattern}/')
(sub, sub_is_literal) = seq[-1]
if not sub_is_literal:
id = sub_rule_ids.get(sub)
if id is None:
id = self._add_rule(f'{name}-{len(sub_rule_ids) + 1}', sub)
sub_rule_ids[sub] = id
sub = id
seq[-1] = (_build_repetition(f'"{sub}"' if sub_is_literal else sub, min_times, max_times), False)
else:
literal = ''
while i < length:
if pattern[i] == '\\' and i < length - 1:
next = pattern[i + 1]
if next in ESCAPED_IN_REGEXPS_BUT_NOT_IN_LITERALS:
i += 1
literal += pattern[i]
i += 1
else:
literal += pattern[i:i+2]
i += 2
elif pattern[i] == '"' and not self._raw_pattern:
literal += '\\"'
i += 1
elif pattern[i] not in NON_LITERAL_SET and \
(i == length - 1 or literal == '' or pattern[i+1] == '.' or pattern[i+1] not in NON_LITERAL_SET):
literal += pattern[i]
i += 1
else:
break
if literal:
seq.append((literal, True))
return join_seq()
return self._add_rule(
name,
to_rule(transform()) if self._raw_pattern \
else "\"\\\"\" (" + to_rule(transform()) + ") \"\\\"\"")
def _resolve_ref(self, ref):
ref_fragment = ref.split('#')[-1]
ref_name = 'ref' + re.sub(r'[^a-zA-Z0-9-]+', '-', ref_fragment)
if ref_name not in self._rules and ref not in self._refs_being_resolved:
self._refs_being_resolved.add(ref)
resolved = self._refs[ref]
ref_name = self.visit(resolved, ref_name)
self._refs_being_resolved.remove(ref)
return ref_name
def _generate_constant_rule(self, value):
return self._format_literal(json.dumps(value))
def visit(self, schema, name):
schema_type = schema.get('type')
schema_format = schema.get('format')
rule_name = name + '-' if name in RESERVED_NAMES else name or 'root'
if (ref := schema.get('$ref')) is not None:
return self._add_rule(rule_name, self._resolve_ref(ref))
elif 'oneOf' in schema or 'anyOf' in schema:
return self._add_rule(rule_name, self._generate_union_rule(name, schema.get('oneOf') or schema['anyOf']))
elif isinstance(schema_type, list):
return self._add_rule(rule_name, self._generate_union_rule(name, [{**schema, 'type': t} for t in schema_type]))
elif 'const' in schema:
return self._add_rule(rule_name, self._generate_constant_rule(schema['const']))
elif 'enum' in schema:
rule = '(' + ' | '.join((self._generate_constant_rule(v) for v in schema['enum'])) + ')'
return self._add_rule(rule_name, rule)
elif schema_type in (None, 'object') and \
('properties' in schema or \
('additionalProperties' in schema and schema['additionalProperties'] is not True)):
required = set(schema.get('required', []))
properties = list(schema.get('properties', {}).items())
return self._add_rule(rule_name, self._build_object_rule(properties, required, name, schema.get('additionalProperties')))
elif schema_type in (None, 'object', 'string') and 'allOf' in schema:
required = set()
properties = []
enum_sets = []
hybrid_name = name
def add_component(comp_schema, is_required):
if (ref := comp_schema.get('$ref')) is not None:
comp_schema = self._refs[ref]
if 'properties' in comp_schema:
for prop_name, prop_schema in comp_schema['properties'].items():
properties.append((prop_name, prop_schema))
if is_required:
required.add(prop_name)
if 'enum' in comp_schema:
enum_sets.append(set(comp_schema['enum']))
for t in schema['allOf']:
if 'anyOf' in t:
for tt in t['anyOf']:
add_component(tt, is_required=False)
else:
add_component(t, is_required=True)
if enum_sets:
enum_intersection = enum_sets[0]
for s in enum_sets[1:]:
enum_intersection &= s
if enum_intersection:
rule = '(' + ' | '.join((self._generate_constant_rule(v) for v in sorted(enum_intersection))) + ')'
return self._add_rule(rule_name, rule)
return self._add_rule(rule_name, self._build_object_rule(properties, required, hybrid_name, additional_properties=None))
elif schema_type in (None, 'array') and ('items' in schema or 'prefixItems' in schema):
items = schema.get('items', schema.get('prefixItems'))
if isinstance(items, list):
return self._add_rule(
rule_name,
'"[" space ' +
' "," space '.join(
self.visit(item, f'{name}{"-" if name else ""}tuple-{i}')
for i, item in enumerate(items)) +
' space "]"')
else:
item_rule_name = self.visit(items, f'{name}{"-" if name else ""}item')
min_items = schema.get("minItems", 0)
max_items = schema.get("maxItems")
return self._add_rule(rule_name, '"[" space ' + _build_repetition(item_rule_name, min_items, max_items, separator_rule='"," space') + ' space "]"')
elif schema_type in (None, 'string') and 'pattern' in schema:
return self._visit_pattern(schema['pattern'], rule_name)
elif schema_type in (None, 'string') and re.match(r'^uuid[1-5]?$', schema_format or ''):
return self._add_primitive(
'root' if rule_name == 'root' else schema_format,
PRIMITIVE_RULES['uuid']
)
elif schema_type in (None, 'string') and f'{schema_format}-string' in STRING_FORMAT_RULES:
prim_name = f'{schema_format}-string'
return self._add_rule(rule_name, self._add_primitive(prim_name, STRING_FORMAT_RULES[prim_name]))
elif schema_type == 'string' and ('minLength' in schema or 'maxLength' in schema):
char_rule = self._add_primitive('char', PRIMITIVE_RULES['char'])
min_len = schema.get('minLength', 0)
max_len = schema.get('maxLength')
return self._add_rule(rule_name, r'"\"" ' + _build_repetition(char_rule, min_len, max_len) + r' "\""')
elif schema_type in (None, 'integer') and \
('minimum' in schema or 'exclusiveMinimum' in schema or 'maximum' in schema or 'exclusiveMaximum' in schema):
min_value = None
max_value = None
if 'minimum' in schema:
min_value = schema['minimum']
elif 'exclusiveMinimum' in schema:
min_value = schema['exclusiveMinimum'] + 1
if 'maximum' in schema:
max_value = schema['maximum']
elif 'exclusiveMaximum' in schema:
max_value = schema['exclusiveMaximum'] - 1
out = ["("]
_generate_min_max_int(min_value, max_value, out)
out.append(")")
return self._add_rule(rule_name, ''.join(out))
elif (schema_type == 'object') or (len(schema) == 0):
return self._add_rule(rule_name, self._add_primitive('object', PRIMITIVE_RULES['object']))
elif schema_type is None and isinstance(schema, dict):
# No type constraint and no recognized structural keywords (e.g. {"description": "..."}).
# Per JSON Schema semantics this is equivalent to {} and accepts any value.
return self._add_rule(rule_name, self._add_primitive('value', PRIMITIVE_RULES['value']))
else:
assert schema_type in PRIMITIVE_RULES, f'Unrecognized schema: {schema}'
# TODO: support minimum, maximum, exclusiveMinimum, exclusiveMaximum at least for zero
return self._add_primitive('root' if rule_name == 'root' else schema_type, PRIMITIVE_RULES[schema_type])
def _add_primitive(self, name: str, rule: BuiltinRule):
n = self._add_rule(name, rule.content)
for dep in rule.deps:
dep_rule = PRIMITIVE_RULES.get(dep) or STRING_FORMAT_RULES.get(dep)
assert dep_rule, f'Rule {dep} not known'
if dep not in self._rules:
self._add_primitive(dep, dep_rule)
return n
def _build_object_rule(self, properties: List[Tuple[str, Any]], required: Set[str], name: str, additional_properties: Optional[Union[bool, Any]]):
prop_order = self._prop_order
# sort by position in prop_order (if specified) then by original order
sorted_props = [kv[0] for _, kv in sorted(enumerate(properties), key=lambda ikv: (prop_order.get(ikv[1][0], len(prop_order)), ikv[0]))]
prop_kv_rule_names = {}
for prop_name, prop_schema in properties:
prop_rule_name = self.visit(prop_schema, f'{name}{"-" if name else ""}{prop_name}')
prop_kv_rule_names[prop_name] = self._add_rule(
f'{name}{"-" if name else ""}{prop_name}-kv',
fr'{self._format_literal(json.dumps(prop_name))} space ":" space {prop_rule_name}'
)
required_props = [k for k in sorted_props if k in required]
optional_props = [k for k in sorted_props if k not in required]
if additional_properties is not None and additional_properties != False:
sub_name = f'{name}{"-" if name else ""}additional'
value_rule = self.visit(additional_properties, f'{sub_name}-value') if isinstance(additional_properties, dict) else \
self._add_primitive('value', PRIMITIVE_RULES['value'])
key_rule = self._add_primitive('string', PRIMITIVE_RULES['string']) if not sorted_props \
else self._add_rule(f'{sub_name}-k', self._not_strings(sorted_props))
prop_kv_rule_names["*"] = self._add_rule(
f'{sub_name}-kv',
f'{key_rule} ":" space {value_rule}'
)
optional_props.append("*")
if not required_props and not optional_props:
return '"{" space "}"'
rule = '"{" space '
rule += ' "," space '.join(prop_kv_rule_names[k] for k in required_props)
if optional_props:
rule += ' ('
if required_props:
rule += ' "," space ( '
def get_recursive_refs(ks, first_is_optional):
[k, *rest] = ks
kv_rule_name = prop_kv_rule_names[k]
comma_ref = f'( "," space {kv_rule_name} )'
if first_is_optional:
res = comma_ref + ('*' if k == '*' else '?')
else:
res = kv_rule_name + (' ' + comma_ref + "*" if k == '*' else '')
if len(rest) > 0:
res += ' ' + self._add_rule(
f'{name}{"-" if name else ""}{k}-rest',
get_recursive_refs(rest, first_is_optional=True)
)
return res
rule += ' | '.join(
get_recursive_refs(optional_props[i:], first_is_optional=False)
for i in range(len(optional_props))
)
if required_props:
rule += ' )'
rule += ' )?'
rule += ' space "}"'
return rule
def format_grammar(self):
return '\n'.join(
f'{name} ::= {rule}'
for name, rule in sorted(self._rules.items(), key=lambda kv: kv[0])
)
def main(args_in = None):
parser = argparse.ArgumentParser(
description='''
Generates a grammar (suitable for use in ./llama-cli) that produces JSON conforming to a
given JSON schema. Only a subset of JSON schema features are supported; more may be
added in the future.
''',
)
parser.add_argument(
'--prop-order',
default=[],
type=lambda s: s.split(','),
help='''
comma-separated property names defining the order of precedence for object properties;
properties not specified here are given lower precedence than those that are, and
are kept in their original order from the schema. Required properties are always
given precedence over optional properties.
'''
)
parser.add_argument(
'--allow-fetch',
action='store_true',
default=False,
help='Whether to allow fetching referenced schemas over HTTPS')
parser.add_argument(
'--dotall',
action='store_true',
default=False,
help='Whether to treat dot (".") as matching all chars including line breaks in regular expression patterns')
parser.add_argument(
'--raw-pattern',
action='store_true',
default=False,
help='Treats string patterns as raw patterns w/o quotes (or quote escapes)')
parser.add_argument('schema', help='file containing JSON schema ("-" for stdin)')
args = parser.parse_args(args_in)
if args.schema.startswith('https://'):
url = args.schema
import requests
schema = requests.get(url).json()
elif args.schema == '-':
url = 'stdin'
schema = json.load(sys.stdin)
else:
url = f'file://{args.schema}'
with open(args.schema) as f:
schema = json.load(f)
converter = SchemaConverter(
prop_order={name: idx for idx, name in enumerate(args.prop_order)},
allow_fetch=args.allow_fetch,
dotall=args.dotall,
raw_pattern=args.raw_pattern)
schema = converter.resolve_refs(schema, url)
converter.visit(schema, '')
print(converter.format_grammar())
if __name__ == '__main__':
main()
-20
View File
@@ -1,20 +0,0 @@
import json, subprocess, sys, os
assert len(sys.argv) >= 2
[_, pattern, *rest] = sys.argv
print(subprocess.check_output(
[
"python",
os.path.join(
os.path.dirname(os.path.realpath(__file__)),
"json_schema_to_grammar.py"),
*rest,
"-",
"--raw-pattern",
],
text=True,
input=json.dumps({
"type": "string",
"pattern": pattern,
}, indent=2)))
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
#
# ./examples/ts-type-to-grammar.sh "{a:string,b:string,c?:string}"
# python examples/json_schema_to_grammar.py https://json.schemastore.org/tsconfig.json
#
set -euo pipefail
readonly type="$1"
# Create a temporary directory
TMPDIR=""
trap 'rm -fR "$TMPDIR"' EXIT
TMPDIR=$(mktemp -d)
DTS_FILE="$TMPDIR/type.d.ts"
SCHEMA_FILE="$TMPDIR/schema.json"
echo "export type MyType = $type" > "$DTS_FILE"
# This is a fork of typescript-json-schema, actively maintained as of March 2024:
# https://github.com/vega/ts-json-schema-generator
npx ts-json-schema-generator --unstable --no-top-ref --path "$DTS_FILE" --type MyType -e none > "$SCHEMA_FILE"
# Alternative, not actively maintained as of March 2024:
# https://github.com/YousefED/typescript-json-schema
# npx typescript-json-schema --defaultProps --required "$DTS_FILE" MyType | tee "$SCHEMA_FILE" >&2
./examples/json_schema_to_grammar.py "$SCHEMA_FILE"
+1 -7
View File
@@ -146,8 +146,6 @@ You can use GBNF grammars:
- For any completion endpoints, passed as the `json_schema` body field
- For the `/chat/completions` endpoint, passed inside the `response_format` body field (e.g. `{"type", "json_object", "schema": {"items": {}}}` or `{ type: "json_schema", json_schema: {"schema": ...} }`)
- In [llama-cli](../tools/cli) and [llama-completion](../tools/completion), passed as the `--json` / `-j` flag
- To convert to a grammar ahead of time:
- in CLI, with [examples/json_schema_to_grammar.py](../examples/json_schema_to_grammar.py)
> [!NOTE]
> The JSON schema is only used to constrain the model output and is not injected into the prompt. The model has no visibility into the schema, so if you want it to understand the expected structure, describe it explicitly in your prompt. This does not apply to tool calling, where schemas are injected into the prompt.
@@ -187,11 +185,7 @@ llama-cli \
<summary>Show grammar</summary>
You can convert any schema in command-line with:
```bash
examples/json_schema_to_grammar.py name-age-schema.json
```
The schema above converts to:
```
char ::= [^"\\\x7F\x00-\x1F] | [\\] (["\\bfnrt] | "u" [0-9a-fA-F]{4})
+2 -5
View File
@@ -163,11 +163,7 @@ if (NOT WIN32 OR NOT BUILD_SHARED_LIBS)
llama_build_and_test(test-chat.cpp WORKING_DIRECTORY ${PROJECT_SOURCE_DIR})
target_include_directories(test-chat PRIVATE ${PROJECT_SOURCE_DIR}/tools/server)
target_link_libraries(test-chat PRIVATE server-context)
# TODO: disabled on loongarch64 because the ggml-ci node lacks Python 3.8
if (NOT ${CMAKE_SYSTEM_PROCESSOR} MATCHES "loongarch64")
llama_build_and_test(test-json-schema-to-grammar.cpp WORKING_DIRECTORY ${PROJECT_SOURCE_DIR})
target_include_directories(test-json-schema-to-grammar PRIVATE ${PROJECT_SOURCE_DIR}/tools/server)
endif()
llama_build_and_test(test-json-schema-to-grammar.cpp)
if (NOT GGML_BACKEND_DL)
llama_build(test-quantize-stats.cpp)
@@ -260,6 +256,7 @@ endif()
llama_build_and_test(test-chat-peg-parser.cpp peg-parser/simple-tokenize.cpp)
llama_build_and_test(test-jinja.cpp)
llama_test(test-jinja NAME test-jinja-py ARGS -py LABEL python)
llama_build_and_test(test-json-schema.cpp)
llama_build_and_test(test-chat-auto-parser.cpp WORKING_DIRECTORY ${PROJECT_SOURCE_DIR})
llama_build_and_test(test-chat-template.cpp)
# debug tool for chat template differential analysis (not registered as a test, run it manually)
-15
View File
@@ -358,11 +358,6 @@ static void test_example_native(testing & t) {
auto parser = build_parser(tc);
auto lazy = !tc.tools.empty() && tc.tool_choice != COMMON_CHAT_TOOL_CHOICE_REQUIRED;
auto grammar = build_grammar([&](const common_grammar_builder & builder) {
for (const auto & def : tc.tools) {
auto function = def.at("function");
auto parameters = function.at("parameters");
builder.resolve_refs(parameters);
};
parser.build_grammar(builder, lazy);
});
@@ -440,11 +435,6 @@ static void test_example_qwen3_coder(testing & t) {
});
auto grammar = build_grammar([&](const common_grammar_builder & builder) {
for (const auto & def : tools) {
auto function = def.at("function");
auto parameters = function.at("parameters");
builder.resolve_refs(parameters);
};
parser.build_grammar(builder);
});
@@ -513,11 +503,6 @@ static void test_example_qwen3_non_coder(testing & t) {
});
auto grammar = build_grammar([&](const common_grammar_builder & builder) {
for (const auto & def : tools) {
auto function = def.at("function");
auto parameters = function.at("parameters");
builder.resolve_refs(parameters);
};
parser.build_grammar(builder);
});
+13
View File
@@ -472,6 +472,12 @@ static common_chat_tool empty_args_tool_no_properties{
})",
};
static common_chat_tool empty_args_tool_no_schema{
/* .name = */ "empty_args_no_schema",
/* .description = */ "A tool that takes no arguments and has no parameters schema",
/* .parameters = */ "{}",
};
static common_chat_tool python_tool{
/* .name = */ "python",
/* .description = */ "an ipython interpreter",
@@ -5071,6 +5077,13 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
.expect(simple_assist_msg("", "", "empty_args", "{}"))
.run();
// Tool call with no parameters schema, {} means no arguments
tst.test("<tool_call>\n{\"name\": \"empty_args_no_schema\", \"arguments\": {}}</tool_call>")
.enable_thinking(false)
.tools({ empty_args_tool_no_schema })
.expect(simple_assist_msg("", "", "empty_args_no_schema", "{}"))
.run();
// fake tool call marker in reasoning
tst.test(
"Let me think about <tool_call>\n{\"name\": \"special_function\", \"arguments\": {\"arg1\": 2}}</tool_call> hmm\n</think>\n\n"
+7 -5
View File
@@ -918,7 +918,7 @@ static void test_json_schema() {
// Otherwise, this test structure is the same.
test_schema(
"empty schema (object)",
"empty schema (any value)",
// Schema
R"""(
{}
@@ -927,14 +927,16 @@ static void test_json_schema() {
{
R"""({})""",
R"""({"foo": "bar"})""",
},
// Failing strings
{
"",
"[]",
"null",
R"""("")""",
"true",
},
// Failing strings
{
"",
R"""({"foo"})""",
"foo",
}
);
+139 -212
View File
@@ -9,8 +9,6 @@
#include "json.h"
#include <cassert>
#include <fstream>
#include <sstream>
#include <regex>
static std::string trim(const std::string & source) {
@@ -64,21 +62,8 @@ struct TestCase {
}
};
static void write(const std::string & file, const std::string & content) {
std::ofstream f;
f.open(file.c_str());
f << content.c_str();
f.close();
}
static std::string read(const std::string & file) {
std::ostringstream actuals;
actuals << std::ifstream(file.c_str()).rdbuf();
return actuals.str();
}
static void test_all(const std::string & lang, std::function<void(const TestCase &)> runner) {
fprintf(stderr, "#\n# Testing JSON schema conversion (%s)\n#\n", lang.c_str());
static void test_all(const std::string & title, std::function<void(const TestCase &)> runner) {
fprintf(stderr, "#\n# %s\n#\n", title.c_str());
auto test = [&](const TestCase & tc) {
fprintf(stderr, "- %s%s\n", tc.name.c_str(), tc.expected_status == FAILURE ? " (failure expected)" : "");
runner(tc);
@@ -330,7 +315,7 @@ static void test_all(const std::string & lang, std::function<void(const TestCase
test({
SUCCESS,
"empty schema (object)",
"empty schema (any value)",
"{}",
R"""(
array ::= "[" space ( value ("," space value)* )? space "]"
@@ -341,7 +326,7 @@ static void test_all(const std::string & lang, std::function<void(const TestCase
null ::= "null"
number ::= ("-"? integral-part) ("." decimal-part)? ([eE] [-+]? integral-part)?
object ::= "{" space ( string ":" space value ("," space string ":" space value)* )? space "}"
root ::= object
root ::= value
space ::= | " " | "\n"{1,2} [ \t]{0,20}
string ::= "\"" char* "\""
value ::= object | array | string | number | boolean | null
@@ -569,6 +554,7 @@ static void test_all(const std::string & lang, std::function<void(const TestCase
)"""
});
// items {} constrains nothing, the same as no items at all
test({
SUCCESS,
"array with empty items",
@@ -582,11 +568,10 @@ static void test_all(const std::string & lang, std::function<void(const TestCase
char ::= [^"\\\x7F\x00-\x1F] | [\\] (["\\bfnrt] | "u" [0-9a-fA-F]{4})
decimal-part ::= [0-9]{1,16}
integral-part ::= [0] | [1-9] [0-9]{0,15}
item ::= object
null ::= "null"
number ::= ("-"? integral-part) ("." decimal-part)? ([eE] [-+]? integral-part)?
object ::= "{" space ( string ":" space value ("," space string ":" space value)* )? space "}"
root ::= "[" space (item ("," space item)*)? space "]"
root ::= "[" space ( value ("," space value)* )? space "]"
space ::= | " " | "\n"{1,2} [ \t]{0,20}
string ::= "\"" char* "\""
value ::= object | array | string | number | boolean | null
@@ -607,11 +592,10 @@ static void test_all(const std::string & lang, std::function<void(const TestCase
char ::= [^"\\\x7F\x00-\x1F] | [\\] (["\\bfnrt] | "u" [0-9a-fA-F]{4})
decimal-part ::= [0-9]{1,16}
integral-part ::= [0] | [1-9] [0-9]{0,15}
item ::= object
null ::= "null"
number ::= ("-"? integral-part) ("." decimal-part)? ([eE] [-+]? integral-part)?
object ::= "{" space ( string ":" space value ("," space string ":" space value)* )? space "}"
root ::= "[" space (item ("," space item)*)? space "]"
root ::= "[" space ( value ("," space value)* )? space "]"
space ::= | " " | "\n"{1,2} [ \t]{0,20}
string ::= "\"" char* "\""
value ::= object | array | string | number | boolean | null
@@ -1434,88 +1418,100 @@ static void test_all(const std::string & lang, std::function<void(const TestCase
space ::= | " " | "\n"{1,2} [ \t]{0,20}
)"""
});
}
static void test_resolves_to_string() {
fprintf(stderr, "#\n# Testing resolves_to_string\n#\n");
test({
SUCCESS,
"regexp with non-capturing group",
R"""({
"type": "string",
"pattern": "^(?:foo|bar)baz$"
})""",
R"""(
root ::= "\"" (("foo" | "bar") "baz") "\""
space ::= | " " | "\n"{1,2} [ \t]{0,20}
)"""
});
auto test = [](const std::string & name, const std::string & schema_str, bool expected) {
fprintf(stderr, "- %s\n", name.c_str());
common_schema_info info;
auto schema = common_json::parse(schema_str);
info.resolve_refs(schema);
bool result = info.resolves_to_string(schema);
if (result != expected) {
fprintf(stderr, "#\n# Test '%s' failed.\n#\n", name.c_str());
fprintf(stderr, "Schema: %s\n", schema_str.c_str());
fprintf(stderr, "Expected: %s, Got: %s\n", expected ? "true" : "false", result ? "true" : "false");
assert(false);
}
};
test({
SUCCESS,
"regexp with nested non-capturing groups",
R"""({
"type": "string",
"pattern": "^(?:(?:ab)+c)?d$"
})""",
R"""(
root ::= "\"" ((("ab")+ "c")? "d") "\""
space ::= | " " | "\n"{1,2} [ \t]{0,20}
)"""
});
// Basic type checks
test("type string", R"({"type": "string"})", true);
test("type integer", R"({"type": "integer"})", false);
test("type number", R"({"type": "number"})", false);
test("type boolean", R"({"type": "boolean"})", false);
test("type object", R"({"type": "object"})", false);
test("type array", R"({"type": "array"})", false);
test({
SUCCESS,
"unanchored regexp",
R"""({
"type": "string",
"pattern": "[0-9]+"
})""",
R"""(
char ::= [^"\\\x7F\x00-\x1F] | [\\] (["\\bfnrt] | "u" [0-9a-fA-F]{4})
root ::= string
space ::= | " " | "\n"{1,2} [ \t]{0,20}
string ::= "\"" char* "\""
)"""
});
// Type array (nullable string)
test("type array with string", R"({"type": ["string", "null"]})", true);
test("type array without string", R"({"type": ["integer", "null"]})", false);
// the rules of the partial conversion (here "root-0") must not leak into the grammar
test({
SUCCESS,
"regexp with unsupported shorthand",
R"""({
"type": "string",
"pattern": "^[0-9]{3}\\w$"
})""",
R"""(
char ::= [^"\\\x7F\x00-\x1F] | [\\] (["\\bfnrt] | "u" [0-9a-fA-F]{4})
root ::= string
space ::= | " " | "\n"{1,2} [ \t]{0,20}
string ::= "\"" char* "\""
)"""
});
// String-specific keywords
test("minLength implies string", R"({"minLength": 1})", true);
test("maxLength implies string", R"({"maxLength": 10})", true);
test("pattern implies string", R"({"pattern": "^[a-z]+$"})", true);
// a regexp that is invalid under any flavor is still an error
test({
FAILURE,
"regexp with unbalanced parentheses",
R"""({
"type": "string",
"pattern": "^(a$"
})""",
""
});
// Format
test("format date", R"({"format": "date"})", true);
test("format uuid", R"({"format": "uuid"})", true);
test("format email", R"({"format": "email"})", true);
// Const
test("const string", R"({"const": "hello"})", true);
test("const number", R"({"const": 123})", false);
// Enum
test("enum with strings", R"({"enum": ["a", "b", "c"]})", true);
test("enum with numbers", R"({"enum": [1, 2, 3]})", false);
test("enum mixed with string", R"({"enum": [1, "a", null]})", true);
// anyOf
test("anyOf with string", R"({"anyOf": [{"type": "string"}, {"type": "integer"}]})", true);
test("anyOf without string", R"({"anyOf": [{"type": "integer"}, {"type": "boolean"}]})", false);
// oneOf
test("oneOf with string", R"({"oneOf": [{"type": "string"}, {"type": "number"}]})", true);
test("oneOf without string", R"({"oneOf": [{"type": "object"}, {"type": "array"}]})", false);
// allOf - all must be strings
test("allOf all strings", R"({"allOf": [{"type": "string"}, {"minLength": 1}]})", true);
test("allOf mixed types", R"({"allOf": [{"type": "string"}, {"type": "integer"}]})", false);
// $ref
test("$ref to string",
R"({"$ref": "#/$defs/str", "$defs": {"str": {"type": "string"}}})", true);
test("$ref to integer",
R"({"$ref": "#/$defs/num", "$defs": {"num": {"type": "integer"}}})", false);
// Nested
test("nested anyOf with string",
R"({"anyOf": [{"anyOf": [{"type": "integer"}, {"type": "string"}]}, {"type": "boolean"}]})", true);
fprintf(stderr, "All resolves_to_string tests passed!\n");
// only the property with the bad pattern degrades
test({
SUCCESS,
"unsupported regexp in a property",
R"""({
"type": "object",
"properties": {
"a": { "type": "string", "pattern": "^[a-z\\-]+$" }
},
"required": ["a"],
"additionalProperties": false
})""",
R"""(
a ::= string
a-kv ::= "\"a\"" space ":" space a
char ::= [^"\\\x7F\x00-\x1F] | [\\] (["\\bfnrt] | "u" [0-9a-fA-F]{4})
root ::= "{" space a-kv space "}"
space ::= | " " | "\n"{1,2} [ \t]{0,20}
string ::= "\"" char* "\""
)"""
});
}
int main() {
fprintf(stderr, "LLAMA_NODE_AVAILABLE = %s\n", getenv("LLAMA_NODE_AVAILABLE") ? "true" : "false");
fprintf(stderr, "LLAMA_PYTHON_AVAILABLE = %s\n", getenv("LLAMA_PYTHON_AVAILABLE") ? "true" : "false");
test_resolves_to_string();
test_all("C++", [](const TestCase & tc) {
test_all("JSON schema conversion", [](const TestCase & tc) {
try {
tc.verify(json_schema_to_grammar(common_json::parse(tc.schema), true));
tc.verify_status(SUCCESS);
@@ -1525,127 +1521,58 @@ int main() {
}
});
// C++ only tests (features not yet supported in JS/Python implementations)
// a document parsed up front gives the same grammar as the JSON, recursion included
{
fprintf(stderr, "#\n# Testing C++ only features\n#\n");
auto run = [](const TestCase & tc) {
fprintf(stderr, "- %s\n", tc.name.c_str());
try {
tc.verify(json_schema_to_grammar(common_json::parse(tc.schema), true));
tc.verify_status(SUCCESS);
} catch (const std::invalid_argument & ex) {
fprintf(stderr, "Error: %s\n", ex.what());
tc.verify_status(FAILURE);
fprintf(stderr, "- parsed document\n");
auto schema = common_json::parse(R"""({
"$ref": "#/$defs/node",
"$defs": {
"node": {
"type": "object",
"properties": {"next": {"$ref": "#/$defs/node"}, "leaf": {}},
"additionalProperties": false
}
}
})""");
assert(json_schema_to_grammar(common_schema_from_json(schema)) == json_schema_to_grammar(schema, true));
}
// a property node carries its $ref target, so its grammar names the ref rule
{
fprintf(stderr, "- sub-schema $ref\n");
auto parameters = common_json::parse(R"""({
"type": "object",
"properties": {"item": {"$ref": "#/$defs/item"}},
"$defs": {
"item": {
"type": "object",
"properties": {"a": {"type": "string"}},
"required": ["a"],
"additionalProperties": false
}
}
})""");
TestCase tc {
SUCCESS,
"sub-schema $ref",
"",
R"""(
char ::= [^"\\\x7F\x00-\x1F] | [\\] (["\\bfnrt] | "u" [0-9a-fA-F]{4})
ref-defs-item ::= "{" space ref-defs-item-a-kv space "}"
ref-defs-item-a-kv ::= "\"a\"" space ":" space string
root ::= ref-defs-item
space ::= | " " | "\n"{1,2} [ \t]{0,20}
string ::= "\"" char* "\""
)""",
};
run({
SUCCESS,
"regexp with non-capturing group",
R"""({
"type": "string",
"pattern": "^(?:foo|bar)baz$"
})""",
R"""(
root ::= "\"" (("foo" | "bar") "baz") "\""
space ::= | " " | "\n"{1,2} [ \t]{0,20}
)""",
});
run({
SUCCESS,
"regexp with nested non-capturing groups",
R"""({
"type": "string",
"pattern": "^(?:(?:ab)+c)?d$"
})""",
R"""(
root ::= "\"" ((("ab")+ "c")? "d") "\""
space ::= | " " | "\n"{1,2} [ \t]{0,20}
)""",
});
run({
SUCCESS,
"unanchored regexp",
R"""({
"type": "string",
"pattern": "[0-9]+"
})""",
R"""(
char ::= [^"\\\x7F\x00-\x1F] | [\\] (["\\bfnrt] | "u" [0-9a-fA-F]{4})
root ::= string
space ::= | " " | "\n"{1,2} [ \t]{0,20}
string ::= "\"" char* "\""
)""",
});
// the rules of the partial conversion (here "root-0") must not leak into the grammar
run({
SUCCESS,
"regexp with unsupported shorthand",
R"""({
"type": "string",
"pattern": "^[0-9]{3}\\w$"
})""",
R"""(
char ::= [^"\\\x7F\x00-\x1F] | [\\] (["\\bfnrt] | "u" [0-9a-fA-F]{4})
root ::= string
space ::= | " " | "\n"{1,2} [ \t]{0,20}
string ::= "\"" char* "\""
)""",
});
// a regexp that is invalid under any flavor is still an error
run({
FAILURE,
"regexp with unbalanced parentheses",
R"""({
"type": "string",
"pattern": "^(a$"
})""",
""
});
// only the property with the bad pattern degrades
run({
SUCCESS,
"unsupported regexp in a property",
R"""({
"type": "object",
"properties": {
"a": { "type": "string", "pattern": "^[a-z\\-]+$" }
},
"required": ["a"],
"additionalProperties": false
})""",
R"""(
a ::= string
a-kv ::= "\"a\"" space ":" space a
char ::= [^"\\\x7F\x00-\x1F] | [\\] (["\\bfnrt] | "u" [0-9a-fA-F]{4})
root ::= "{" space a-kv space "}"
space ::= | " " | "\n"{1,2} [ \t]{0,20}
string ::= "\"" char* "\""
)""",
});
auto doc = common_schema_from_json(parameters);
tc.verify(build_grammar([&](const common_grammar_builder & builder) {
const auto & item = static_cast<const common_schema_object &>(*doc.root).properties.at(0);
builder.add_schema("root", *item.schema);
}));
}
if (getenv("LLAMA_SKIP_TESTS_SLOW_ON_EMULATOR")) {
fprintf(stderr, "\033[33mWARNING: Skipping slow tests on emulator.\n\033[0m");
} else {
if (getenv("LLAMA_PYTHON_AVAILABLE") || (std::system("python -c \"import sys; exit(1) if sys.version_info < (3, 8) else print('Python version is sufficient')\"") == 0)) {
test_all("Python", [](const TestCase & tc) {
write("test-json-schema-input.tmp", tc.schema);
tc.verify_status(std::system(
"python ./examples/json_schema_to_grammar.py test-json-schema-input.tmp > test-grammar-output.tmp") == 0 ? SUCCESS : FAILURE);
tc.verify(read("test-grammar-output.tmp"));
});
} else {
fprintf(stderr, "\033[33mWARNING: Python not found (min version required is 3.8), skipping Python JSON schema -> grammar tests.\n\033[0m");
}
}
test_all("Check Expectations Validity", [](const TestCase & tc) {
test_all("Check the expectations parse", [](const TestCase & tc) {
if (tc.expected_status == SUCCESS) {
tc.verify_expectation_parseable();
}
+530
View File
@@ -0,0 +1,530 @@
#include "json-schema.h"
#include "json.h"
#include "testing.h"
#include <cstdlib>
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>
static common_schema_document parse(const std::string & schema) {
return common_schema_from_json(common_json::parse(schema));
}
// the node as T, aborting the current test when it is some other kind
template <typename T>
static const T & as(testing & t, const common_schema * node, const char * what) {
const T * typed = dynamic_cast<const T *>(node);
if (!t.assert_true(std::string(what) + " has the expected kind", typed != nullptr)) {
throw std::runtime_error(std::string(what) + " has the wrong kind");
}
return *typed;
}
template <typename T>
static const T & root(testing & t, const common_schema_document & doc) {
return as<T>(t, doc.root.get(), "root");
}
static void assert_error(testing & t, const std::string & schema, const std::string & needle) {
try {
parse(schema);
t.assert_true(schema + " is rejected", false);
} catch (const std::runtime_error & e) {
std::string what = e.what();
t.assert_true(schema + " -> " + what, what.find(needle) != std::string::npos);
}
}
static void test_any(testing & t) {
t.test("empty schema", [](testing & t) {
auto doc = parse("{}");
root<common_schema_any>(t, doc);
t.assert_true("no refs", doc.refs.empty());
});
t.test("keywords that do not imply a type", [](testing & t) {
auto doc = parse(R"({"description": "x", "format": "email", "minLength": 3, "additionalProperties": true})");
root<common_schema_any>(t, doc);
});
}
static void test_primitives(testing & t) {
t.test("null, boolean, number", [](testing & t) {
auto doc_null = parse(R"({"type": "null"})");
root<common_schema_null>(t, doc_null);
auto doc_bool = parse(R"({"type": "boolean"})");
root<common_schema_boolean>(t, doc_bool);
auto doc_num = parse(R"({"type": "number", "minimum": 1, "maximum": 2})");
root<common_schema_number>(t, doc_num);
});
}
static void test_integer(testing & t) {
t.test("unbounded", [](testing & t) {
auto doc = parse(R"({"type": "integer"})");
const auto & i = root<common_schema_integer>(t, doc);
t.assert_equal("minimum", INT64_MIN, i.minimum);
t.assert_equal("maximum", INT64_MAX, i.maximum);
});
t.test("inclusive bounds", [](testing & t) {
auto doc = parse(R"({"type": "integer", "minimum": -5, "maximum": 10})");
const auto & i = root<common_schema_integer>(t, doc);
t.assert_equal("minimum", -5, i.minimum);
t.assert_equal("maximum", 10, i.maximum);
});
t.test("exclusive bounds are folded", [](testing & t) {
auto doc = parse(R"({"type": "integer", "exclusiveMinimum": 0, "exclusiveMaximum": 10})");
const auto & i = root<common_schema_integer>(t, doc);
t.assert_equal("minimum", 1, i.minimum);
t.assert_equal("maximum", 9, i.maximum);
});
t.test("fractional bounds round inwards", [](testing & t) {
auto doc = parse(R"({"type": "integer", "minimum": 1.5, "exclusiveMaximum": 9.5})");
const auto & i = root<common_schema_integer>(t, doc);
t.assert_equal("minimum", 2, i.minimum);
t.assert_equal("maximum", 9, i.maximum);
});
}
static void test_string(testing & t) {
t.test("defaults", [](testing & t) {
auto doc = parse(R"({"type": "string"})");
const auto & s = root<common_schema_string>(t, doc);
t.assert_equal("pattern", "", s.pattern);
t.assert_equal("format", common_schema::FORMAT_NONE, s.format);
t.assert_equal("min_length", 0, s.min_length);
t.assert_equal("max_length", -1, s.max_length);
});
t.test("all keywords are kept", [](testing & t) {
auto doc = parse(R"({"type": "string", "pattern": "^[a-z]+$", "format": "date", "minLength": 2, "maxLength": 8})");
const auto & s = root<common_schema_string>(t, doc);
t.assert_equal("pattern", "^[a-z]+$", s.pattern);
t.assert_equal("format", common_schema::FORMAT_DATE, s.format);
t.assert_equal("min_length", 2, s.min_length);
t.assert_equal("max_length", 8, s.max_length);
});
t.test("formats", [](testing & t) {
auto expect = [&](const char * format, common_schema::string_format expected) {
auto doc = parse(std::string(R"({"type": "string", "format": ")") + format + "\"}");
t.assert_equal(format, expected, root<common_schema_string>(t, doc).format);
};
expect("time", common_schema::FORMAT_TIME);
expect("date-time", common_schema::FORMAT_DATE_TIME);
expect("uuid", common_schema::FORMAT_UUID);
expect("uuid5", common_schema::FORMAT_UUID);
expect("email", common_schema::FORMAT_NONE);
});
t.test("pattern and known format imply a string", [](testing & t) {
auto doc_pattern = parse(R"({"pattern": "^a$"})");
t.assert_equal("pattern", "^a$", root<common_schema_string>(t, doc_pattern).pattern);
auto doc_format = parse(R"({"format": "uuid"})");
t.assert_equal("format", common_schema::FORMAT_UUID, root<common_schema_string>(t, doc_format).format);
});
}
static void test_array(testing & t) {
t.test("items with bounds", [](testing & t) {
auto doc = parse(R"({"type": "array", "items": {"type": "integer"}, "minItems": 1, "maxItems": 3})");
const auto & a = root<common_schema_array>(t, doc);
as<common_schema_integer>(t, a.items.get(), "items");
t.assert_equal("min_items", 1, a.min_items);
t.assert_equal("max_items", 3, a.max_items);
});
t.test("no items", [](testing & t) {
auto doc = parse(R"({"type": "array"})");
const auto & a = root<common_schema_array>(t, doc);
as<common_schema_any>(t, a.items.get(), "items");
t.assert_equal("min_items", 0, a.min_items);
t.assert_equal("max_items", -1, a.max_items);
});
t.test("items imply an array", [](testing & t) {
auto doc = parse(R"({"items": {"type": "string"}})");
const auto & a = root<common_schema_array>(t, doc);
as<common_schema_string>(t, a.items.get(), "items");
});
}
static void test_tuple(testing & t) {
t.test("prefixItems", [](testing & t) {
auto doc = parse(R"({"prefixItems": [{"type": "string"}, {"type": "number"}]})");
const auto & tup = root<common_schema_tuple>(t, doc);
t.assert_equal("size", (size_t) 2, tup.items.size());
as<common_schema_string>(t, tup.items[0].get(), "items[0]");
as<common_schema_number>(t, tup.items[1].get(), "items[1]");
});
t.test("items as an array", [](testing & t) {
auto doc = parse(R"({"type": "array", "items": [{"type": "boolean"}]})");
const auto & tup = root<common_schema_tuple>(t, doc);
t.assert_equal("size", (size_t) 1, tup.items.size());
as<common_schema_boolean>(t, tup.items[0].get(), "items[0]");
});
}
static void test_object(testing & t) {
t.test("type alone accepts any object", [](testing & t) {
auto doc = parse(R"({"type": "object"})");
const auto & o = root<common_schema_object>(t, doc);
t.assert_true("no properties", o.properties.empty());
as<common_schema_any>(t, o.additional_properties.get(), "additional_properties");
});
t.test("properties", [](testing & t) {
auto doc = parse(R"({
"type": "object",
"properties": {
"b": {"type": "string"},
"a": {"type": "integer"},
"c": {"type": "boolean"}
},
"required": ["a", "c"]
})");
const auto & o = root<common_schema_object>(t, doc);
t.assert_equal("size", (size_t) 3, o.properties.size());
t.assert_equal("order", "b", o.properties[0].name);
t.assert_equal("order", "a", o.properties[1].name);
t.assert_equal("order", "c", o.properties[2].name);
t.assert_true("b optional", !o.properties[0].required);
t.assert_true("a required", o.properties[1].required);
t.assert_true("c required", o.properties[2].required);
as<common_schema_string>(t, o.properties[0].schema.get(), "b");
as<common_schema_integer>(t, o.properties[1].schema.get(), "a");
as<common_schema_boolean>(t, o.properties[2].schema.get(), "c");
t.assert_true("closed", o.additional_properties == nullptr);
});
t.test("unknown required entries are ignored", [](testing & t) {
auto doc = parse(R"({"properties": {"a": {}}, "required": ["a", "zzz", 1]})");
const auto & o = root<common_schema_object>(t, doc);
t.assert_equal("size", (size_t) 1, o.properties.size());
t.assert_true("a required", o.properties[0].required);
});
t.test("additionalProperties false implies an object", [](testing & t) {
auto doc = parse(R"({"additionalProperties": false})");
const auto & o = root<common_schema_object>(t, doc);
t.assert_true("no properties", o.properties.empty());
t.assert_true("closed", o.additional_properties == nullptr);
});
t.test("additionalProperties schema", [](testing & t) {
auto doc = parse(R"({"properties": {"a": {}}, "additionalProperties": {"type": "integer", "minimum": 0}})");
const auto & o = root<common_schema_object>(t, doc);
t.assert_equal("size", (size_t) 1, o.properties.size());
const auto & v = as<common_schema_integer>(t, o.additional_properties.get(), "additional_properties");
t.assert_equal("minimum", 0, v.minimum);
});
t.test("nested", [](testing & t) {
auto doc = parse(R"({"properties": {"inner": {"properties": {"leaf": {"type": "null"}}, "required": ["leaf"]}}})");
const auto & o = root<common_schema_object>(t, doc);
const auto & inner = as<common_schema_object>(t, o.properties[0].schema.get(), "inner");
t.assert_equal("leaf name", "leaf", inner.properties[0].name);
t.assert_true("leaf required", inner.properties[0].required);
as<common_schema_null>(t, inner.properties[0].schema.get(), "leaf");
});
}
static void test_const_enum(testing & t) {
t.test("const", [](testing & t) {
auto doc = parse(R"({"const": {"a": [1, null]}})");
t.assert_equal("value", R"({"a":[1,null]})", root<common_schema_const>(t, doc).value.dump());
});
t.test("enum", [](testing & t) {
auto doc = parse(R"({"enum": ["a", 1, null, true]})");
const auto & e = root<common_schema_enum>(t, doc);
t.assert_equal("size", (size_t) 4, e.values.size());
t.assert_equal("values[0]", "\"a\"", e.values[0].dump());
t.assert_equal("values[1]", "1", e.values[1].dump());
t.assert_equal("values[2]", "null", e.values[2].dump());
t.assert_equal("values[3]", "true", e.values[3].dump());
});
t.test("const wins over enum, enum wins over type", [](testing & t) {
auto doc_enum = parse(R"({"type": "integer", "enum": [1, 2]})");
root<common_schema_enum>(t, doc_enum);
auto doc_const = parse(R"({"type": "string", "const": "x", "enum": ["y"]})");
t.assert_equal("value", "\"x\"", root<common_schema_const>(t, doc_const).value.dump());
});
}
static void test_any_of(testing & t) {
t.test("anyOf and oneOf", [](testing & t) {
auto doc_any = parse(R"({"anyOf": [{"type": "string"}, {"type": "number"}]})");
const auto & u = root<common_schema_any_of>(t, doc_any);
t.assert_equal("size", (size_t) 2, u.children.size());
as<common_schema_string>(t, u.children[0].get(), "children[0]");
as<common_schema_number>(t, u.children[1].get(), "children[1]");
auto doc_one = parse(R"({"oneOf": [{"type": "null"}]})");
const auto & o = root<common_schema_any_of>(t, doc_one);
t.assert_equal("size", (size_t) 1, o.children.size());
as<common_schema_null>(t, o.children[0].get(), "children[0]");
});
t.test("oneOf wins over anyOf and type", [](testing & t) {
auto doc = parse(R"({"type": "string", "oneOf": [{"type": "null"}], "anyOf": [{"type": "number"}, {"type": "boolean"}]})");
const auto & u = root<common_schema_any_of>(t, doc);
t.assert_equal("size", (size_t) 1, u.children.size());
as<common_schema_null>(t, u.children[0].get(), "children[0]");
});
t.test("type array expands with sibling keywords", [](testing & t) {
auto doc = parse(R"({"type": ["string", "null", "integer"], "minLength": 2, "minimum": 5})");
const auto & u = root<common_schema_any_of>(t, doc);
t.assert_equal("size", (size_t) 3, u.children.size());
t.assert_equal("min_length", 2, as<common_schema_string>(t, u.children[0].get(), "children[0]").min_length);
as<common_schema_null>(t, u.children[1].get(), "children[1]");
t.assert_equal("minimum", 5, as<common_schema_integer>(t, u.children[2].get(), "children[2]").minimum);
});
}
static void test_all_of(testing & t) {
t.test("components", [](testing & t) {
auto doc = parse(R"({"allOf": [{"properties": {"a": {}}}, {"anyOf": [{"properties": {"b": {}}}, {"type": "null"}]}]})");
const auto & all = root<common_schema_all_of>(t, doc);
t.assert_equal("size", (size_t) 2, all.children.size());
as<common_schema_object>(t, all.children[0].get(), "children[0]");
as<common_schema_any_of>(t, all.children[1].get(), "children[1]");
auto doc_typed = parse(R"({"type": "object", "allOf": [{"properties": {"a": {}}}]})");
root<common_schema_all_of>(t, doc_typed);
});
t.test("properties win over allOf", [](testing & t) {
auto doc = parse(R"({"type": "object", "properties": {"a": {}}, "allOf": [{"properties": {"b": {}}}]})");
t.assert_equal("size", (size_t) 1, root<common_schema_object>(t, doc).properties.size());
});
t.test("other types ignore allOf", [](testing & t) {
auto doc = parse(R"({"type": "integer", "allOf": [{"minimum": 1}]})");
root<common_schema_integer>(t, doc);
});
}
static void test_ref(testing & t) {
t.test("target is owned by the document", [](testing & t) {
auto doc = parse(R"({"$ref": "#/$defs/t", "type": "string", "$defs": {"t": {"type": "boolean"}}})");
const auto & r = root<common_schema_ref>(t, doc);
t.assert_equal("ref", "#/$defs/t", r.ref);
t.assert_equal("refs", (size_t) 1, doc.refs.size());
t.assert_true("target", r.target != nullptr && r.target == doc.refs.at("#/$defs/t").get());
as<common_schema_boolean>(t, r.target, "target");
});
t.test("definitions", [](testing & t) {
auto doc = parse(R"({"properties": {"a": {"$ref": "#/definitions/t"}}, "definitions": {"t": {"type": "number"}}})");
const auto & o = root<common_schema_object>(t, doc);
const auto & r = as<common_schema_ref>(t, o.properties[0].schema.get(), "a");
as<common_schema_number>(t, r.target, "target");
});
t.test("recursive", [](testing & t) {
auto doc = parse(R"({
"$ref": "#/$defs/node",
"$defs": {
"node": {
"type": "object",
"properties": {
"value": {"type": "number"},
"next": {"$ref": "#/$defs/node"}
},
"required": ["value"]
}
}
})");
const auto & r = root<common_schema_ref>(t, doc);
const auto & node = as<common_schema_object>(t, r.target, "node");
t.assert_equal("properties", (size_t) 2, node.properties.size());
const auto & next = as<common_schema_ref>(t, node.properties[1].schema.get(), "next");
t.assert_true("cycle", next.target == r.target);
t.assert_equal("refs", (size_t) 1, doc.refs.size());
});
t.test("pointer through an array", [](testing & t) {
auto doc = parse(R"({"oneOf": [{"type": "null"}, {"$ref": "#/oneOf/0"}]})");
const auto & u = root<common_schema_any_of>(t, doc);
const auto & r = as<common_schema_ref>(t, u.children[1].get(), "children[1]");
as<common_schema_null>(t, r.target, "target");
});
t.test("targets survive moving the document", [](testing & t) {
auto parsed = parse(R"({"items": {"$ref": "#/$defs/t"}, "$defs": {"t": {"type": "null"}}})");
common_schema_document doc = std::move(parsed);
const auto & a = root<common_schema_array>(t, doc);
const auto & r = as<common_schema_ref>(t, a.items.get(), "items");
t.assert_true("target", r.target == doc.refs.at("#/$defs/t").get());
as<common_schema_null>(t, r.target, "target");
});
t.test("a schema parsed into a document shares its refs", [](testing & t) {
auto doc = parse(R"({"properties": {"a": {"$ref": "#/$defs/t"}}, "$defs": {"t": {"type": "boolean"}}})");
auto node = common_schema_from_json(common_json::parse(R"({"items": {"$ref": "#/$defs/t"}})"), doc);
const auto & a = as<common_schema_array>(t, node.get(), "node");
const auto & r = as<common_schema_ref>(t, a.items.get(), "items");
t.assert_true("shared target", r.target == doc.refs.at("#/$defs/t").get());
t.assert_equal("refs", (size_t) 1, doc.refs.size());
auto added = common_schema_from_json(common_json::parse(R"({"$ref": "#/$defs/u", "$defs": {"u": {"type": "null"}}})"), doc);
as<common_schema_null>(t, as<common_schema_ref>(t, added.get(), "added").target, "target");
t.assert_equal("refs", (size_t) 2, doc.refs.size());
});
t.test("a rejected schema leaves the document unchanged", [](testing & t) {
common_schema_document doc;
try {
common_schema_from_json(common_json::parse(R"({"allOf": [{"$ref": "#/$defs/t"}, {"type": "x"}], "$defs": {"t": {"type": "null"}}})"), doc);
t.assert_true("rejected", false);
} catch (const std::runtime_error &) {
t.assert_true("no refs", doc.refs.empty());
}
});
}
static void test_may_be_string(testing & t) {
auto check = [](testing & t, const std::string & schema, bool expected) {
t.assert_equal(schema, expected, parse(schema).root->may_be_string());
};
t.test("leaves", [&](testing & t) {
check(t, R"({"type": "string"})", true);
check(t, R"({"type": "integer"})", false);
check(t, R"({"minLength": 1})", false);
check(t, R"({"pattern": "^[a-z]+$"})", true);
check(t, R"({"const": "hello"})", true);
check(t, R"({"const": 123})", false);
check(t, R"({"enum": [1, "a", null]})", true);
check(t, R"({"enum": [1, 2, 3]})", false);
});
t.test("composites", [&](testing & t) {
check(t, R"({"type": ["integer", "string"]})", true);
check(t, R"({"anyOf": [{"type": "integer"}, {"type": "boolean"}]})", false);
check(t, R"({"allOf": [{"type": "string"}, {"minLength": 1}]})", true);
check(t, R"({"allOf": [{"type": "string"}, {"type": "integer"}]})", false);
check(t, R"({"allOf": [{"minLength": 1}, {"maxLength": 2}]})", false);
});
t.test("ref", [&](testing & t) {
check(t, R"({"$ref": "#/$defs/n", "$defs": {"n": {"anyOf": [{"$ref": "#/$defs/n"}, {"type": "string"}]}}})", true);
check(t, R"({"$ref": "#/$defs/n", "$defs": {"n": {"$ref": "#/$defs/n"}}})", false);
});
}
// e.g. {number, integer}, in type order
static std::string dump(const common_schema::type_set & types) {
static const common_schema::value_type order[] = { common_schema::TYPE_NULL, common_schema::TYPE_BOOLEAN, common_schema::TYPE_NUMBER,
common_schema::TYPE_INTEGER, common_schema::TYPE_STRING, common_schema::TYPE_ARRAY,
common_schema::TYPE_OBJECT };
std::string out;
for (auto type : order) {
if (types.has(type)) {
out += (out.empty() ? "" : ", ") + std::string(common_schema::type_name(type));
}
}
return "{" + out + "}";
}
static void test_value_types(testing & t) {
auto check = [](testing & t, const std::string & schema, const common_schema::type_set & expected) {
t.assert_equal(schema, dump(expected), dump(parse(schema).root->value_types()));
};
t.test("leaves", [&](testing & t) {
check(t, R"({"type": "string"})", { common_schema::TYPE_STRING });
check(t, R"({"type": "number"})", { common_schema::TYPE_NUMBER, common_schema::TYPE_INTEGER });
check(t, R"({"minLength": 1})", common_schema::type_set::all());
check(t, R"({"properties": {"a": {"type": "string"}}})", { common_schema::TYPE_OBJECT });
check(t, R"({"items": {"type": "string"}})", { common_schema::TYPE_ARRAY });
check(t, R"({"const": 1.5})", { common_schema::TYPE_NUMBER });
check(t, R"({"enum": [1, "a", null]})", { common_schema::TYPE_INTEGER, common_schema::TYPE_STRING, common_schema::TYPE_NULL });
});
t.test("any_of is the union, all_of is the intersection", [&](testing & t) {
check(t, R"({"type": ["string", "null"]})", { common_schema::TYPE_STRING, common_schema::TYPE_NULL });
check(t, R"({"allOf": [{"type": ["string", "number"]}, {"type": ["number", "object"]}]})", { common_schema::TYPE_NUMBER, common_schema::TYPE_INTEGER });
check(t, R"({"allOf": [{"type": "string"}, {"type": "integer"}]})", {});
});
t.test("ref", [&](testing & t) {
check(t, R"({"$ref": "#/$defs/n", "$defs": {"n": {"anyOf": [{"$ref": "#/$defs/n"}, {"type": "string"}]}}})",
{ common_schema::TYPE_STRING });
});
}
static void test_errors(testing & t) {
t.test("not a schema", [](testing & t) {
assert_error(t, R"([])", "#: schema must be an object");
});
t.test("type", [](testing & t) {
assert_error(t, R"({"type": 5})", "#: type must be a string or an array of strings");
assert_error(t, R"({"type": []})", "#: type must not be empty");
assert_error(t, R"({"type": ["string", "bad"]})", "#/type/1: unrecognized type bad");
});
t.test("ref", [](testing & t) {
assert_error(t, R"({"$ref": 5})", "#: $ref must be a string");
assert_error(t, R"({"$ref": "https://example.com/x.json"})", "#: unsupported $ref https://example.com/x.json");
assert_error(t, R"({"$defs": {}, "$ref": "#/$defs/missing"})", "#: cannot resolve $ref #/$defs/missing, missing not found");
assert_error(t, R"({"oneOf": [{}], "$ref": "#/oneOf/1"})", "#: cannot resolve $ref #/oneOf/1, 1 is out of range");
assert_error(t, R"({"$defs": {"a": {"$ref": "#/$defs/a/nope"}}, "$ref": "#/$defs/a"})", "#/$defs/a: cannot resolve $ref #/$defs/a/nope, nope not found");
});
t.test("alternatives", [](testing & t) {
assert_error(t, R"({"oneOf": []})", "#/oneOf: must not be empty");
assert_error(t, R"({"anyOf": {}})", "#/anyOf: must be an array of schemas");
assert_error(t, R"({"anyOf": [{"type": "string"}, {"items": {"type": "x"}}]})", "#/anyOf/1/items: unrecognized type x");
});
t.test("keywords", [](testing & t) {
assert_error(t, R"({"enum": []})", "#: enum must be a non-empty array");
assert_error(t, R"({"type": "string", "pattern": 5})", "#: pattern must be a string");
assert_error(t, R"({"type": "string", "minLength": -1})", "#: minLength must be a non-negative integer");
assert_error(t, R"({"type": "integer", "minimum": "1"})", "#: minimum must be a number");
assert_error(t, R"({"type": "array", "maxItems": 1.5})", "#: maxItems must be a non-negative integer");
assert_error(t, R"({"properties": []})", "#: properties must be an object");
assert_error(t, R"({"properties": {"a": {"type": "nope"}}})", "#/properties/a: unrecognized type nope");
assert_error(t, R"({"additionalProperties": null})", "#: additionalProperties must be a boolean or a schema");
});
}
int main(int argc, char * argv[]) {
testing t(std::cout);
if (argc >= 2) {
t.set_filter(argv[1]);
}
const char * verbose = getenv("LLAMA_TEST_VERBOSE");
if (verbose) {
t.verbose = std::string(verbose) == "1";
}
t.test("any", test_any);
t.test("primitives", test_primitives);
t.test("integer", test_integer);
t.test("string", test_string);
t.test("array", test_array);
t.test("tuple", test_tuple);
t.test("object", test_object);
t.test("const and enum", test_const_enum);
t.test("any_of", test_any_of);
t.test("all_of", test_all_of);
t.test("ref", test_ref);
t.test("may_be_string", test_may_be_string);
t.test("value_types", test_value_types);
t.test("errors", test_errors);
return t.summary();
}
+2 -2
View File
@@ -133,8 +133,8 @@
| `-l, --logit-bias TOKEN_ID(+/-)BIAS` | modifies the likelihood of token appearing in the completion,<br/>i.e. `--logit-bias 15043+1` to increase likelihood of token ' Hello',<br/>or `--logit-bias 15043-1` to decrease likelihood of token ' Hello' |
| `--grammar GRAMMAR` | BNF-like grammar to constrain generations (see samples in grammars/ dir) |
| `--grammar-file FNAME` | file to read grammar from |
| `-j, --json-schema SCHEMA` | JSON schema to constrain generations (https://json-schema.org/), e.g. `{}` for any JSON object<br/>For schemas w/ external $refs, use --grammar + example/json_schema_to_grammar.py instead |
| `-jf, --json-schema-file FILE` | File containing a JSON schema to constrain generations (https://json-schema.org/), e.g. `{}` for any JSON object<br/>For schemas w/ external $refs, use --grammar + example/json_schema_to_grammar.py instead |
| `-j, --json-schema SCHEMA` | JSON schema to constrain generations (https://json-schema.org/), e.g. `{"type": "object"}` for any JSON object |
| `-jf, --json-schema-file FILE` | File containing a JSON schema to constrain generations (https://json-schema.org/), e.g. `{"type": "object"}` for any JSON object |
| `-bs, --backend-sampling` | enable backend sampling (experimental) (default: disabled)<br/>(env: LLAMA_ARG_BACKEND_SAMPLING) |
+3 -3
View File
@@ -216,8 +216,8 @@ llama-completion.exe -m models\gemma-1.1-7b-it.Q4_K_M.gguf --ignore-eos -n -1
| `-l, --logit-bias TOKEN_ID(+/-)BIAS` | modifies the likelihood of token appearing in the completion,<br/>i.e. `--logit-bias 15043+1` to increase likelihood of token ' Hello',<br/>or `--logit-bias 15043-1` to decrease likelihood of token ' Hello' |
| `--grammar GRAMMAR` | BNF-like grammar to constrain generations (see samples in grammars/ dir) |
| `--grammar-file FNAME` | file to read grammar from |
| `-j, --json-schema SCHEMA` | JSON schema to constrain generations (https://json-schema.org/), e.g. `{}` for any JSON object<br/>For schemas w/ external $refs, use --grammar + example/json_schema_to_grammar.py instead |
| `-jf, --json-schema-file FILE` | File containing a JSON schema to constrain generations (https://json-schema.org/), e.g. `{}` for any JSON object<br/>For schemas w/ external $refs, use --grammar + example/json_schema_to_grammar.py instead |
| `-j, --json-schema SCHEMA` | JSON schema to constrain generations (https://json-schema.org/), e.g. `{"type": "object"}` for any JSON object |
| `-jf, --json-schema-file FILE` | File containing a JSON schema to constrain generations (https://json-schema.org/), e.g. `{"type": "object"}` for any JSON object |
| `-bs, --backend-sampling` | enable backend sampling (experimental) (default: disabled)<br/>(env: LLAMA_ARG_BACKEND_SAMPLING) |
@@ -556,7 +556,7 @@ These options help improve the performance and memory usage of the LLaMA models.
- `--grammar GRAMMAR`, `--grammar-file FILE`: Specify a grammar (defined inline or in a file) to constrain model output to a specific format. For example, you could force the model to output JSON or to speak only in emojis. See the [GBNF guide](../../grammars/README.md) for details on the syntax.
- `--json-schema SCHEMA`: Specify a [JSON schema](https://json-schema.org/) to constrain model output to (e.g. `{}` for any JSON object, or `{"items": {"type": "string", "minLength": 10, "maxLength": 100}, "minItems": 10}` for a JSON array of strings with size constraints). If a schema uses external `$ref`s, you should use `--grammar "$( python examples/json_schema_to_grammar.py myschema.json )"` instead.
- `--json-schema SCHEMA`: Specify a [JSON schema](https://json-schema.org/) to constrain model output to (e.g. `{"type": "object"}` for any JSON object, or `{"items": {"type": "string", "minLength": 10, "maxLength": 100}, "minItems": 10}` for a JSON array of strings with size constraints).
### Quantization
+2 -2
View File
@@ -150,8 +150,8 @@ For the full list of features, please refer to [server's changelog](https://gith
| `-l, --logit-bias TOKEN_ID(+/-)BIAS` | modifies the likelihood of token appearing in the completion,<br/>i.e. `--logit-bias 15043+1` to increase likelihood of token ' Hello',<br/>or `--logit-bias 15043-1` to decrease likelihood of token ' Hello' |
| `--grammar GRAMMAR` | BNF-like grammar to constrain generations (see samples in grammars/ dir) |
| `--grammar-file FNAME` | file to read grammar from |
| `-j, --json-schema SCHEMA` | JSON schema to constrain generations (https://json-schema.org/), e.g. `{}` for any JSON object<br/>For schemas w/ external $refs, use --grammar + example/json_schema_to_grammar.py instead |
| `-jf, --json-schema-file FILE` | File containing a JSON schema to constrain generations (https://json-schema.org/), e.g. `{}` for any JSON object<br/>For schemas w/ external $refs, use --grammar + example/json_schema_to_grammar.py instead |
| `-j, --json-schema SCHEMA` | JSON schema to constrain generations (https://json-schema.org/), e.g. `{"type": "object"}` for any JSON object |
| `-jf, --json-schema-file FILE` | File containing a JSON schema to constrain generations (https://json-schema.org/), e.g. `{"type": "object"}` for any JSON object |
| `-bs, --backend-sampling` | enable backend sampling (experimental) (default: disabled)<br/>(env: LLAMA_ARG_BACKEND_SAMPLING) |
+4 -1
View File
@@ -1171,7 +1171,10 @@ json oaicompat_chat_params_parse(
std::string response_type = json_value(response_format, "type", std::string());
if (response_type == "json_object") {
if (response_format.contains("schema") || json_schema.empty()) {
json_schema = json_value(response_format, "schema", json::object());
// any object without a schema, {} would be any value
json any_object = json::object();
any_object["type"] = "object";
json_schema = json_value(response_format, "schema", any_object);
}
} else if (response_type == "json_schema") {
auto schema_wrapper = json_value(response_format, "json_schema", json::object());