From 7bb9ef5b790e2afa34a18eb59e23b5e91c2fcdd4 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 20 Oct 2026 22:18:48 +0000 Subject: [PATCH 1/2] fix(llama-cpp): preserve strict tool flags Carry a local native patch that preserves strict through tool parsing and serialization. Keep false and absent values compatible with legacy callers. Add a CPU-only native round-trip regression test. This prerequisite does not enforce strict argument schemas yet. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto --- .../patches/0003-lfm-strict-schema.patch | 29 ++++++++++ .../llama-cpp/tests/README-native-strict.md | 48 +++++++++++++++++ backend/cpp/llama-cpp/tests/native-strict.cpp | 54 +++++++++++++++++++ .../tests/native-strict/CMakeLists.txt | 12 +++++ .../cpp/llama-cpp/tests/run-native-strict.sh | 20 +++++++ 5 files changed, 163 insertions(+) create mode 100644 backend/cpp/llama-cpp/patches/0003-lfm-strict-schema.patch create mode 100644 backend/cpp/llama-cpp/tests/README-native-strict.md create mode 100644 backend/cpp/llama-cpp/tests/native-strict.cpp create mode 100644 backend/cpp/llama-cpp/tests/native-strict/CMakeLists.txt create mode 100755 backend/cpp/llama-cpp/tests/run-native-strict.sh diff --git a/backend/cpp/llama-cpp/patches/0003-lfm-strict-schema.patch b/backend/cpp/llama-cpp/patches/0003-lfm-strict-schema.patch new file mode 100644 index 000000000000..2c5ade2f71f4 --- /dev/null +++ b/backend/cpp/llama-cpp/patches/0003-lfm-strict-schema.patch @@ -0,0 +1,29 @@ +--- a/common/chat.h ++++ b/common/chat.h +@@ -224,4 +224,5 @@ + std::string name; + std::string description; + std::string parameters; ++ bool strict = false; + }; +--- a/common/chat.cpp ++++ b/common/chat.cpp +@@ -539,6 +539,10 @@ + { "parameters", json::parse(tool.parameters) }, + }}, + }); ++ // Keep the legacy non-strict tool representation unchanged. ++ if (tool.strict) { ++ result.back()["function"]["strict"] = true; ++ } + } + return result; + } +@@ -578,6 +582,7 @@ + /* .name = */ function.at("name"), + /* .description = */ function.value("description", ""), + /* .parameters = */ function.value("parameters", json::object()).dump(), ++ /* .strict = */ function.value("strict", false), + }); + } + } diff --git a/backend/cpp/llama-cpp/tests/README-native-strict.md b/backend/cpp/llama-cpp/tests/README-native-strict.md new file mode 100644 index 000000000000..7220df675a45 --- /dev/null +++ b/backend/cpp/llama-cpp/tests/README-native-strict.md @@ -0,0 +1,48 @@ +# Native strict transport regression + +`patches/0003-lfm-strict-schema.patch` targets llama.cpp +`c0fd78626405c6d9b934efef0ec7291fcc20e3d6`. This first portion changes +`common/chat.h` and `common/chat.cpp` only. It preserves `function.strict:true` +through the actual oaicompat parser, `common_chat_tool`, and serializer. +False and absent values both default to non-strict. Their serialized tool +representation does not change. Three-field aggregate initializers remain +valid because the new trailing member defaults to false. + +This patch does **not** enforce argument schemas or change LFM grammar. +Strict schema generation, production patch preparation, and CI integration +remain separate work. Do not treat this transport regression as full schema +acceptance. + +## CPU-only check + +Use an isolated extraction of the exact pinned source. Apply the patch with +`git apply --check` followed by `git apply` from that source directory. The +runner neither downloads nor patches source, so it also runs against an +unpatched extraction for the RED check. + +From the LocalAI root: + +```sh +backend/cpp/llama-cpp/tests/run-native-strict.sh /absolute/pinned-source /absolute/scratch-build +``` + +The runner builds the real `llama-common` and `llama` libraries with CPU-only +settings, then runs CTest. It requires no model, gRPC server, inference, or +GPU. Use a fresh build directory when changing source trees. A change to the +native struct requires rebuilding dependent common objects; do not link +old common libraries against the new header. + +The harness is deliberately not named `*_test.cpp`: the independent +standard-library-only backend runner must not discover it. + +At the unpatched pin the executable builds, then fails three strict-true +round-trip checks. False, absent, aggregate compatibility, and empty/null +controls pass. After the patch, all checks pass. + +## Maintenance + +Keep this patch tied to the exact pin above. Rebase and rerun this native +regression when the dependency changes. Retire the transport portion only +when the new pin preserves strict through both native conversion functions +and passes the legacy compatibility controls. Grammar enforcement requires +its own acceptance tests before any full-repair claim. diff --git a/backend/cpp/llama-cpp/tests/native-strict.cpp b/backend/cpp/llama-cpp/tests/native-strict.cpp new file mode 100644 index 000000000000..ca59987f2eb8 --- /dev/null +++ b/backend/cpp/llama-cpp/tests/native-strict.cpp @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +#include "chat.h" + +#include +#include +#include + +// Keep the harness runnable against the unpatched pin: missing transport +// support must produce a runtime regression failure, not a compiler error. +template struct strict_flag { + static bool get(const T &) { return false; } +}; +template struct strict_flag().strict)>> { + static bool get(const T & tool) { return tool.strict; } +}; + +int main() { + int failures = 0; + auto check = [&](bool ok, const char * label) { + std::cout << (ok ? "PASS " : "FAIL ") << label << '\n'; + failures += !ok; + }; + using json = common_json; + const auto parameters = json::parse(R"({"type":"object","properties":{"prompt":{"type":"string"}},"required":["prompt"],"additionalProperties":false})"); + for (int mode = 0; mode != 3; ++mode) { + const bool expected = mode == 0; + json function = {{"name", "sound"}, {"description", "Sound tool"}, {"parameters", parameters}}; + if (mode != 2) { + function["strict"] = expected; + } + const json incoming = json::array({{{"type", "function"}, {"function", function}}}); + const auto native = common_chat_tools_parse_oaicompat(incoming); + check(native.size() == 1, "incoming tool count"); + if (native.size() != 1) { continue; } + std::cout << "CASE " << (mode == 0 ? "true" : mode == 1 ? "false" : "absent") << '\n'; + check(strict_flag::get(native[0]) == expected, "incoming -> native strict"); + const auto outgoing = common_chat_tools_to_json_oaicompat(native); + const auto & out = outgoing.at(0).at("function"); + check(out.value("strict", false) == expected, "native -> outgoing strict"); + check(expected ? out.contains("strict") : !out.contains("strict"), + "only strict tools add a serialized strict field"); + check(out.at("name") == "sound" && out.at("description") == "Sound tool" && + out.at("parameters") == parameters, "other tool fields unchanged"); + const auto reparsed = common_chat_tools_parse_oaicompat(outgoing); + check(strict_flag::get(reparsed.at(0)) == expected, "outgoing -> native strict"); + } + const common_chat_tool legacy = {"legacy", "Three-field initializer", parameters.dump()}; + check(!strict_flag::get(legacy), "three-field aggregate defaults false"); + const auto legacy_json = common_chat_tools_to_json_oaicompat({legacy}); + check(!legacy_json.at(0).at("function").value("strict", false), "legacy serialization remains non-strict"); + check(common_chat_tools_parse_oaicompat(json()).empty(), "null input remains empty"); + check(common_chat_tools_to_json_oaicompat({}).is_null(), "empty output remains null"); + return failures ? 1 : 0; +} diff --git a/backend/cpp/llama-cpp/tests/native-strict/CMakeLists.txt b/backend/cpp/llama-cpp/tests/native-strict/CMakeLists.txt new file mode 100644 index 000000000000..4919c791298b --- /dev/null +++ b/backend/cpp/llama-cpp/tests/native-strict/CMakeLists.txt @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: MIT +cmake_minimum_required(VERSION 3.14) +project(localai_native_strict C CXX) +if(NOT IS_DIRECTORY "${LLAMA_SOURCE_DIR}/common") + message(FATAL_ERROR "LLAMA_SOURCE_DIR must name the pinned llama.cpp source") +endif() +add_subdirectory("${LLAMA_SOURCE_DIR}" pinned) +add_executable(native-strict ../native-strict.cpp) +target_link_libraries(native-strict PRIVATE llama-common llama) +target_compile_features(native-strict PRIVATE cxx_std_17) +enable_testing() +add_test(NAME native-strict COMMAND native-strict) diff --git a/backend/cpp/llama-cpp/tests/run-native-strict.sh b/backend/cpp/llama-cpp/tests/run-native-strict.sh new file mode 100755 index 000000000000..3ad2a6b5616f --- /dev/null +++ b/backend/cpp/llama-cpp/tests/run-native-strict.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# SPDX-License-Identifier: MIT +# Use an isolated source and build directory; never downloads or patches source. +set -eu +if [ "$#" -ne 2 ]; then + echo "Usage: $0 PINNED_LLAMA_SOURCE SCRATCH_BUILD_DIR" >&2 + exit 2 +fi +here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +src=$(CDPATH= cd -- "$1" && pwd) +build=$2 +cmake -S "$here/native-strict" -B "$build" -DLLAMA_SOURCE_DIR="$src" \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \ + -DLLAMA_BUILD_COMMON=ON -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_TOOLS=OFF \ + -DLLAMA_BUILD_EXAMPLES=OFF -DLLAMA_BUILD_SERVER=OFF -DLLAMA_BUILD_APP=OFF \ + -DLLAMA_OPENSSL=OFF -DGGML_CUDA=OFF -DGGML_VULKAN=OFF -DGGML_METAL=OFF \ + -DGGML_SYCL=OFF -DGGML_OPENCL=OFF -DGGML_BLAS=OFF -DGGML_NATIVE=OFF \ + -DGGML_OPENMP=OFF -DLLAMA_BUILD_COMMIT=c0fd78626405c6d9b934efef0ec7291fcc20e3d6 +cmake --build "$build" --target native-strict -j "${JOBS:-4}" +ctest --test-dir "$build" --output-on-failure -R '^native-strict$' From b7434d9cb0c0e6486efd5a7eb47ebde69ece6437 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 21 Oct 2026 07:40:46 +0000 Subject: [PATCH 2/2] fix(llama-cpp): constrain strict LFM arguments The native LFM builder accepts arguments that violate strict schemas. Carry a local schema-aware patch and validate decoded mapped values. Require complete final tool envelopes before publishing calls. Add offline native grammar, parser and exact-mapping regressions. Build integration and full acceptance remain pending in the draft PR. Assisted-by: nib:gpt-6.1-sol Signed-off-by: Ettore Di Giacinto --- .../patches/0003-lfm-strict-schema.patch | 482 ++++++++++++++- .../llama-cpp/tests/README-native-strict.md | 89 +-- .../tests/fixtures/sound-schema.json | 548 ++++++++++++++++++ backend/cpp/llama-cpp/tests/native-edge.cpp | 116 ++++ .../cpp/llama-cpp/tests/native-json-test.h | 20 + backend/cpp/llama-cpp/tests/native-schema.cpp | 117 ++++ .../tests/native-strict/CMakeLists.txt | 15 + .../cpp/llama-cpp/tests/run-native-strict.sh | 4 +- 8 files changed, 1342 insertions(+), 49 deletions(-) create mode 100644 backend/cpp/llama-cpp/tests/fixtures/sound-schema.json create mode 100644 backend/cpp/llama-cpp/tests/native-edge.cpp create mode 100644 backend/cpp/llama-cpp/tests/native-json-test.h create mode 100644 backend/cpp/llama-cpp/tests/native-schema.cpp diff --git a/backend/cpp/llama-cpp/patches/0003-lfm-strict-schema.patch b/backend/cpp/llama-cpp/patches/0003-lfm-strict-schema.patch index 2c5ade2f71f4..a436cd52827e 100644 --- a/backend/cpp/llama-cpp/patches/0003-lfm-strict-schema.patch +++ b/backend/cpp/llama-cpp/patches/0003-lfm-strict-schema.patch @@ -1,14 +1,329 @@ ---- a/common/chat.h -+++ b/common/chat.h -@@ -224,4 +224,5 @@ - std::string name; - std::string description; - std::string parameters; -+ bool strict = false; - }; +diff --git a/common/chat-native-schema.h b/common/chat-native-schema.h +new file mode 100644 +index 0000000..a678895 +--- /dev/null ++++ b/common/chat-native-schema.h +@@ -0,0 +1,201 @@ ++// SPDX-License-Identifier: MIT ++#pragma once ++ ++#include "chat-peg-parser.h" ++#include ++#include ++ ++// Deliberately bounded subset for native Python-style strict tool calls. These ++// define canonical generation. Recognition validates decoded values separately; ++// generation order follows the schema's property order. ++class common_native_schema { ++ common_chat_peg_builder & b; ++ std::string prefix; ++ ++ [[noreturn]] static void unsupported(const std::string & detail) { ++ throw std::runtime_error("strict native tool schema: " + detail); ++ } ++ static void keys(const common_json & s, const std::set & allowed) { ++ if (!s.is_object()) { unsupported("expected a schema object"); } ++ for (const auto & e : s.items()) { ++ if (!allowed.count(e.key())) { unsupported("unsupported keyword " + e.key()); } ++ } ++ } ++ common_peg_parser alternatives(std::vector values) { ++ // PEG ordered choice must try longer literals before their prefixes. ++ std::sort(values.begin(), values.end(), [](const std::string & a, const std::string & c) { ++ return a.size() != c.size() ? a.size() > c.size() : a < c; ++ }); ++ auto p = b.choice(); ++ for (const auto & value : values) { p |= b.literal(value); } ++ return p; ++ } ++ static int bound(const common_json & s, const char * key, int absent, int limit) { ++ if (!s.contains(key)) { return absent; } ++ const auto & n = s[key]; ++ // Check type and range before narrowing. The small supported limit is ++ // exactly representable as double. Floats (even ++ // integral-looking ones), negatives and oversized unsigneds fail closed. ++ if (!n.is_number_integer() || n.get() < 0 || n.get() > limit) { ++ unsupported(std::string(key) + " must be a nonnegative integer <= " + std::to_string(limit)); ++ } ++ return n.get(); ++ } ++ common_peg_parser string(const common_json & s) { ++ int lo = bound(s, "minLength", 0, 100000), hi = bound(s, "maxLength", -1, 100000); ++ if (lo < 0 || hi < -1 || (hi >= 0 && hi < lo) || lo > 100000 || hi > 100000) { ++ unsupported("invalid or excessive string length bounds"); ++ } ++ if (s.contains("enum")) { ++ if (!s["enum"].is_array() || s["enum"].empty()) { unsupported("expected nonempty string enum"); } ++ std::vector values; ++ for (const auto & v : s["enum"]) { ++ if (!v.is_string()) { unsupported("expected string enum member"); } ++ // The literal parser retains native quote normalization downstream. ++ std::string text = v.get(); ++ size_t length = 0; ++ for (unsigned char c : text) { if ((c & 0xc0) != 0x80) { ++length; } } ++ if (length < size_t(lo) || (hi >= 0 && length > size_t(hi))) { continue; } ++ values.push_back(v.dump()); ++ std::string single = "'"; ++ for (char c : text) { ++ if (c == '\'' || c == '\\') { single += '\\'; } ++ if (static_cast(c) < 32) { unsupported("control character in enum"); } ++ single += c; ++ } ++ values.push_back(single + "'"); ++ } ++ if (s.contains("pattern")) { unsupported("enum combined with pattern"); } ++ if (values.empty()) { unsupported("enum has no values within length bounds"); } ++ return alternatives(values); ++ } ++ if (s.contains("pattern")) { ++ if (s["pattern"] != "^(0?[1-9]|[12][0-9]|3[0-2])/(0?[1-9]|[12][0-9]|3[0-2])$" || lo != 0 || hi != -1) { ++ unsupported("only the Sound musical-meter pattern is supported"); ++ } ++ std::vector values; ++ for (int n = 1; n <= 32; ++n) { ++ values.push_back(std::to_string(n)); ++ if (n < 10) { values.push_back("0" + std::to_string(n)); } ++ } ++ auto meter = alternatives(values); ++ return b.choice({b.literal("\"") + meter + "/" + meter + "\"", ++ b.literal("'") + meter + "/" + meter + "'"}); ++ } ++ auto quoted = [&](char q) { ++ // One Unicode scalar or one native escape counts as one character. ++ // A UTF-16 surrogate pair in a Unicode escape is one scalar too. ++ auto hex = b.chars("[0-9a-fA-F]", 1, 1); ++ auto unicode = b.literal("\\u") + b.choice({ ++ b.chars("[dD]", 1, 1) + b.chars("[89aAbB]", 1, 1) + b.repeat(hex, 2) + ++ "\\u" + b.chars("[dD]", 1, 1) + b.chars("[c-fC-F]", 1, 1) + b.repeat(hex, 2), ++ b.chars("[0-9a-cA-Ce-fE-F]", 1, 1) + b.repeat(hex, 3), ++ b.chars("[dD]", 1, 1) + b.chars("[0-7]", 1, 1) + b.repeat(hex, 2)}); ++ auto escaped = unicode | (b.literal("\\") + b.chars("[\"'\\\\/bfnrt]", 1, 1)); ++ std::string excluded = q == '"' ? "^\"\\\\\\x00-\\x1f" : "^'\\\\\\x00-\\x1f"; ++ auto unit = escaped | b.chars("[" + excluded + "]", 1, 1); ++ // The native grammar reader treats upper bounds above 2000 as ++ // unbounded. Keep every emitted repetition below that threshold. ++ auto unit_rule = b.rule(prefix + (q == '"' ? "-double-unit" : "-single-unit"), [&]() { return unit; }); ++ auto content = b.literal(""); ++ int required = lo; ++ while (required > 0) { ++ int count = std::min(required, 1000); ++ content = content + b.repeat(unit_rule, count, count); ++ required -= count; ++ } ++ if (hi < 0) { ++ content = content + b.zero_or_more(unit_rule); ++ } else { ++ std::function bounded = [&](int n) { ++ if (n == 0) { return b.literal(""); } ++ int block = 1; ++ while (block * 2 <= n) { block *= 2; } ++ auto exact = b.rule(prefix + (q == '"' ? "-d-exact-" : "-s-exact-") + std::to_string(block), [&]() { ++ auto part = b.repeat(unit_rule, std::min(block, 1000), std::min(block, 1000)); ++ auto all = part; ++ int remaining = block - std::min(block, 1000); ++ while (remaining > 0) { int n = std::min(remaining, 1000); all = all + b.repeat(unit_rule, n, n); remaining -= n; } ++ return all; ++ }); ++ return b.rule(prefix + (q == '"' ? "-d-up-to-" : "-s-up-to-") + std::to_string(n), [&]() { ++ return (exact + bounded(n - block)) | bounded(block - 1); ++ }); ++ }; ++ content = content + bounded(hi - lo); ++ } ++ return b.literal(std::string(1,q)) + content + std::string(1,q); ++ }; ++ return quoted('"') | quoted('\''); ++ } ++public: ++ common_native_schema(common_chat_peg_builder & builder, std::string name) : b(builder), prefix(std::move(name)) {} ++ ++ common_peg_parser value(const common_json & s) { ++ keys(s, {"type", "description", "title", "enum", "minLength", "maxLength", "pattern", "minimum", "maximum"}); ++ auto type = s.value("type", std::string()); ++ if (type == "string") { ++ if (s.contains("minimum") || s.contains("maximum")) { unsupported("numeric bounds on string"); } ++ return string(s); ++ } ++ if (type != "integer" && type != "number") { unsupported("unsupported value type " + type); } ++ for (auto k : {"enum", "minLength", "maxLength", "pattern"}) { ++ if (s.contains(k)) { unsupported(std::string(k) + " on numeric value"); } ++ } ++ // Enumerating the integral part gives exact finite range enforcement, ++ // without relying on the generic converter (which ignores ranges). ++ if (!s.contains("minimum") || !s.contains("maximum") || ++ !s["minimum"].is_number_integer() || !s["maximum"].is_number_integer()) { ++ unsupported("numeric values require integral minimum and maximum"); ++ } ++ int lo = bound(s, "minimum", 0, 10000), hi = bound(s, "maximum", 0, 10000); ++ if (lo < 0 || hi < lo || hi > 10000) { unsupported("numeric range must satisfy 0 <= minimum <= maximum <= 10000"); } ++ std::vector integral; ++ for (int n = hi; n >= lo; --n) { integral.push_back(std::to_string(n)); } ++ auto whole = alternatives(integral); ++ if (type == "integer") { return whole; } ++ // Native decimal spelling, including fractional durations. The upper ++ // endpoint can only have a zero fractional part. Exponents are not ++ // generated; semantic recognition also accepts in-range exponents. ++ std::vector below; ++ for (int n = hi - 1; n >= lo; --n) { below.push_back(std::to_string(n)); } ++ auto fractional = b.literal(std::to_string(hi)) + "." + b.chars("[0]"); ++ if (!below.empty()) { fractional |= alternatives(below) + "." + b.chars("[0-9]"); } ++ return fractional | whole; ++ } ++ ++ common_peg_parser arguments(const common_json & s) { ++ keys(s, {"type", "properties", "required", "additionalProperties", "description", "title"}); ++ if (s.value("type", std::string()) != "object" || !s.contains("additionalProperties") || s["additionalProperties"] != false) { ++ unsupported("expected object with additionalProperties:false"); ++ } ++ auto props = s.value("properties", common_json::object()); ++ if (!props.is_object()) { unsupported("properties must be an object"); } ++ auto required = s.value("required", common_json::array()); ++ if (!required.is_array()) { unsupported("required must be an array"); } ++ std::set req; ++ for (const auto & r : required) { ++ if (!r.is_string() || !props.contains(r.get()) || !req.insert(r.get()).second) { ++ unsupported("invalid required property"); ++ } ++ } ++ std::vector fields; ++ std::vector mandatory; ++ for (const auto & e : props.items()) { ++ auto field = b.tool_arg(b.tool_arg_open(b.tool_arg_name(b.literal(e.key())) + "=") + ++ b.tool_arg_value(value(e.value())) + b.tool_arg_close(b.eps())); ++ fields.push_back(field); ++ mandatory.push_back(req.count(e.key()) != 0); ++ } ++ // Two suffix states encode whether a comma is needed. Named rules keep ++ // all-optional schemas linear in size rather than expanding subsets. ++ std::function suffix = [&](size_t n, bool seen) { ++ if (n == fields.size()) { return b.literal(""); } ++ return b.rule(prefix + "-args-" + std::to_string(n) + (seen ? "-next" : "-first"), [&]() { ++ auto take = (seen ? b.literal(",") + b.space() : b.eps()) + fields[n] + suffix(n+1, true); ++ return mandatory[n] ? take : take | suffix(n+1, seen); ++ }); ++ }; ++ return suffix(0, false); ++ } ++}; +diff --git a/common/chat-peg-parser.cpp b/common/chat-peg-parser.cpp +index 74b81dc..42cbbd0 100644 +--- a/common/chat-peg-parser.cpp ++++ b/common/chat-peg-parser.cpp +@@ -1,4 +1,6 @@ ++#include + #include "chat-peg-parser.h" ++#include "chat-native-schema.h" + + #include "chat-auto-parser.h" + #include "ggml.h" +@@ -119,7 +121,9 @@ static std::string normalize_quotes_to_json(const std::string & input) { + } + + if (in_double_quoted) { +- // Inside a double-quoted string - pass through escape sequences as-is ++ // Python accepts \' in either quote style; JSON does not. ++ if (next == '\'') { result += '\''; ++i; continue; } ++ // Other double-quoted escapes already have JSON spelling. + result += c; + result += next; + ++i; +@@ -552,6 +556,72 @@ common_peg_parser common_chat_peg_builder::python_or_json_value() { + + // Python-style tool calls: name(arg1="value1", arg2=123) + // Used only by LFM2 for now, so we don't merge it into autoparser ++// Validate the complete native argument list after quote normalization, before ++// any AST can be reported as a successful strict parse. Generation stays ordered. ++static bool validate_native_arguments(const std::string & text, const common_json & schema) { ++ try { ++ common_json args = common_json::object(); ++ size_t start = 0; ++ char quote = 0; ++ bool escape = false; ++ auto field = [&](size_t end) { ++ std::string part = text.substr(start, end - start); ++ if (part.find_first_not_of(" \t\r\n") == std::string::npos) { return start == 0; } ++ auto equal = part.find('='); ++ if (equal == std::string::npos) { return false; } ++ std::string name = part.substr(0, equal); ++ auto first = name.find_first_not_of(" \t\r\n"); ++ auto last = name.find_last_not_of(" \t\r\n"); ++ if (first == std::string::npos) { return false; } ++ name = name.substr(first, last - first + 1); ++ if (args.contains(name) || !schema["properties"].contains(name)) { return false; } ++ args[name] = common_json::parse(normalize_quotes_to_json(part.substr(equal + 1))); ++ return true; ++ }; ++ for (size_t i = 0; i < text.size(); ++i) { ++ char c = text[i]; ++ if (escape) { escape = false; continue; } ++ if (quote) { ++ if (c == '\\') { escape = true; } ++ else if (c == quote) { quote = 0; } ++ } else if (c == '\'' || c == '"') { quote = c; } ++ else if (c == ',') { if (!field(i)) { return false; } start = i + 1; } ++ } ++ if (quote || !field(text.size())) { return false; } ++ for (const auto & r : schema.value("required", common_json::array())) { ++ if (!args.contains(r.get())) { return false; } ++ } ++ for (const auto & e : args.items()) { ++ const auto & def = schema["properties"][e.key()]; ++ const auto & value = e.value(); ++ if (def["type"] == "string") { ++ if (!value.is_string()) { return false; } ++ const auto str = value.get(); ++ size_t length = 0; ++ for (unsigned char c : str) { if ((c & 0xc0) != 0x80) { ++length; } } ++ if (length < def.value("minLength", size_t(0)) || ++ (def.contains("maxLength") && length > def["maxLength"].get())) { return false; } ++ if (def.contains("enum") && std::find(def["enum"].begin(), def["enum"].end(), value) == def["enum"].end()) { return false; } ++ if (def.contains("pattern")) { ++ auto slash = str.find('/'); ++ auto meter = [](const std::string & v) { ++ if (v.empty() || v.size() > 2) { return false; } ++ for (char c : v) { if (c < '0' || c > '9') { return false; } } ++ int n = std::stoi(v); return n >= 1 && n <= 32; ++ }; ++ if (slash == std::string::npos || !meter(str.substr(0, slash)) || !meter(str.substr(slash + 1))) { return false; } ++ } ++ } else { ++ if (!value.is_number() || (def["type"] == "integer" && !value.is_number_integer()) || ++ !std::isfinite(value.get()) || ++ value.get() < def["minimum"].get() || ++ value.get() > def["maximum"].get()) { return false; } ++ } ++ } ++ return true; ++ } catch (const std::exception &) { return false; } ++} ++ + common_peg_parser common_chat_peg_builder::python_style_tool_calls( + const ordered_json & tools, + bool parallel_tool_calls, +@@ -572,7 +642,17 @@ common_peg_parser common_chat_peg_builder::python_style_tool_calls( + ordered_json params = common_chat_tool_parameters(function); + + auto args = eps(); +- if (params.contains("properties") && !params["properties"].empty()) { ++ if (function.value("strict", false)) { ++ auto generation = common_native_schema(*this, "native-tool-" + std::to_string(i)).arguments(params); ++ auto native_string = common_native_schema(*this, "native-recognition-" + std::to_string(i)).value(common_json{{"type", "string"}}); ++ auto scalar = native_string | python_number() | python_bool() | python_null() | json_bool() | json_null(); ++ auto field = tool_arg(tool_arg_open(tool_arg_name((chars("[a-zA-Z_]", 1, 1) + chars("[a-zA-Z0-9_]", 0, -1))) + "=") + ++ tool_arg_value(scalar) + tool_arg_close(eps())); ++ auto recognition = optional(field + zero_or_more("," + space() + field)); ++ args = validated(recognition, generation, [params](const std::string & text) { ++ return validate_native_arguments(text, params); ++ }); ++ } else if (params.contains("properties") && !params["properties"].empty()) { + auto arg_choice = choice(); + for (const auto & el : params["properties"].items()) { + const std::string & prop_name = el.key(); +diff --git a/common/chat.cpp b/common/chat.cpp +index 99ed74f..77dfa02 100644 --- a/common/chat.cpp +++ b/common/chat.cpp -@@ -539,6 +539,10 @@ +@@ -539,6 +539,10 @@ json common_chat_tools_to_json_oaicompat(const std::vector & t { "parameters", json::parse(tool.parameters) }, }}, }); @@ -19,7 +334,7 @@ } return result; } -@@ -578,6 +582,7 @@ +@@ -578,6 +582,7 @@ std::vector common_chat_tools_parse_oaicompat(const json & too /* .name = */ function.at("name"), /* .description = */ function.value("description", ""), /* .parameters = */ function.value("parameters", json::object()).dump(), @@ -27,3 +342,150 @@ }); } } +@@ -1687,6 +1692,19 @@ common_chat_msg common_chat_peg_parse(const common_peg_arena & src_pars + fflush(stderr); + } + ++ // LFM lenient parsing may synthesize missing closing delimiters at EOF. ++ // Streaming may expose partial arguments, but FINAL must never publish a ++ // call until the complete native envelope has been consumed without repair. ++ if (!is_partial && params.require_complete_final_tools && !msg.tool_calls.empty()) { ++ common_chat_input complete_input = input; ++ complete_input.prepend(params.generation_prompt); ++ common_peg_parse_context complete_ctx(std::move(complete_input.text), std::move(complete_input.tokens)); ++ auto complete = parser.parse(complete_ctx); ++ if (complete.fail() || complete.end != complete_ctx.input.size()) { ++ throw std::runtime_error("Incomplete FINAL native tool call"); ++ } ++ } ++ + if (!is_partial) { + LOG_DBG("Parsed message: %s\n", common_chat_msgs_to_json_oaicompat({ msg }).at(0).dump().c_str()); + } +@@ -1710,6 +1728,7 @@ common_chat_session::common_chat_session(const common_chat_templates * tm + thinking_start = std::move(applied.thinking_start_tag); + thinking_ends = std::move(applied.thinking_end_tags); + ++ parser_params.require_complete_final_tools = applied.require_complete_final_tools; + parser_params.format = applied.format; + parser_params.generation_prompt = vocab ? common_chat_input_tokenize(vocab, applied.generation_prompt) + : common_chat_input(applied.generation_prompt); +diff --git a/common/chat.h b/common/chat.h +index dcff949..b1b93c7 100644 +--- a/common/chat.h ++++ b/common/chat.h +@@ -224,6 +224,7 @@ struct common_chat_tool { + std::string name; + std::string description; + std::string parameters; ++ bool strict = false; + }; + + enum common_chat_tool_choice { +@@ -309,6 +310,7 @@ struct common_chat_params { + std::vector additional_stops; + common_peg_arena parser; + common_chat_msg_delimiters message_delimiters; ++ bool require_complete_final_tools = false; + }; + + struct common_chat_input { +@@ -341,10 +343,12 @@ common_chat_input common_chat_input_tokenize(const llama_vocab * vocab, const st + struct common_chat_parser_params { + common_chat_format format = COMMON_CHAT_FORMAT_CONTENT_ONLY; + common_chat_input generation_prompt; ++ bool require_complete_final_tools = false; + bool debug = false; // Enable debug output for PEG parser + common_peg_arena parser = {}; + common_chat_parser_params() = default; + common_chat_parser_params(const common_chat_params & chat_params) { ++ require_complete_final_tools = chat_params.require_complete_final_tools; + format = chat_params.format; + generation_prompt = common_chat_input(chat_params.generation_prompt); + } +diff --git a/common/parsers/lfm2.cpp b/common/parsers/lfm2.cpp +index 1bb1e67..41cae3e 100644 +--- a/common/parsers/lfm2.cpp ++++ b/common/parsers/lfm2.cpp +@@ -15,6 +15,7 @@ common_chat_params common_chat_params_init_lfm2(const common_chat_template & + const autoparser::generation_params & inputs, + bool tool_list_tokens) { + common_chat_params data; ++ data.require_complete_final_tools = true; + + const std::string TOOL_CALL_START = "<|tool_call_start|>"; + const std::string TOOL_CALL_END = "<|tool_call_end|>"; +diff --git a/common/peg-parser.cpp b/common/peg-parser.cpp +index 8033f7e..9c55db3 100644 +--- a/common/peg-parser.cpp ++++ b/common/peg-parser.cpp +@@ -807,7 +807,11 @@ struct parser_executor { + } + + common_peg_parse_result operator()(const common_peg_gbnf_parser & p) { +- return arena.parse(p.child, ctx, start_pos); ++ auto result = arena.parse(p.child, ctx, start_pos); ++ if (result.success() && p.validate && !p.validate(ctx.input.substr(start_pos, result.end - start_pos))) { ++ return {COMMON_PEG_PARSE_RESULT_FAIL, start_pos, start_pos, {}}; ++ } ++ return result; + } + + common_peg_parse_result operator()(const common_peg_ac_parser & p) { +@@ -884,6 +888,9 @@ void common_peg_arena::resolve_refs() { + std::is_same_v || + std::is_same_v) { + p.child = resolve_ref(p.child); ++ if constexpr (std::is_same_v) { ++ if (p.generation != COMMON_PEG_INVALID_PARSER_ID) { p.generation = resolve_ref(p.generation); } ++ } + } else if constexpr (std::is_same_v) { + p.child = resolve_ref(p.child); + } else if constexpr (std::is_same_v) { +@@ -1582,6 +1589,9 @@ static std::set collect_reachable_rules( + std::is_same_v || + std::is_same_v) { + visit(p.child); ++ if constexpr (std::is_same_v) { ++ if (p.generation != COMMON_PEG_INVALID_PARSER_ID) { visit(p.generation); } ++ } + } else if constexpr (std::is_same_v) { + if (visited.find(p.name) == visited.end()) { + visited.insert(p.name); +@@ -1752,7 +1762,7 @@ void common_peg_arena::build_grammar(const common_grammar_builder & builder, boo + } else if constexpr (std::is_same_v) { + return to_gbnf(p.child); + } else if constexpr (std::is_same_v) { +- return p.grammar; ++ return p.generation == COMMON_PEG_INVALID_PARSER_ID ? p.grammar : to_gbnf(p.generation); + } else if constexpr (std::is_same_v) { + return gbnf_including_grammar(builder, "ac-" + std::to_string(id), p.delimiters); + } else { +diff --git a/common/peg-parser.h b/common/peg-parser.h +index 9255d6e..86d84da 100644 +--- a/common/peg-parser.h ++++ b/common/peg-parser.h +@@ -298,6 +298,8 @@ struct common_peg_tag_parser { + struct common_peg_gbnf_parser { + common_peg_parser_id child; + std::string grammar; ++ common_peg_parser_id generation = COMMON_PEG_INVALID_PARSER_ID; ++ std::function validate; + }; + + struct common_peg_ac_parser { +@@ -540,7 +542,13 @@ class common_peg_parser_builder { + + // Wraps a child parser but emits a custom GBNF grammar string instead of + // the child's grammar. Parsing delegates entirely to the child. +- common_peg_parser gbnf(const common_peg_parser & p, const std::string & grammar) { return add(common_peg_gbnf_parser{p, grammar}); } ++ common_peg_parser gbnf(const common_peg_parser & p, const std::string & grammar) { return add(common_peg_gbnf_parser{p, grammar, COMMON_PEG_INVALID_PARSER_ID, {}}); } ++ ++ // Separate canonical generation from semantically validated recognition. ++ common_peg_parser validated(const common_peg_parser & child, const common_peg_parser & generation, ++ std::function validate) { ++ return add(common_peg_gbnf_parser{child, "", generation, std::move(validate)}); ++ } + + // Wraps a child parser but emits a GBNF grammar built from the Aho-Corasick + // automaton of `delimiters`, matching everything up to and including the diff --git a/backend/cpp/llama-cpp/tests/README-native-strict.md b/backend/cpp/llama-cpp/tests/README-native-strict.md index 7220df675a45..b9d88f4481c1 100644 --- a/backend/cpp/llama-cpp/tests/README-native-strict.md +++ b/backend/cpp/llama-cpp/tests/README-native-strict.md @@ -1,24 +1,29 @@ -# Native strict transport regression +# Local native LFM schema regression `patches/0003-lfm-strict-schema.patch` targets llama.cpp -`c0fd78626405c6d9b934efef0ec7291fcc20e3d6`. This first portion changes -`common/chat.h` and `common/chat.cpp` only. It preserves `function.strict:true` -through the actual oaicompat parser, `common_chat_tool`, and serializer. -False and absent values both default to non-strict. Their serialized tool -representation does not change. Three-field aggregate initializers remain -valid because the new trailing member defaults to false. - -This patch does **not** enforce argument schemas or change LFM grammar. -Strict schema generation, production patch preparation, and CI integration -remain separate work. Do not treat this transport regression as full schema -acceptance. - -## CPU-only check +`c0fd78626405c6d9b934efef0ec7291fcc20e3d6`. It preserves the native strict +flag through tool parsing and serialization, then selects a schema-aware +Python-style argument parser for strict tools. Non-strict tools retain the +legacy builder. This patch is carried by LocalAI; no upstream submission is +required. + +The supported subset covers Sound's object properties, required fields, +optional omission, string enums, string lengths, bounded integer/decimal +values and its musical-meter pattern. Unsupported schema constructs fail +explicitly rather than silently dropping constraints. Strict generation uses +canonical property order and decimal notation. The parser also accepts valid +reordered arguments, equivalent escaped enum strings and in-range exponent +notation, and validates their decoded values. LFM final responses must have a +complete native envelope before tools are published, including non-strict calls. +Partial streaming and non-strict argument recognition remain unchanged. + +## CPU-only checks Use an isolated extraction of the exact pinned source. Apply the patch with -`git apply --check` followed by `git apply` from that source directory. The -runner neither downloads nor patches source, so it also runs against an -unpatched extraction for the RED check. +`git apply --check` followed by `git apply` inside an initialized isolated +source repository. The runner neither downloads nor patches source. Do not +run Git patch checks from an uninitialized directory inside another repo: +Git can resolve that enclosing repository instead of the intended source. From the LocalAI root: @@ -26,23 +31,33 @@ From the LocalAI root: backend/cpp/llama-cpp/tests/run-native-strict.sh /absolute/pinned-source /absolute/scratch-build ``` -The runner builds the real `llama-common` and `llama` libraries with CPU-only -settings, then runs CTest. It requires no model, gRPC server, inference, or -GPU. Use a fresh build directory when changing source trees. A change to the -native struct requires rebuilding dependent common objects; do not link -old common libraries against the new header. - -The harness is deliberately not named `*_test.cpp`: the independent -standard-library-only backend runner must not discover it. - -At the unpatched pin the executable builds, then fails three strict-true -round-trip checks. False, absent, aggregate compatibility, and empty/null -controls pass. After the patch, all checks pass. - -## Maintenance - -Keep this patch tied to the exact pin above. Rebase and rerun this native -regression when the dependency changes. Retire the transport portion only -when the new pin preserves strict through both native conversion functions -and passes the legacy compatibility controls. Grammar enforcement requires -its own acceptance tests before any full-repair claim. +The runner builds the actual `llama-common` and `llama` CPU libraries and runs +all three CTest executables. It requires no model, inference, GPU or gRPC server. +Rebuild dependent objects after changes to the native tool struct. The +harness names deliberately do not match the standard-library-only backend +runner's `*_test.cpp` discovery pattern. + +`native-strict` checks strict true/false/absent round trips and legacy +three-field aggregate initialization. `native-schema` uses the committed +`fixtures/sound-schema.json`, derived from LocalAI's Sound tool definition, +and exercises generated grammar recognition, complete PEG parsing and mapped +tool calls separately. This fixture is covered by the repository's MIT +license. It includes all 504 existing keyscale values. + +The baseline accepts 30 invalid cases in the original 44-case matrix. The +expanded patched matrix currently has 1,092 cases, including all enum values +under both parallel settings. `native-edge` adds 639 checks for semantic edges, +required/auto/none modes, strict true/false/absent and bytewise final truncation, +plus six JSON-comparison controls. String repetitions use bounded chunks because +the native GBNF reader treats repetition bounds above 2,000 as unbounded. +These are offline parser checks, not tokenized sampling or model benchmarks. + +## Maintenance and remaining integration + +Keep the native patch tied to the exact pin above. Rebase and rerun transport, +grammar and compatibility checks whenever that pin changes. Do not assume +an upstream update fixes this behavior without running the actual LFM path. +Production patch preparation, full schema compatibility review, additional +regressions, performance comparison, duplicate-key defense and CI integration +are separate acceptance steps. Do not treat this harness alone as a deployed +or complete repair. No inference-throughput improvement is claimed. diff --git a/backend/cpp/llama-cpp/tests/fixtures/sound-schema.json b/backend/cpp/llama-cpp/tests/fixtures/sound-schema.json new file mode 100644 index 000000000000..f4063ccb7233 --- /dev/null +++ b/backend/cpp/llama-cpp/tests/fixtures/sound-schema.json @@ -0,0 +1,548 @@ +{ + "additionalProperties": false, + "properties": { + "bpm": { + "maximum": 400, + "minimum": 1, + "type": "integer" + }, + "caption": { + "maxLength": 500, + "minLength": 1, + "type": "string" + }, + "duration_seconds": { + "maximum": 600, + "minimum": 1, + "type": "number" + }, + "keyscale": { + "description": "Optional musical key and mode. Omit when unspecified; never use an empty string or null.", + "enum": [ + "A", + "A major", + "A minor", + "A maj", + "A min", + "A dorian", + "A phrygian", + "A lydian", + "A mixolydian", + "A locrian", + "A ionian", + "A aeolian", + "A#", + "A# major", + "A# minor", + "A# maj", + "A# min", + "A# dorian", + "A# phrygian", + "A# lydian", + "A# mixolydian", + "A# locrian", + "A# ionian", + "A# aeolian", + "Ab", + "Ab major", + "Ab minor", + "Ab maj", + "Ab min", + "Ab dorian", + "Ab phrygian", + "Ab lydian", + "Ab mixolydian", + "Ab locrian", + "Ab ionian", + "Ab aeolian", + "B", + "B major", + "B minor", + "B maj", + "B min", + "B dorian", + "B phrygian", + "B lydian", + "B mixolydian", + "B locrian", + "B ionian", + "B aeolian", + "B#", + "B# major", + "B# minor", + "B# maj", + "B# min", + "B# dorian", + "B# phrygian", + "B# lydian", + "B# mixolydian", + "B# locrian", + "B# ionian", + "B# aeolian", + "Bb", + "Bb major", + "Bb minor", + "Bb maj", + "Bb min", + "Bb dorian", + "Bb phrygian", + "Bb lydian", + "Bb mixolydian", + "Bb locrian", + "Bb ionian", + "Bb aeolian", + "C", + "C major", + "C minor", + "C maj", + "C min", + "C dorian", + "C phrygian", + "C lydian", + "C mixolydian", + "C locrian", + "C ionian", + "C aeolian", + "C#", + "C# major", + "C# minor", + "C# maj", + "C# min", + "C# dorian", + "C# phrygian", + "C# lydian", + "C# mixolydian", + "C# locrian", + "C# ionian", + "C# aeolian", + "Cb", + "Cb major", + "Cb minor", + "Cb maj", + "Cb min", + "Cb dorian", + "Cb phrygian", + "Cb lydian", + "Cb mixolydian", + "Cb locrian", + "Cb ionian", + "Cb aeolian", + "D", + "D major", + "D minor", + "D maj", + "D min", + "D dorian", + "D phrygian", + "D lydian", + "D mixolydian", + "D locrian", + "D ionian", + "D aeolian", + "D#", + "D# major", + "D# minor", + "D# maj", + "D# min", + "D# dorian", + "D# phrygian", + "D# lydian", + "D# mixolydian", + "D# locrian", + "D# ionian", + "D# aeolian", + "Db", + "Db major", + "Db minor", + "Db maj", + "Db min", + "Db dorian", + "Db phrygian", + "Db lydian", + "Db mixolydian", + "Db locrian", + "Db ionian", + "Db aeolian", + "E", + "E major", + "E minor", + "E maj", + "E min", + "E dorian", + "E phrygian", + "E lydian", + "E mixolydian", + "E locrian", + "E ionian", + "E aeolian", + "E#", + "E# major", + "E# minor", + "E# maj", + "E# min", + "E# dorian", + "E# phrygian", + "E# lydian", + "E# mixolydian", + "E# locrian", + "E# ionian", + "E# aeolian", + "Eb", + "Eb major", + "Eb minor", + "Eb maj", + "Eb min", + "Eb dorian", + "Eb phrygian", + "Eb lydian", + "Eb mixolydian", + "Eb locrian", + "Eb ionian", + "Eb aeolian", + "F", + "F major", + "F minor", + "F maj", + "F min", + "F dorian", + "F phrygian", + "F lydian", + "F mixolydian", + "F locrian", + "F ionian", + "F aeolian", + "F#", + "F# major", + "F# minor", + "F# maj", + "F# min", + "F# dorian", + "F# phrygian", + "F# lydian", + "F# mixolydian", + "F# locrian", + "F# ionian", + "F# aeolian", + "Fb", + "Fb major", + "Fb minor", + "Fb maj", + "Fb min", + "Fb dorian", + "Fb phrygian", + "Fb lydian", + "Fb mixolydian", + "Fb locrian", + "Fb ionian", + "Fb aeolian", + "G", + "G major", + "G minor", + "G maj", + "G min", + "G dorian", + "G phrygian", + "G lydian", + "G mixolydian", + "G locrian", + "G ionian", + "G aeolian", + "G#", + "G# major", + "G# minor", + "G# maj", + "G# min", + "G# dorian", + "G# phrygian", + "G# lydian", + "G# mixolydian", + "G# locrian", + "G# ionian", + "G# aeolian", + "Gb", + "Gb major", + "Gb minor", + "Gb maj", + "Gb min", + "Gb dorian", + "Gb phrygian", + "Gb lydian", + "Gb mixolydian", + "Gb locrian", + "Gb ionian", + "Gb aeolian", + "a", + "a major", + "a minor", + "a maj", + "a min", + "a dorian", + "a phrygian", + "a lydian", + "a mixolydian", + "a locrian", + "a ionian", + "a aeolian", + "a#", + "a# major", + "a# minor", + "a# maj", + "a# min", + "a# dorian", + "a# phrygian", + "a# lydian", + "a# mixolydian", + "a# locrian", + "a# ionian", + "a# aeolian", + "ab", + "ab major", + "ab minor", + "ab maj", + "ab min", + "ab dorian", + "ab phrygian", + "ab lydian", + "ab mixolydian", + "ab locrian", + "ab ionian", + "ab aeolian", + "b", + "b major", + "b minor", + "b maj", + "b min", + "b dorian", + "b phrygian", + "b lydian", + "b mixolydian", + "b locrian", + "b ionian", + "b aeolian", + "b#", + "b# major", + "b# minor", + "b# maj", + "b# min", + "b# dorian", + "b# phrygian", + "b# lydian", + "b# mixolydian", + "b# locrian", + "b# ionian", + "b# aeolian", + "bb", + "bb major", + "bb minor", + "bb maj", + "bb min", + "bb dorian", + "bb phrygian", + "bb lydian", + "bb mixolydian", + "bb locrian", + "bb ionian", + "bb aeolian", + "c", + "c major", + "c minor", + "c maj", + "c min", + "c dorian", + "c phrygian", + "c lydian", + "c mixolydian", + "c locrian", + "c ionian", + "c aeolian", + "c#", + "c# major", + "c# minor", + "c# maj", + "c# min", + "c# dorian", + "c# phrygian", + "c# lydian", + "c# mixolydian", + "c# locrian", + "c# ionian", + "c# aeolian", + "cb", + "cb major", + "cb minor", + "cb maj", + "cb min", + "cb dorian", + "cb phrygian", + "cb lydian", + "cb mixolydian", + "cb locrian", + "cb ionian", + "cb aeolian", + "d", + "d major", + "d minor", + "d maj", + "d min", + "d dorian", + "d phrygian", + "d lydian", + "d mixolydian", + "d locrian", + "d ionian", + "d aeolian", + "d#", + "d# major", + "d# minor", + "d# maj", + "d# min", + "d# dorian", + "d# phrygian", + "d# lydian", + "d# mixolydian", + "d# locrian", + "d# ionian", + "d# aeolian", + "db", + "db major", + "db minor", + "db maj", + "db min", + "db dorian", + "db phrygian", + "db lydian", + "db mixolydian", + "db locrian", + "db ionian", + "db aeolian", + "e", + "e major", + "e minor", + "e maj", + "e min", + "e dorian", + "e phrygian", + "e lydian", + "e mixolydian", + "e locrian", + "e ionian", + "e aeolian", + "e#", + "e# major", + "e# minor", + "e# maj", + "e# min", + "e# dorian", + "e# phrygian", + "e# lydian", + "e# mixolydian", + "e# locrian", + "e# ionian", + "e# aeolian", + "eb", + "eb major", + "eb minor", + "eb maj", + "eb min", + "eb dorian", + "eb phrygian", + "eb lydian", + "eb mixolydian", + "eb locrian", + "eb ionian", + "eb aeolian", + "f", + "f major", + "f minor", + "f maj", + "f min", + "f dorian", + "f phrygian", + "f lydian", + "f mixolydian", + "f locrian", + "f ionian", + "f aeolian", + "f#", + "f# major", + "f# minor", + "f# maj", + "f# min", + "f# dorian", + "f# phrygian", + "f# lydian", + "f# mixolydian", + "f# locrian", + "f# ionian", + "f# aeolian", + "fb", + "fb major", + "fb minor", + "fb maj", + "fb min", + "fb dorian", + "fb phrygian", + "fb lydian", + "fb mixolydian", + "fb locrian", + "fb ionian", + "fb aeolian", + "g", + "g major", + "g minor", + "g maj", + "g min", + "g dorian", + "g phrygian", + "g lydian", + "g mixolydian", + "g locrian", + "g ionian", + "g aeolian", + "g#", + "g# major", + "g# minor", + "g# maj", + "g# min", + "g# dorian", + "g# phrygian", + "g# lydian", + "g# mixolydian", + "g# locrian", + "g# ionian", + "g# aeolian", + "gb", + "gb major", + "gb minor", + "gb maj", + "gb min", + "gb dorian", + "gb phrygian", + "gb lydian", + "gb mixolydian", + "gb locrian", + "gb ionian", + "gb aeolian" + ], + "maxLength": 32, + "type": "string" + }, + "language": { + "maxLength": 32, + "type": "string" + }, + "lyrics": { + "maxLength": 4000, + "type": "string" + }, + "timesignature": { + "pattern": "^(0?[1-9]|[12][0-9]|3[0-2])/(0?[1-9]|[12][0-9]|3[0-2])$", + "type": "string" + } + }, + "required": [ + "caption", + "lyrics" + ], + "type": "object" +} diff --git a/backend/cpp/llama-cpp/tests/native-edge.cpp b/backend/cpp/llama-cpp/tests/native-edge.cpp new file mode 100644 index 000000000000..525befc9a3eb --- /dev/null +++ b/backend/cpp/llama-cpp/tests/native-edge.cpp @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: MIT +#include "chat.h" +#include "native-json-test.h" +#include "llama-grammar.h" +#include +#include +#include +#include +#include +#include +std::string read(const char *p){std::ifstream f(p); if(!f)throw std::runtime_error(p);std::stringstream s;s<=0xf0){c=lead&7;n=3;}else if(lead>=0xe0){c=lead&15;n=2;}else if(lead>=0xc0){c=lead&31;n=1;} + while(n--){if(i>=s.size())throw std::runtime_error("invalid UTF-8 fixture");c=(c<<6)|(s[i++]&63);} + llama_grammar_accept(p,c);if(llama_grammar_get_stacks(p).empty()){ok=false;break;}} + bool complete=false;if(ok)for(auto &stack:llama_grammar_get_stacks(p))if(stack.empty())complete=true; + llama_grammar_free_impl(p);return ok&&complete; +} +struct Fixture {std::string name,args;bool valid;}; + +int main(){ int failures=0; int checks=0; +auto comparison_check=[&](const char *name,const char *actual,const char *expected,bool equal){ + bool ok=semantic_equals(common_json::parse(actual),common_json::parse(expected))==equal; + std::cout<<"comparison_"<"; +common_peg_parse_context ctx(p.generation_prompt+text);auto r=p.parser.parse(ctx); bool complete=!r.fail()&&r.end==p.generation_prompt.size()+text.size(); +bool recognized=match(p.grammar,p.generation_prompt+text); +bool mapped=false; size_t published=0; +try { common_chat_parser_params pp(p); auto msg=common_chat_peg_parse(p.parser,common_chat_input(text),false,pp); published=msg.tool_calls.size(); + mapped=msg.tool_calls.size()==1 && msg.tool_calls[0].name=="f" && semantic_equals(common_json::parse(msg.tool_calls[0].arguments),expected); +} catch(const std::exception &) {} +bool ok=!schema_error && recognized==grammar && complete==parser && mapped==parser && (parser || published==0); +std::cout<=0)fn["strict"]=strict_flag==1; + in.tools=common_chat_tools_parse_oaicompat(common_json::array({{{"type","function"},{"function",fn}}})); + auto p=common_chat_templates_apply(templates.get(),in); + const bool none=mode==COMMON_CHAT_TOOL_CHOICE_NONE, automatic=mode==COMMON_CHAT_TOOL_CHOICE_AUTO; + auto run_final=[&](const std::string &name,const std::string &text,bool grammar_expected,bool peg_expected,common_json expected,const std::string &content_expected="") { + ++checks; + // No grammar in none mode; record that as no recognition, not an accept. + bool recognized=!p.grammar.empty() && match(p.grammar,(automatic?"":p.generation_prompt)+text); + common_peg_parse_context ctx(p.generation_prompt+text);auto r=p.parser.parse(ctx); + bool complete=!r.fail() && r.end==p.generation_prompt.size()+text.size(); + common_json calls=common_json::array();std::string content,error; + try {common_chat_parser_params pp(p);auto msg=common_chat_peg_parse(p.parser,common_chat_input(text),false,pp); + content=msg.content;for(auto &c:msg.tool_calls)calls.push_back({{"name",c.name},{"arguments",common_json::parse(c.arguments)}}); + }catch(const std::exception &e){error=e.what();} + bool ok=recognized==grammar_expected && complete==peg_expected && semantic_equals(calls,expected) && (content_expected.empty() || content==content_expected); + std::cout<<"FINAL mode="< +#include +#include +#include +#include +#include +std::string read(const char *p){std::ifstream f(p); if(!f)throw std::runtime_error(p);std::stringstream s;s<=0xf0){c=lead&7;n=3;}else if(lead>=0xe0){c=lead&15;n=2;}else if(lead>=0xc0){c=lead&31;n=1;} + while(n--){if(i>=s.size())throw std::runtime_error("invalid UTF-8 fixture");c=(c<<6)|(s[i++]&63);} + llama_grammar_accept(p,c);if(llama_grammar_get_stacks(p).empty()){ok=false;break;}} + bool complete=false;if(ok)for(auto &stack:llama_grammar_get_stacks(p))if(stack.empty())complete=true; + llama_grammar_free_impl(p);return ok&&complete; +} +struct Fixture { + std::string name,args; + bool grammar_expected,parser_expected; + common_json expected_json; +}; +int main(int argc,char **argv){try{ + if(argc!=3)throw std::runtime_error("usage: native-schema schema.json output-dir"); + std::filesystem::create_directories(argv[2]); + auto schema=common_json::parse(read(argv[1])); + if(schema["properties"]["keyscale"]["enum"].size()!=504 || schema["required"]!=common_json::array({"caption","lyrics"}))throw std::runtime_error("schema invariant failed"); + // Deliberately synthetic; only public format markers, never an installed/private prompt. + std::string tmpl="List of tools: [\n{% for tool in tools %}{{ tool | tojson }}\n{% endfor %}]\n{% for message in messages %}<|im_start|>{{ message.role }}\n{{ message.content }}<|im_end|>\n{% endfor %}{% if add_generation_prompt %}<|im_start|>assistant\n{% endif %}"; + std::ofstream(std::string(argv[2])+"/synthetic-template.jinja")< fixtures={ + {"valid","caption='piano', lyrics=''",true,true,{{"caption","piano"},{"lyrics",""}}}, + {"caption_only","caption='piano'",false,false,common_json()}, + {"lyrics_only","lyrics=''",false,false,common_json()}, + {"bpm_only","bpm=120",false,false,common_json()}, + {"empty_arguments","",false,false,common_json()}, + {"valid_enum","caption='piano', keyscale='C major', lyrics=''",true,true,{{"caption","piano"},{"keyscale","C major"},{"lyrics",""}}}, + {"invalid_enum","caption='piano', keyscale='H major', lyrics=''",false,false,common_json()}, + {"caption_wrong_type","caption=123, lyrics=''",false,false,common_json()}, + {"lyrics_wrong_type","caption='piano', lyrics=null",false,false,common_json()}, + {"bpm_wrong_type","bpm='fast', caption='piano', lyrics=''",false,false,common_json()}, + {"bpm_boolean","bpm=true, caption='piano', lyrics=''",false,false,common_json()}, + {"duplicate_caption","caption='first', caption='second', lyrics=''",false,false,common_json()}, + {"unknown_arg","caption='piano', lyrics='', unknown=1",false,false,common_json()}, + {"bpm_lower","bpm=0, caption='piano', lyrics=''",false,false,common_json()}, + {"bpm_upper","bpm=401, caption='piano', lyrics=''",false,false,common_json()}, + {"bpm_fraction","bpm=120.5, caption='piano', lyrics=''",false,false,common_json()}, + {"duration_upper","caption='piano', duration_seconds=601, lyrics=''",false,false,common_json()}, + {"caption_empty","caption='', lyrics=''",false,false,common_json()}, + {"caption_too_long","caption='"+std::string(501,'x')+"', lyrics=''",false,false,common_json()}, + {"lyrics_too_long","caption='piano', lyrics='"+std::string(4001,'x')+"'",false,false,common_json()}, + {"valid_bpm_lower","bpm=1, caption='piano', lyrics=''",true,true,{{"bpm",1},{"caption","piano"},{"lyrics",""}}}, + {"valid_bpm_upper","bpm=400, caption='piano', lyrics=''",true,true,{{"bpm",400},{"caption","piano"},{"lyrics",""}}}, + {"valid_duration_fraction","caption='piano', duration_seconds=1.25, lyrics=''",true,true,{{"caption","piano"},{"duration_seconds",1.25},{"lyrics",""}}}, + {"valid_duration_upper","caption='piano', duration_seconds=600.00, lyrics=''",true,true,{{"caption","piano"},{"duration_seconds",600},{"lyrics",""}}}, + {"duration_upper_fraction","caption='piano', duration_seconds=600.01, lyrics=''",false,false,common_json()}, + {"duration_lower","caption='piano', duration_seconds=0.99, lyrics=''",false,false,common_json()}, + {"duration_exponent","caption='piano', duration_seconds=1e3, lyrics=''",false,false,common_json()}, + {"leading_zero_integer","bpm=0120, caption='piano', lyrics=''",false,false,common_json()}, + {"valid_meter","caption='piano', lyrics='', timesignature='04/32'",true,true,{{"caption","piano"},{"lyrics",""},{"timesignature","04/32"}}}, + {"invalid_meter_zero","caption='piano', lyrics='', timesignature='0/4'",false,false,common_json()}, + {"invalid_meter_high","caption='piano', lyrics='', timesignature='33/4'",false,false,common_json()}, + {"valid_caption_bound","caption='"+std::string(500,'x')+"', lyrics=''",true,true,{{"caption",std::string(500,'x')},{"lyrics",""}}}, + {"valid_lyrics_bound","caption='piano', lyrics='"+std::string(4000,'x')+"'",true,true,{{"caption","piano"},{"lyrics",std::string(4000,'x')}}}, + {"valid_unicode","caption='音楽🎵', lyrics=''",true,true,{{"caption","音楽🎵"},{"lyrics",""}}}, + {"valid_escapes","caption='a\\n\\u0041\\uD83C\\uDFB5', lyrics=''",true,true,{{"caption","a\nA🎵"},{"lyrics",""}}}, + {"valid_double_quotes","caption=\"piano\", lyrics=\"\"",true,true,{{"caption","piano"},{"lyrics",""}}}, + {"reordered_arguments","lyrics='', caption='piano'",false,true,{{"caption","piano"},{"lyrics",""}}}, + {"valid_escaped_enum","caption='piano', keyscale='\\u0043 major', lyrics=''",false,true,{{"caption","piano"},{"keyscale","C major"},{"lyrics",""}}}, + {"valid_duration_exponent","caption='piano', duration_seconds=1e2, lyrics=''",false,true,{{"caption","piano"},{"duration_seconds",100},{"lyrics",""}}}, + {"valid_duration_exponent_upper","caption='piano', duration_seconds=6e2, lyrics=''",false,true,{{"caption","piano"},{"duration_seconds",600},{"lyrics",""}}}, + {"pattern_invalid","caption='piano', lyrics='', timesignature='nonsense'",false,false,common_json()} + }; + std::ofstream matrix(std::string(argv[2])+"/matrix.jsonl");int reds=0,rows=0; + for(bool parallel:{false,true}){ + common_chat_templates_inputs inputs;common_chat_msg user;user.role="user";user.content="Synthetic offline formatter test.";inputs.messages.push_back(user); + inputs.tools = common_chat_tools_parse_oaicompat(common_json::array({{{"type", "function"}, {"function", {{"name", "expand_sound"}, {"parameters", schema}, {"strict", true}}}}}));inputs.parallel_tool_calls=parallel;inputs.tool_choice=COMMON_CHAT_TOOL_CHOICE_REQUIRED; + auto begin=std::chrono::steady_clock::now(); + auto p=common_chat_templates_apply(templates.get(),inputs); + std::cout << "COST grammar_bytes=" << p.grammar.size() << " construction_us=" << std::chrono::duration_cast(std::chrono::steady_clock::now()-begin).count() << "\n"; + if(p.format!=COMMON_CHAT_FORMAT_PEG_NATIVE || p.grammar.empty() || p.grammar_lazy)throw std::runtime_error("specialized formatter not selected"); + for(auto &token:p.preserved_tokens)if(token=="<|tool_list_start|>")throw std::runtime_error("selected LFM2 instead of LFM2.5"); + std::string suffix=parallel?"true":"false"; + std::ofstream(std::string(argv[2])+"/grammar-parallel-"+suffix+".gbnf")<[expand_sound(caption='piano', lyrics='')]<|tool_call_end|>"))throw std::runtime_error("native recognizer positive control failed"); + if(match(p.grammar,p.generation_prompt+"<|tool_call_start|>[expand_sound(caption='piano', lyrics='')]<|tool_call_end|"))throw std::runtime_error("native recognizer truncated EOF control failed"); + auto run=[&](std::string name,std::string body,bool grammar_expected,bool parser_expected,common_json expected_json){ + std::string text="<|tool_call_start|>["+body+"]<|tool_call_end|>"; + // The generated root includes the assistant generation prefix; match from root, not sampler continuation. + auto recognition_begin=std::chrono::steady_clock::now(); + bool recognized=match(p.grammar,p.generation_prompt+text); + auto recognition_us=std::chrono::duration_cast(std::chrono::steady_clock::now()-recognition_begin).count();bool parsed=false,strict=false;size_t count=0;std::string error;common_json calls=common_json::array(); + common_peg_parse_context ctx(p.generation_prompt+text);auto result=p.parser.parse(ctx);strict=!result.fail()&&result.end==p.generation_prompt.size()+text.size(); + try{common_chat_parser_params pp(p);auto msg=common_chat_peg_parse(p.parser,common_chat_input(text),false,pp);count=msg.tool_calls.size();parsed=count>0;for(auto &c:msg.tool_calls)calls.push_back({{"name",c.name},{"arguments",common_json::parse(c.arguments)}});}catch(const std::exception &e){error=e.what();} + bool exact_mapping=semantic_equals(calls,expected_json); + bool red=recognized!=grammar_expected || strict!=parser_expected || !exact_mapping || (!parser_expected && count!=0);if(red)reds++;rows++; + common_json row={{"parallel",parallel},{"fixture",name},{"grammar_expected",grammar_expected},{"parser_expected",parser_expected},{"expected_json",expected_json},{"exact_mapping",exact_mapping},{"recognition_us",recognition_us},{"grammar_accept",recognized},{"strict_peg_complete",strict},{"api_peg_tools",parsed},{"tool_count",count},{"calls",calls},{"error",error},{"red",red},{"text",text}}; + matrix<(),"expand_sound(caption='piano', keyscale="+v.dump()+", lyrics='')",true,true,expected_calls({{"caption","piano"},{"keyscale",v},{"lyrics",""}})); + } + for(auto &f:fixtures)run(f.name,"expand_sound("+f.args+")",f.grammar_expected,f.parser_expected,f.parser_expected?expected_calls(f.expected_json):common_json::array()); + run("two_calls","expand_sound(caption='piano', lyrics=''), expand_sound(caption='drums', lyrics='')",parallel,parallel,parallel?common_json::array({{{"name","expand_sound"},{"arguments",{{"caption","piano"},{"lyrics",""}}}},{{"name","expand_sound"},{"arguments",{{"caption","drums"},{"lyrics",""}}}}}):common_json::array()); + } + std::cout<<"TOTAL rows="<