feat(ext): let text providers register native tool dialects (#1013)

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Francesco MocaandClaude Opus 5.5 authored and GitHub committed 2026-11-05 14:58:13 +01:00
1 parent d02a1d2d5f
commit 2c97b72d86
18 files changed
+2133 -405

No files matched your search

+31
View File
@@ -212,6 +212,37 @@ start at a leading `open`. The budget counts from `start`, so the rest of `open`
counts toward it. Line breaks after `close` are framing and dropped, and Qwen's
tool dialect also drops other whitespace there.
### Native tool calls
A text model whose call syntax is not a built-in format registers a
`ToolDialect` from its extension registration and returns its `id` from
`TextModelRunner::ToolDialectId()`; no core enum entry or host branch is
needed. That one identity selects both the constrained grammar and the
buffered and streamed parsers of the Chat Completions, Responses and Messages
endpoints, and only its markers start calls. The registration supplies:
- `syntax`, a `JsonConstraint::ToolSyntax`: the call literals (`open`,
`name_prefix`, `name_suffix`, `close`) and either `tagged` key/value
parameters (the form of the built-in Qwen and DeepSeek grammars, which
`JsonConstraint::Syntax()` returns for copying) or JSON-shaped arguments
spelled by `values` (string delimiters, bare keys, whitespace, member order).
Core compiles each tool's JSON schema into it for `required` and `auto` tool
choice, strict and best-effort tools.
- `markers`, `reasoning_markers` and `closers` for the host's text handling,
and `scan_tool_calls`, which reads calls back, including the unfinished call
of a streamed partial parse. Values that are not JSON are converted to JSON
text, which the host checks against the declared schema.
The constrained reasoning phase before an answer or call uses the runner's
`ReasoningDelimiters()`, as the response parsers do: it ends at `close` or at a
complete call header, and for `InitialOutputState::kAuto` the model may open it
with the complete `open` literal.
The contract, ownership and error rules are documented on `ToolDialect` in
[`text.hpp`](../include/gufo/ext/text.hpp). Existing runners need no change:
the default `ToolDialectId()` is the built-in identity (`json`, `qwen`,
`deepseek`) of `ToolFormat()`.
Provider options use canonical names and manifest types. CMake checks conflicting
declarations. The Nix serving helper derives provider arguments and validation
from metadata, including optional `serving` declarations. New runtime controls
@@ -0,0 +1,45 @@
# Provider-defined native tool dialects
Text (`src/ext/text.hpp`, `src/core/json_constraint.hpp`): a provider registers
a native tool dialect, its grammar and its parser by identity, without a core
format enum entry.
- `ToolDialect` gains `id` and `syntax`. `id` is the selection key; an empty
one is `BuiltinToolDialectId(format)`, so existing registrations keep their
identity. `syntax` (`JsonConstraint::ToolSyntax`) describes the call grammar;
without it the built-in grammar of `format` applies.
- `TextModelRunner::ToolDialectId()` selects the dialect; its default is the
built-in identity of `ToolFormat()` (`json`, `qwen`, `deepseek`).
`BuiltinToolDialectId()` names those identities and `FindToolDialect(id)`
finds a registration. The host rejects a runner whose identity is neither
built in nor registered at load.
- `JsonConstraint::ToolSyntax`, `TaggedArguments` and `ValueSyntax` describe a
call envelope with either tagged key/value parameters or JSON-shaped
arguments in another value spelling. `JsonConstraint::Syntax(format)` returns
the descriptors of the built-in Qwen and DeepSeek grammars, which are now
compiled from them unchanged. New overloads `ToolParameters(schema, strict,
syntax)`, `OpenToolParameters(syntax)` and `WithTools(..., syntax)` compile
them.
- The host now uses a registered dialect's `markers`, `reasoning_markers` and
`closers`, and selects its scanner by identity; only the selected dialect's
markers start calls. `closers` are removed when echoed directly after an
accepted call; the DeepSeek registration now declares none, as the host
already behaved.
- `RegisterToolDialect` validates its input (std::invalid_argument): `json`
and `qwen` are built into the host, and a dialect needs markers containing
`<`, a scanner, and a syntax opened by one of its markers or a native
`format`. Registered dialects keep stable addresses for the process.
- Constrained reasoning uses the runner's `ReasoningDelimiters()`:
`JsonConstraint::WithReasoning(answer, close, open)` ends the reasoning phase
at `close` (or a complete call header) instead of `</think>`. Prompt-opened
reasoning (`kThought`) uses `close` alone; for `kAuto`, unless the request
disables reasoning, the output may start with the complete `open` (which may
span tokens and include whitespace) and reason, or answer directly. The
defaults (`</think>`, already open) build the previous grammar.
- With `partial`, `scan_tool_calls` appends only an unfinished call that later
text can still complete, and its arguments only grow.
Additive for existing callers, except that constrained output of a `kAuto`
runner with a non-empty opener now admits a leading reasoning block, as its
response parsers already did; a registration that the new validation rejects
must declare a scanner, `<` markers and a grammar.
+14 -4
View File
@@ -96,10 +96,12 @@ struct InferenceBackend::Impl {
std::shared_ptr<const State> model_state,
TextGenerationScheduler::Request scheduled_request,
InitialOutputState initial = InitialOutputState::kContent,
std::optional<sampling::JsonConstraint::ToolFormat> tool_format = {})
std::optional<sampling::JsonConstraint::ToolFormat> tool_format = {},
std::string tool_dialect = {})
: state_(std::move(model_state)),
request_(std::move(scheduled_request)),
tool_format_(tool_format),
tool_dialect_(std::move(tool_dialect)),
initial_(initial),
reasoning_(state_->scheduler->runner().ReasoningDelimiters()) {}
@@ -127,10 +129,15 @@ struct InferenceBackend::Impl {
return tool_format_;
}
std::optional<std::string> ToolDialectId() const override {
return tool_dialect_;
}
private:
std::shared_ptr<const State> state_;
TextGenerationScheduler::Request request_;
const std::optional<sampling::JsonConstraint::ToolFormat> tool_format_;
const std::string tool_dialect_;
const InitialOutputState initial_;
const ext::ReasoningMarkup reasoning_;
};
@@ -630,8 +637,10 @@ InferenceBackend::start_chat(const ChatRequest& request, std::size_t max_tokens,
auto effective_sampling = sampling_config;
std::optional<sampling::JsonConstraint::ToolFormat> tool_format;
auto constrained = ConstrainChatRequest(request, state->scheduler->runner(),
&effective_sampling, &tool_format);
std::string tool_dialect;
auto constrained =
ConstrainChatRequest(request, state->scheduler->runner(),
&effective_sampling, &tool_format, &tool_dialect);
const auto& effective_request = constrained ? *constrained : request;
auto prompt = state->scheduler->runner().PreparePrompt(effective_request);
if (!prompt.has_value() || prompt->tokens.empty()) {
@@ -663,7 +672,8 @@ InferenceBackend::start_chat(const ChatRequest& request, std::size_t max_tokens,
.return_progress = request.return_progress,
});
return std::make_shared<Impl::ScheduledGenerationRequest>(
state, std::move(scheduled_request), initial, tool_format);
state, std::move(scheduled_request), initial, tool_format,
std::move(tool_dialect));
#else
return TextGenerationBackend::start_chat(request, max_tokens, sampling_config,
is_cancelled, stream_output);
+135 -126
View File
@@ -130,43 +130,7 @@ constexpr std::array<std::string_view, 7> kToolMarkers{
};
using ReasoningMarkup = ext::ReasoningMarkup;
using ToolMarkerSet = std::span<const std::string_view>;
ToolMarkerSet ToolMarkers(
std::optional<sampling::JsonConstraint::ToolFormat> format) {
using Format = sampling::JsonConstraint::ToolFormat;
static constexpr std::array<std::string_view, 1> qwen{
tokenization::QwenToolSyntax::kCallOpen};
// The template writes "\n\n" before the call block. As in llama.cpp
// common/parsers/deepseek.cpp (TC_SEPARATOR + FC_START), that separator is
// framing: returning it as content would double it when history is replayed.
static constexpr std::array<std::string_view, 2> deepseek{
"\n\n<|DSML|tool_calls>", "<|DSML|tool_calls>"};
// The JSON fallback has no native counterpart in llama.cpp. Its grammar
// leaves text before <tool_call> unconstrained, where models still write
// their native call syntax; keep recognizing every opener there.
if (!format || *format == Format::kJson)
return kToolMarkers;
// Match the canonical opener selected by JsonConstraint::WithTools. Other
// dialects are ordinary prose under that grammar, not additional calls.
return *format == Format::kDeepSeek ? ToolMarkerSet(deepseek)
: ToolMarkerSet(qwen);
}
// A native function header, rather than a bare marker mentioned in prose,
// permits the implicit reasoning boundary used by llama.cpp's native parsers.
ToolMarkerSet ReasoningToolMarkers(ToolMarkerSet markers) {
static constexpr std::array<std::string_view, 1> qwen{
tokenization::QwenToolSyntax::kNativeCallOpen};
static constexpr std::array<std::string_view, 2> deepseek{
"\n\n<|DSML|tool_calls>\n<|DSML|invoke name=\"",
"<|DSML|tool_calls>\n<|DSML|invoke name=\""};
if (markers.size() == 1 &&
markers.front() == tokenization::QwenToolSyntax::kCallOpen)
return qwen;
if (markers.size() == 2 && markers.back() == "<|DSML|tool_calls>")
return deepseek;
return {};
}
using ToolCloserSet = std::span<const std::string_view>;
// Closers eligible for removal immediately after an accepted call. Standalone
// closers and XML remain literal; the declared-tool envelope rule is separate.
@@ -192,23 +156,72 @@ constexpr std::array<std::string_view, 9> kToolClosers{
"</|DSML|invoke>",
"</|DSML|tool_calls>",
};
using ToolCloserSet = std::span<const std::string_view>;
ToolCloserSet ToolClosers(
std::optional<sampling::JsonConstraint::ToolFormat> format) {
// The call markup of one admitted request's tool dialect.
struct ToolMarkup {
// Openers that start a call; nothing else does.
ToolMarkerSet markers;
// Complete native call headers which end a constrained reasoning phase.
ToolMarkerSet reasoning_markers;
ToolCloserSet closers;
// The registered parser of calls the host does not parse itself.
const ext::ToolDialect* dialect{nullptr};
// The host parses calls opened by the Qwen marker.
bool qwen_calls{false};
// A native grammar frames arguments before a value completes, so partial
// calls stream. Recovery from JSON fallback text needs a full call.
bool native{false};
// Whitespace after an explicit end of reasoning that is framing.
std::string_view reasoning_separator{"\r\n"};
};
// The markup of the dialect the request admitted. Other dialects' markers and
// closers are ordinary prose under its grammar, not additional calls.
ToolMarkup SelectToolMarkup(const std::optional<std::string>& id) {
using Format = sampling::JsonConstraint::ToolFormat;
if (!format)
return kToolClosers;
// As in llama.cpp, DeepSeek content is everything outside its native call
// block, which ends the output: there is no echo to strip, and client
// envelope syntax the model writes stays visible text.
if (*format == Format::kDeepSeek)
// The JSON fallback has no native counterpart in llama.cpp. Its grammar
// leaves text before <tool_call> unconstrained, where models still write
// their native call syntax; keep recognizing every opener there.
if (!id || *id == ext::BuiltinToolDialectId(Format::kJson))
return {
.markers = kToolMarkers,
// Mirror the markers: stripping follows the dialect the request
// admitted, the client's envelope excepted, which no dialect admits
// and every one of them parses.
.closers =
id ? ToolCloserSet(kQwenClosers) : ToolCloserSet(kToolClosers),
.dialect = ext::FindToolDialect(Format::kDeepSeek),
.qwen_calls = true,
};
if (*id == ext::BuiltinToolDialectId(Format::kQwen)) {
static constexpr std::array<std::string_view, 1> qwen{
tokenization::QwenToolSyntax::kCallOpen};
// A native function header, rather than a bare marker mentioned in prose,
// permits the implicit reasoning boundary used by llama.cpp's native
// parsers.
static constexpr std::array<std::string_view, 1> header{
tokenization::QwenToolSyntax::kNativeCallOpen};
return {
.markers = qwen,
.reasoning_markers = header,
.closers = kQwenClosers,
.qwen_calls = true,
.native = true,
// Qwen's llama.cpp PEG uses `reasoning << content`: `<<` consumes
// space().
.reasoning_separator = " \t\r\n\f\v",
};
}
const auto* dialect = ext::FindToolDialect(*id);
if (dialect == nullptr)
return {};
// Mirror ToolMarkers: stripping follows the dialect the request admitted,
// and another dialect's closing tags are ordinary prose like its openers —
// the client's envelope excepted, which no dialect admits and every one of
// them parses.
return kQwenClosers;
return {
.markers = dialect->markers,
.reasoning_markers = dialect->reasoning_markers,
.closers = dialect->closers,
.dialect = dialect,
.native = true,
};
}
long long Now() {
@@ -1074,14 +1087,10 @@ std::string_view Trim(std::string_view value) {
}
std::string_view AfterReasoningSeparator(std::string_view value,
ToolMarkerSet markers) {
// Qwen's llama.cpp PEG uses `reasoning << content`: `<<` consumes space().
// Keep other dialects' existing line-break rule. Only the boundary after an
// explicit reasoning close is framing; answer and argument interiors are
// untouched.
const bool qwen = markers.size() == 1 &&
markers.front() == tokenization::QwenToolSyntax::kCallOpen;
const auto first = value.find_first_not_of(qwen ? " \t\r\n\f\v" : "\r\n");
const ToolMarkup& markup) {
// Only the boundary after an explicit reasoning close is framing; answer and
// argument interiors are untouched.
const auto first = value.find_first_not_of(markup.reasoning_separator);
return first == std::string_view::npos ? std::string_view{}
: value.substr(first);
}
@@ -1757,24 +1766,25 @@ std::vector<tokenization::ChatMessage::ToolArgument> DeepSeekArguments(
return converted;
}
void ParseDsmlCalls(
std::string_view text, ToolMarkerSet markers,
// Calls of a registered dialect, read by its scanner and checked against the
// declared schemas.
void ParseScannedCalls(
std::string_view text, const ToolMarkup& markup,
std::span<const tokenization::ChatTool> tools,
std::vector<ParsedToolCall>* calls,
std::vector<std::pair<std::size_t, std::size_t>>* spans = nullptr,
bool partial = false) {
const auto* dialect = markup.dialect;
if (dialect == nullptr || dialect->scan_tool_calls == nullptr)
return;
// The Qwen opener is not an envelope for this dialect even when the JSON
// fallback grammar admits both.
std::vector<std::string_view> dialect_markers;
dialect_markers.reserve(markers.size());
for (const auto marker : markers) {
if (marker != tokenization::QwenToolSyntax::kCallOpen)
dialect_markers.reserve(markup.markers.size());
for (const auto marker : markup.markers) {
if (!markup.qwen_calls || marker != tokenization::QwenToolSyntax::kCallOpen)
dialect_markers.push_back(marker);
}
const auto* dialect =
ext::FindToolDialect(sampling::JsonConstraint::ToolFormat::kDeepSeek);
if (dialect == nullptr || dialect->scan_tool_calls == nullptr)
return;
std::vector<ext::ScannedToolCall> scanned;
dialect->scan_tool_calls(text, dialect_markers, &scanned, spans, partial);
for (auto& invocation : scanned) {
@@ -1812,8 +1822,10 @@ ParsedGeneration ParseGeneration(
const ReasoningMarkup& reasoning,
std::span<const tokenization::ChatTool> tools,
ChatRequest::ToolChoice choice, bool enforce_required,
ToolMarkerSet markers, ToolCloserSet closers, QuoteTracker& quotes,
bool partial = false, bool track_text = false) {
const ToolMarkup& markup, QuoteTracker& quotes, bool partial = false,
bool track_text = false) {
const auto markers = markup.markers;
const auto closers = markup.closers;
quotes.Reset(raw);
ParsedGeneration parsed;
std::string_view content = raw;
@@ -1853,7 +1865,7 @@ ParsedGeneration ParseGeneration(
parsed.reasoning_content =
std::string(Trim(content.substr(0, think_end)));
content.remove_prefix(think_end + reasoning.close.size());
SetParsedText(parsed, AfterReasoningSeparator(content, markers), raw,
SetParsedText(parsed, AfterReasoningSeparator(content, markup), raw,
track_text);
}
} else if (initial_output_state ==
@@ -1880,7 +1892,7 @@ ParsedGeneration ParseGeneration(
think_content_start, think_end - think_content_start)));
std::string_view remaining =
content.substr(think_end + reasoning.close.size());
remaining = AfterReasoningSeparator(remaining, markers);
remaining = AfterReasoningSeparator(remaining, markup);
if (think_start > 0) {
SetParsedText(parsed, content.substr(0, think_start), raw,
track_text);
@@ -1927,12 +1939,13 @@ ParsedGeneration ParseGeneration(
// The outer envelope selects the format, as in llama.cpp's model parsers.
// Scanning both dialects would turn a literal call inside an argument into
// an additional API invocation.
if (text_from_tools.starts_with(tokenization::QwenToolSyntax::kCallOpen))
if (markup.qwen_calls &&
text_from_tools.starts_with(tokenization::QwenToolSyntax::kCallOpen))
ParseQwenCalls(parsed.text, tools, &parsed.tool_calls, nullptr,
unfinished_reasoning_quote, partial);
else
ParseDsmlCalls(
text_from_tools, markers, tools, &parsed.tool_calls, &spans,
ParseScannedCalls(
text_from_tools, markup, tools, &parsed.tool_calls, &spans,
partial && !unfinished_reasoning_quote && !quotes.UnclosedAt(marker));
std::erase_if(parsed.tool_calls, [&](const auto& call) {
return std::ranges::none_of(
@@ -1947,7 +1960,7 @@ ParsedGeneration ParseGeneration(
parsed = ParseGeneration(
reasoning_call_context.substr(end + reasoning.close.size()),
TextGenerationBackend::InitialOutputState::kContent, reasoning,
tools, choice, enforce_required, markers, closers, quotes, partial,
tools, choice, enforce_required, markup, quotes, partial,
track_text);
RebaseTextSpans(parsed,
static_cast<std::size_t>(reasoning_call_context.data() -
@@ -2024,8 +2037,10 @@ ParsedGeneration ParseStructuredGeneration(
const ReasoningMarkup& reasoning,
std::span<const tokenization::ChatTool> tools,
ChatRequest::ToolChoice choice, bool enforce_required, bool tool_only,
ToolMarkerSet markers, ToolCloserSet closers, QuoteTracker& quotes,
bool partial = false, bool track_text = false) {
const ToolMarkup& markup, QuoteTracker& quotes, bool partial = false,
bool track_text = false) {
const auto markers = markup.markers;
const auto closers = markup.closers;
ParsedGeneration parsed;
const auto source = raw;
if (initial == TextGenerationBackend::InitialOutputState::kAuto) {
@@ -2043,7 +2058,7 @@ ParsedGeneration ParseStructuredGeneration(
quotes.Reset(raw);
const auto call =
!tools.empty() && choice != ChatRequest::ToolChoice::kNone
? ReasoningToolMarker(raw, ReasoningToolMarkers(markers), quotes)
? ReasoningToolMarker(raw, markup.reasoning_markers, quotes)
: std::string_view::npos;
if (call < end) {
if (partial && quotes.UnclosedAt(call))
@@ -2055,7 +2070,7 @@ ParsedGeneration ParseStructuredGeneration(
if (end == std::string_view::npos)
return parsed;
raw.remove_prefix(end + reasoning.close.size());
raw = AfterReasoningSeparator(raw, markers);
raw = AfterReasoningSeparator(raw, markup);
}
}
const auto content = tool_only ? raw : Trim(raw);
@@ -2063,12 +2078,11 @@ ParsedGeneration ParseStructuredGeneration(
if (const auto marker = EarliestMarker(content, markers, quotes);
!tools.empty() && choice != ChatRequest::ToolChoice::kNone &&
(tool_only || marker == 0) && marker != std::string_view::npos &&
!content.substr(marker).starts_with(
tokenization::QwenToolSyntax::kCallOpen)) {
!(markup.qwen_calls && content.substr(marker).starts_with(
tokenization::QwenToolSyntax::kCallOpen))) {
auto native = ParseGeneration(
content, TextGenerationBackend::InitialOutputState::kContent, reasoning,
tools, choice, enforce_required, markers, closers, quotes, partial,
track_text);
tools, choice, enforce_required, markup, quotes, partial, track_text);
RebaseTextSpans(native, content.data() - source.data());
native.reasoning_content = std::move(parsed.reasoning_content);
return native;
@@ -2085,7 +2099,7 @@ ParsedGeneration ParseStructuredGeneration(
}
if (!tools.empty() && choice != ChatRequest::ToolChoice::kNone &&
!content.empty() && marker != std::string_view::npos &&
(tool_only || content.starts_with(tool_start) ||
(tool_only || (markup.qwen_calls && content.starts_with(tool_start)) ||
ToolMarkerPrefix(content, markers))) {
ParsedGeneration call;
std::vector<std::pair<std::size_t, std::size_t>> spans;
@@ -2294,15 +2308,16 @@ public:
StreamingTextFilter(
TextGenerationBackend::InitialOutputState initial_output_state,
ReasoningMarkup reasoning, EmitCallback emit_piece, bool structured,
bool tool_only, bool recognize_tools, ToolMarkerSet markers,
ToolCloserSet closers, std::span<const tokenization::ChatTool> tools)
bool tool_only, bool recognize_tools, const ToolMarkup& markup,
std::span<const tokenization::ChatTool> tools)
: reasoning_(std::move(reasoning)),
emit_piece_(std::move(emit_piece)),
raw_content_(structured || tool_only),
tool_only_(tool_only),
recognize_tools_(recognize_tools),
markers_(markers),
closers_(closers),
markup_(markup),
markers_(markup.markers),
closers_(markup.closers),
tools_(tools) {
if (initial_output_state ==
TextGenerationBackend::InitialOutputState::kReasoning) {
@@ -2371,7 +2386,7 @@ public:
? std::string::npos
: think_found - offset;
const auto thinking_markers =
raw_content_ ? ReasoningToolMarkers(markers_) : markers_;
raw_content_ ? markup_.reasoning_markers : markers_;
const auto found =
!recognize_tools_ ? std::string::npos
: raw_content_
@@ -2421,7 +2436,7 @@ public:
if (state_ == State::kContent) {
if (trim_reasoning_separator_) {
const auto content = AfterReasoningSeparator(pending_, markers_);
const auto content = AfterReasoningSeparator(pending_, markup_);
if (content.empty()) {
pending_.clear();
return true;
@@ -2617,6 +2632,7 @@ private:
bool raw_content_{false};
bool tool_only_{false};
bool recognize_tools_{false};
ToolMarkup markup_;
ToolMarkerSet markers_;
QuoteTracker quotes_;
ToolCloserSet closers_;
@@ -2702,21 +2718,21 @@ private:
ParsedGeneration StreamingGeneration(
const StreamingTextFilter& filter, const ChatRequest& chat,
TextGenerationBackend::InitialOutputState initial,
const ReasoningMarkup& reasoning, ToolMarkerSet markers,
ToolCloserSet closers, QuoteTracker& quotes, bool enforce_required,
bool partial = false, bool track_text = false) {
const ReasoningMarkup& reasoning, const ToolMarkup& markup,
QuoteTracker& quotes, bool enforce_required, bool partial = false,
bool track_text = false) {
// Native grammars establish argument framing before the value completes.
// Recovery from unconstrained/JSON fallback text still requires a full call.
partial = partial && markers.size() <= 2;
partial = partial && markup.native;
return chat.response_format || chat.constrained_tools
? ParseStructuredGeneration(
filter.raw(), initial, reasoning, chat.tools,
chat.tool_choice, enforce_required,
chat.constrained_tools && !chat.response_format, markers,
closers, quotes, partial, track_text)
chat.constrained_tools && !chat.response_format, markup,
quotes, partial, track_text)
: ParseGeneration(filter.raw(), initial, reasoning, chat.tools,
chat.tool_choice, enforce_required, markers,
closers, quotes, partial, track_text);
chat.tool_choice, enforce_required, markup,
quotes, partial, track_text);
}
// Responses uses semantic SSE events, rather than Chat Completions chunks.
@@ -3000,9 +3016,7 @@ HttpResponse NonStreamingResponse(
const std::shared_ptr<TextGenerationBackend::GenerationRequest>& generation,
TextGenerationBackend::InitialOutputState initial_output_state) {
const auto result = generation->Wait();
const auto format = generation->ToolFormat();
const auto markers = ToolMarkers(format);
const auto closers = ToolClosers(format);
const auto markup = SelectToolMarkup(generation->ToolDialectId());
const auto reasoning = generation->ReasoningDelimiters();
core::Utf8Decoder decoder;
const auto decoded = decoder.Push(result.text, true);
@@ -3015,12 +3029,12 @@ HttpResponse NonStreamingResponse(
result.finish_reason ==
TextGenerationBackend::FinishReason::kStop,
request.chat.constrained_tools && !request.chat.response_format,
markers, closers, quotes)
markup, quotes)
: ParseGeneration(decoded, initial_output_state, reasoning,
request.chat.tools, request.chat.tool_choice,
result.finish_reason ==
TextGenerationBackend::FinishReason::kStop,
markers, closers, quotes);
markup, quotes);
json::Value response = json::Value::object();
response["id"] = RandomId("chatcmpl-");
@@ -3077,9 +3091,7 @@ HttpResponse StreamingResponse(
const std::string id = RandomId("chatcmpl-");
const long long created = Now();
const std::string model = request.model;
const auto format = generation->ToolFormat();
const auto markers = ToolMarkers(format);
const auto closers = ToolClosers(format);
const auto markup = SelectToolMarkup(generation->ToolDialectId());
const auto reasoning = generation->ReasoningDelimiters();
auto stream_log = std::make_shared<HttpResponse::StreamLog>();
return {
@@ -3094,7 +3106,7 @@ HttpResponse StreamingResponse(
},
.streaming_body =
[request, generation = std::move(generation), id, created, model,
initial_output_state, reasoning, markers, closers,
initial_output_state, reasoning, markup,
stream_log](const HttpResponse::BodyWriter& writer) {
bool connected = true;
bool started = false;
@@ -3141,7 +3153,7 @@ HttpResponse StreamingResponse(
request.chat.constrained_tools && !request.chat.response_format,
!request.chat.tools.empty() &&
request.chat.tool_choice != ChatRequest::ToolChoice::kNone,
markers, closers, request.chat.tools);
markup, request.chat.tools);
StreamingToolCalls tools([&](const ToolDelta& call) {
// Completion without additional arguments needs no Chat delta.
if (call.id.empty() && call.arguments.empty())
@@ -3182,11 +3194,11 @@ HttpResponse StreamingResponse(
*request.sampling.logprobs);
if (!begin() || !filter.Push(piece.text))
return false;
if (filter.in_tools() && markers.size() <= 2 &&
!tools.Update(StreamingGeneration(
filter, request.chat,
initial_output_state, reasoning,
markers, closers, quotes, false, true)
if (filter.in_tools() && markup.native &&
!tools.Update(StreamingGeneration(filter, request.chat,
initial_output_state,
reasoning, markup,
quotes, false, true)
.tool_calls))
return false;
if (pending_logprobs)
@@ -3208,8 +3220,8 @@ HttpResponse StreamingResponse(
result.finish_reason !=
TextGenerationBackend::FinishReason::kStop;
const auto generated = StreamingGeneration(
filter, request.chat, initial_output_state, reasoning,
markers, closers, quotes, !interrupted);
filter, request.chat, initial_output_state, reasoning, markup,
quotes, !interrupted);
if (!tools.Finish(generated.tool_calls, interrupted))
return;
if (!filter.Finish(generated.hide_tool_markup, generated.text,
@@ -3908,13 +3920,11 @@ HttpResponse CreateCompatibilityResponse(
std::is_same_v<Output, ResponsesOutput> && sampling.logprobs.has_value();
auto generation = backend.start_chat(chat, max_tokens, sampling,
request.is_cancelled, stream || observe);
const auto format = generation->ToolFormat();
const auto markers = ToolMarkers(format);
const auto closers = ToolClosers(format);
const auto markup = SelectToolMarkup(generation->ToolDialectId());
const auto reasoning = generation->ReasoningDelimiters();
auto stream_log = std::make_shared<HttpResponse::StreamLog>();
auto timing = std::make_shared<std::string>();
const auto run = [generation, initial, reasoning, chat, markers, closers,
const auto run = [generation, initial, reasoning, chat, markup,
model = requested_model.empty()
? backend.model_id()
: std::string(requested_model),
@@ -3961,7 +3971,7 @@ HttpResponse CreateCompatibilityResponse(
chat.constrained_tools && !chat.response_format,
!chat.tools.empty() &&
chat.tool_choice != ChatRequest::ToolChoice::kNone,
markers, closers, chat.tools);
markup, chat.tools);
StreamingToolCalls tools([&](const ToolDelta& call) {
if (output.Tool(call))
return true;
@@ -3984,11 +3994,10 @@ HttpResponse CreateCompatibilityResponse(
probabilities->Push(piece);
if (!begin() || !filter.Push(piece.text))
return false;
return !filter.in_tools() || markers.size() > 2 ||
return !filter.in_tools() || !markup.native ||
tools.Update(StreamingGeneration(
filter, chat, initial, reasoning,
markers, closers, quotes, false,
true)
markup, quotes, false, true)
.tool_calls);
},
on_progress, begin)
@@ -4018,8 +4027,8 @@ HttpResponse CreateCompatibilityResponse(
const bool interrupted =
result.finish_reason != TextGenerationBackend::FinishReason::kStop;
const auto generated =
StreamingGeneration(filter, chat, initial, reasoning, markers,
closers, quotes, !interrupted, false, observe);
StreamingGeneration(filter, chat, initial, reasoning, markup, quotes,
!interrupted, false, observe);
if (!tools.Finish(generated.tool_calls, interrupted)) {
generation->Cancel();
return json::Value();
+11
View File
@@ -249,6 +249,17 @@ public:
ToolFormat() const {
return std::nullopt;
}
/// Tool dialect identity the response parsers select markers, closers
/// and the call parser by; stable for this admitted request. The default
/// is the built-in identity of ToolFormat(), and none keeps the legacy
/// detection. An identity that is neither built in nor registered
/// recognizes no calls.
[[nodiscard]] virtual std::optional<std::string> ToolDialectId() const {
const auto format = ToolFormat();
if (!format)
return std::nullopt;
return std::string(ext::BuiltinToolDialectId(*format));
}
/// Reasoning delimiters the response parsers use for this admitted
/// request. Backends without this metadata retain `<think>`/`</think>`.
[[nodiscard]] virtual ReasoningMarkup ReasoningDelimiters() const {
+61 -11
View File
@@ -18,14 +18,47 @@
namespace gufo::server {
namespace {
using Format = sampling::JsonConstraint::ToolFormat;
// The built-in format behind a dialect identity; null for a provider syntax.
struct SelectedToolDialect {
std::optional<Format> format;
const ext::ToolDialect* dialect{nullptr};
};
SelectedToolDialect SelectToolDialect(const std::string& id) {
SelectedToolDialect selected{.dialect = ext::FindToolDialect(id)};
if (selected.dialect != nullptr) {
if (!selected.dialect->syntax)
selected.format = selected.dialect->format;
return selected;
}
// Built-in grammars need no registration; a missing DeepSeek registration
// only leaves its calls unparsed.
for (const auto format : {Format::kJson, Format::kQwen, Format::kDeepSeek})
if (id == ext::BuiltinToolDialectId(format))
return {.format = format};
throw std::invalid_argument("text runner tool dialect '" + id +
"' is not registered");
}
} // namespace
std::optional<ChatRequest> ConstrainChatRequest(
const ChatRequest& request, const TextModelRunner& runner,
sampling::SamplingConfig* sampling,
std::optional<sampling::JsonConstraint::ToolFormat>* tool_format) {
std::optional<sampling::JsonConstraint::ToolFormat>* tool_format,
std::string* tool_dialect) {
// Output parsing needs the model's dialect even without tool constraints
// (for example Qwen's whitespace boundary after </think>).
const auto dialect_id = runner.ToolDialectId();
const auto selected = SelectToolDialect(dialect_id);
if (tool_format)
*tool_format = runner.ToolFormat();
*tool_format = selected.format;
if (tool_dialect)
*tool_dialect = dialect_id;
if (!request.response_format &&
(request.tools.empty() ||
request.tool_choice == ChatRequest::ToolChoice::kNone))
@@ -39,7 +72,8 @@ std::optional<ChatRequest> ConstrainChatRequest(
std::vector<sampling::JsonConstraint::Tool> tools;
const bool required =
request.tool_choice == ChatRequest::ToolChoice::kRequired;
auto format = runner.ToolFormat();
const auto* syntax = selected.format ? nullptr : &*selected.dialect->syntax;
const auto format = selected.format.value_or(Format::kJson);
for (const auto& tool : request.tools) {
const auto definition = tool.definition_json.empty()
? json::Value()
@@ -48,14 +82,20 @@ std::optional<ChatRequest> ConstrainChatRequest(
const auto* strict = function ? function->find("strict") : nullptr;
const bool enforce = strict && strict->as_bool();
auto schema = json::parse(tool.parameters_json);
auto native =
sampling::JsonConstraint::ToolParameters(schema, enforce, format);
auto native = syntax ? sampling::JsonConstraint::ToolParameters(
schema, enforce, *syntax)
: sampling::JsonConstraint::ToolParameters(
schema, enforce, format);
tools.emplace_back(tool.name, std::move(native));
}
grammar = sampling::JsonConstraint::WithTools(
grammar, std::move(tools), required,
!request.response_format && request.parallel_tool_calls, format);
if (format == sampling::JsonConstraint::ToolFormat::kJson)
const bool parallel =
!request.response_format && request.parallel_tool_calls;
grammar = syntax
? sampling::JsonConstraint::WithTools(
grammar, std::move(tools), required, parallel, *syntax)
: sampling::JsonConstraint::WithTools(
grammar, std::move(tools), required, parallel, format);
if (!syntax && format == Format::kJson)
instruction +=
"\nIf a tool is needed, respond using the JSON tool-call form "
"<tool_call>{\"name\":\"function_name\",\"arguments\":{...}}</"
@@ -81,8 +121,17 @@ std::optional<ChatRequest> ConstrainChatRequest(
constrained.messages.insert(constrained.messages.begin(),
std::move(message));
}
if (runner.InitialOutputState(request) == ext::InitialOutputState::kReasoning)
grammar = sampling::JsonConstraint::WithReasoning(grammar);
// The reasoning phase uses the provider's delimiters, as the response
// parsers do. Reasoning the prompt opened may still repeat the opener;
// otherwise the model may open it itself unless the request disabled it.
const auto initial = runner.InitialOutputState(request);
const auto reasoning = runner.ReasoningDelimiters();
if (initial == ext::InitialOutputState::kReasoning)
grammar = sampling::JsonConstraint::WithReasoning(grammar, reasoning.close);
else if (initial == ext::InitialOutputState::kAuto &&
request.reasoning.enabled.value_or(true) && !reasoning.open.empty())
grammar = sampling::JsonConstraint::WithReasoning(grammar, reasoning.close,
reasoning.open);
sampling->constraint = runner.BindConstraint(grammar);
return constrained;
}
@@ -140,6 +189,7 @@ ValidatedRunner ValidateRunner(std::shared_ptr<TextModelRunner> runner,
throw std::invalid_argument(
"text runner reasoning open delimiter must not begin with whitespace");
}
(void)SelectToolDialect(runner->ToolDialectId());
auto resources = runner->ResourceClaim();
const auto per_state_reservation = PerStateReservationBytes(resources);
+7 -1
View File
@@ -190,10 +190,16 @@ private:
/// Build output constraints and attach server-authored instructions as framing,
/// preserving client message content and the model's native prompt layout.
/// `tool_format` receives the built-in format the request was constrained
/// with (none for a provider syntax) and `tool_dialect` the runner's dialect
/// identity, which the response parsers select by. Throws
/// std::invalid_argument for an identity that is neither built in nor
/// registered.
[[nodiscard]] std::optional<ChatRequest> ConstrainChatRequest(
const ChatRequest& request, const TextModelRunner& runner,
sampling::SamplingConfig* sampling,
std::optional<sampling::JsonConstraint::ToolFormat>* tool_format = nullptr);
std::optional<sampling::JsonConstraint::ToolFormat>* tool_format = nullptr,
std::string* tool_dialect = nullptr);
} // namespace gufo::server
File diff suppressed because it is too large. Load diff
+104 -1
View File
@@ -7,6 +7,7 @@
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <span>
#include <string>
#include <vector>
@@ -22,7 +23,83 @@ namespace gufo::sampling {
// verification.
class JsonConstraint {
public:
// Grammars built into core. kQwen and kDeepSeek are the descriptors that
// Syntax() returns; a provider whose call syntax is none of these describes
// it with ToolSyntax instead of adding an entry here.
enum class ToolFormat { kJson, kQwen, kDeepSeek };
// How JSON-shaped native arguments spell JSON values. The defaults are JSON;
// schema semantics (types, bounds, finite values, required and closed
// properties) are the same in every spelling. Numbers, true, false, null,
// braces, brackets, ':' and ',' keep their JSON spelling.
struct ValueSyntax {
// Both empty: JSON strings with escapes. Both set: a string is raw UTF-8
// text between these delimiters, without escapes, which therefore cannot
// contain string_close.
std::string string_open;
std::string string_close;
// Object keys are written unquoted, ending at ':', when they are
// nonempty, hold none of :{}[], have no surrounding whitespace and do not
// begin like a string. Other keys keep the quoted string form.
bool bare_keys{false};
// JSON's insignificant whitespace between tokens. Without it, values are
// compact.
bool whitespace{true};
// Object members in sorted key order instead of schema order.
bool sorted_keys{false};
auto operator<=>(const ValueSyntax&) const = default;
};
// Arguments as one tagged parameter per top-level property, in any order:
// parameter_open KEY raw_value_open TEXT value_close parameter_separator
// parameter_open KEY typed_value_open JSON value_close parameter_separator
// TEXT is raw and ends at the first value_close. When both openers are
// equal the tag carries no type (as Qwen's): a parameter admitting strings
// is raw text and the parser decides types from the schema; undeclared
// parameters are then not admitted. Otherwise (as DeepSeek's string="true"
// and string="false") raw and JSON values are both admitted where the
// schema allows them. A key ends at the first byte of raw_value_open, which
// typed_value_open must share.
struct TaggedArguments {
std::string parameter_open;
std::string raw_value_open;
std::string typed_value_open;
std::string value_close;
std::string parameter_separator;
auto operator<=>(const TaggedArguments&) const = default;
};
// A native call written as
// open name_prefix NAME name_suffix ARGUMENTS close
// ARGUMENTS is either `tagged` parameters or, without them, the tool's
// parameter object spelled by `values`.
//
// JSON-shaped arguments: each parallel call repeats `open`, ordinary text may
// precede a call and, without a response format, follow it, and a required
// call may only be preceded by llama.cpp's bounded SPACE_RULE.
//
// Tagged arguments follow llama.cpp's native parsers: calls end the output
// and a required call may be preceded by whitespace. With an empty
// block_close each parallel call repeats `open` after optional SPACE_RULE
// whitespace, which may also follow the last call. Otherwise parallel calls
// share one block that block_close ends.
struct ToolSyntax {
std::string open;
std::string name_prefix;
std::string name_suffix;
std::string close;
std::string block_close;
// Bytes that end a function name for the parser; a tool whose name holds
// one cannot be constrained (std::invalid_argument).
std::string name_delimiters;
ValueSyntax values;
std::optional<TaggedArguments> tagged;
// A complete call header (`open` + `name_prefix`) written inside the
// reasoning phase ends it, as for the Qwen and DeepSeek grammars.
bool reasoning_header{false};
auto operator<=>(const ToolSyntax&) const = default;
};
// The descriptor of a built-in native format; kJson has none
// (std::invalid_argument). A provider variant can copy one and change its
// tag text.
static const ToolSyntax& Syntax(ToolFormat format);
struct Stack {
std::vector<std::uint32_t> symbols;
std::string lexeme;
@@ -56,13 +133,36 @@ public:
// types. It preserves the model's native string/DSML typed-value semantics.
static std::shared_ptr<const JsonConstraint> OpenToolParameters(
ToolFormat format);
// The same tool argument contract in a provider-described syntax; the
// ToolFormat overloads are these for Syntax(format). Tagged arguments keep
// the native routes above. JSON-shaped arguments compile the parameter
// object in `syntax.values`: a schema this cannot enforce is
// std::invalid_argument when strict; otherwise its supported parts are
// kept, and a schema with no usable object becomes any object in that
// syntax.
static std::shared_ptr<const JsonConstraint> ToolParameters(
const json::Value& schema, bool strict, const ToolSyntax& syntax);
static std::shared_ptr<const JsonConstraint> OpenToolParameters(
const ToolSyntax& syntax);
// Prefixes `answer` with a reasoning phase that ends at `close` (or, for a
// grammar with `reasoning_header` calls, at a complete call header). With an
// empty `open` the phase is already open, as when the prompt opened it. A
// non-empty `open` is the provider's complete opener (it may span tokens and
// include whitespace): the output either starts with it and reasons, or
// starts with the answer. An empty `close` is std::invalid_argument.
static std::shared_ptr<const JsonConstraint> WithReasoning(
std::shared_ptr<const JsonConstraint> answer);
std::shared_ptr<const JsonConstraint> answer,
std::string_view close = "</think>", std::string_view open = {});
using Tool = std::pair<std::string, std::shared_ptr<const JsonConstraint>>;
static std::shared_ptr<const JsonConstraint> WithTools(
std::shared_ptr<const JsonConstraint> answer, std::vector<Tool> tools,
bool required, bool parallel = false,
ToolFormat format = ToolFormat::kJson);
// Tool choice for a provider-described call syntax. Argument grammars
// normally come from ToolParameters with the same syntax.
static std::shared_ptr<const JsonConstraint> WithTools(
std::shared_ptr<const JsonConstraint> answer, std::vector<Tool> tools,
bool required, bool parallel, const ToolSyntax& syntax);
[[nodiscard]] State Start() const;
[[nodiscard]] State Advance(const State& state, unsigned char byte) const;
@@ -75,6 +175,9 @@ private:
friend class ConstraintVocabulary;
friend class SamplerState;
friend struct TokenConstraint;
static std::shared_ptr<const JsonConstraint> BuildTools(
std::shared_ptr<const JsonConstraint> answer, std::vector<Tool> tools,
bool required, bool parallel, const ToolSyntax* syntax);
State CanonicalMaskState(const State& state, std::size_t token_bytes) const;
State Expand(State state) const;
std::vector<Rule> rules_;
+27 -19
View File
@@ -824,23 +824,18 @@ private:
std::vector<bool> can_close_;
};
// Native calls can start before </think>. Keep literal marker mentions and
// Markdown code quoted; a complete native function header starts constrained
// tool decoding. Only a small scan state belongs to each speculative branch.
// Native calls can start before the reasoning close delimiter. Keep literal
// marker mentions and Markdown code quoted; a complete native function header
// starts constrained tool decoding. Only a small scan state belongs to each
// speculative branch.
class ReasoningLexeme final : public JsonSchemaLexeme {
public:
ReasoningLexeme(std::string tool_prefix, ReasoningEnd end)
ReasoningLexeme(std::string tool_prefix, ReasoningEnd end, std::string close)
: tool_prefix_(std::move(tool_prefix)),
close_(std::move(close)),
end_(end),
tool_failure_(tool_prefix_.size()) {
for (std::size_t i = 1, matched = 0; i < tool_prefix_.size(); ++i) {
while (matched && tool_prefix_[i] != tool_prefix_[matched])
matched = tool_failure_[matched - 1];
if (tool_prefix_[i] == tool_prefix_[matched])
++matched;
tool_failure_[i] = matched;
}
}
tool_failure_(Failure(tool_prefix_)),
close_failure_(Failure(close_)) {}
bool CacheTransitions() const override { return true; }
bool PlainReasoning(std::string_view encoded) const override {
Scan state{};
@@ -907,15 +902,13 @@ public:
return prefix + 1;
return std::uint32_t{0};
};
constexpr std::string_view explicit_end = "</think>";
constexpr std::array<std::uint32_t, 8> explicit_failure{};
state.explicit_prefix =
advance(state.explicit_prefix, explicit_end, explicit_failure);
advance(state.explicit_prefix, close_, close_failure_);
state.tool_prefix =
state.inline_quote || state.fence
? 0
: advance(state.tool_prefix, tool_prefix_, tool_failure_);
if (state.explicit_prefix == explicit_end.size())
if (state.explicit_prefix == close_.size())
return {false, end_ == ReasoningEnd::kExplicit};
if (state.tool_prefix == tool_prefix_.size())
return {false, end_ == ReasoningEnd::kTool};
@@ -933,9 +926,23 @@ private:
std::uint32_t run{}, run_char{}, run_at_start{}, fence_char{};
std::uint32_t nonblank{}, closing_fence{};
};
// Knuth-Morris-Pratt failure links, so overlapping marker prefixes survive.
static std::vector<std::uint32_t> Failure(std::string_view marker) {
std::vector<std::uint32_t> failure(marker.size());
for (std::size_t i = 1, matched = 0; i < marker.size(); ++i) {
while (matched && marker[i] != marker[matched])
matched = failure[matched - 1];
if (marker[i] == marker[matched])
++matched;
failure[i] = static_cast<std::uint32_t>(matched);
}
return failure;
}
std::string tool_prefix_;
std::string close_;
ReasoningEnd end_;
std::vector<std::uint32_t> tool_failure_;
std::vector<std::uint32_t> close_failure_;
};
} // namespace
@@ -953,8 +960,9 @@ std::shared_ptr<const JsonSchemaLexeme> JsonSchemaLexeme::Whitespace() {
return whitespace;
}
std::shared_ptr<const JsonSchemaLexeme> JsonSchemaLexeme::Reasoning(
std::string tool_prefix, ReasoningEnd end) {
return std::make_shared<ReasoningLexeme>(std::move(tool_prefix), end);
std::string tool_prefix, ReasoningEnd end, std::string close) {
return std::make_shared<ReasoningLexeme>(std::move(tool_prefix), end,
std::move(close));
}
json::Value JsonSchemaLexeme::IntersectMultipleOf(const json::Value& a,
+2 -1
View File
@@ -50,7 +50,8 @@ public:
static std::shared_ptr<const JsonSchemaLexeme> Whitespace();
enum class ReasoningEnd { kExplicit, kTool, kUnfinished };
static std::shared_ptr<const JsonSchemaLexeme> Reasoning(
std::string tool_prefix, ReasoningEnd end);
std::string tool_prefix, ReasoningEnd end,
std::string close = "</think>");
static json::Value IntersectMultipleOf(const json::Value& a,
const json::Value& b);
static json::Value Format(std::string_view format);
+51 -5
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <charconv>
#include <deque>
#include <stdexcept>
#include <utility>
@@ -174,7 +175,8 @@ TextModelRunner::BuildReasoningBudget(std::size_t tokens,
// ---------------------------------------------------------------------------
static std::vector<TextProvider> g_text_providers;
static std::vector<ToolDialect> g_tool_dialects;
// A deque keeps registered dialects at stable addresses.
static std::deque<ToolDialect> g_tool_dialects;
void RegisterTextProvider(TextProvider provider) {
for (auto& existing : g_text_providers) {
@@ -213,9 +215,49 @@ const TextProvider* ProbeTextProvider(const core::GgufReader& reader) {
return nullptr;
}
std::string_view BuiltinToolDialectId(
sampling::JsonConstraint::ToolFormat format) {
using Format = sampling::JsonConstraint::ToolFormat;
switch (format) {
case Format::kQwen:
return "qwen";
case Format::kDeepSeek:
return "deepseek";
case Format::kJson:
break;
}
return "json";
}
std::string TextModelRunner::ToolDialectId() const {
return std::string(BuiltinToolDialectId(ToolFormat()));
}
void RegisterToolDialect(ToolDialect dialect) {
using Format = sampling::JsonConstraint::ToolFormat;
if (dialect.id.empty())
dialect.id = std::string(BuiltinToolDialectId(dialect.format));
const auto fail = [&](std::string_view reason) {
throw std::invalid_argument("tool dialect '" + dialect.id + "' " +
std::string(reason));
};
if (dialect.id == BuiltinToolDialectId(Format::kJson) ||
dialect.id == BuiltinToolDialectId(Format::kQwen))
fail("is built into the host");
if (dialect.markers.empty() || dialect.scan_tool_calls == nullptr)
fail("needs markers and scan_tool_calls");
if (std::ranges::any_of(dialect.markers, [](std::string_view marker) {
return marker.find('<') == std::string_view::npos;
}))
fail("has a marker without '<'");
if (dialect.syntax
? std::ranges::find(dialect.markers,
std::string_view(dialect.syntax->open)) ==
dialect.markers.end()
: dialect.format == Format::kJson)
fail("needs a syntax opened by one of its markers, or a native format");
for (auto& existing : g_tool_dialects) {
if (existing.format == dialect.format) {
if (existing.id == dialect.id) {
existing = std::move(dialect);
return;
}
@@ -223,16 +265,20 @@ void RegisterToolDialect(ToolDialect dialect) {
g_tool_dialects.push_back(std::move(dialect));
}
const ToolDialect* FindToolDialect(
sampling::JsonConstraint::ToolFormat format) {
const ToolDialect* FindToolDialect(std::string_view id) {
for (const auto& dialect : g_tool_dialects) {
if (dialect.format == format) {
if (dialect.id == id) {
return &dialect;
}
}
return nullptr;
}
const ToolDialect* FindToolDialect(
sampling::JsonConstraint::ToolFormat format) {
return FindToolDialect(BuiltinToolDialectId(format));
}
std::vector<const ToolDialect*> AllToolDialects() {
std::vector<const ToolDialect*> result;
result.reserve(g_tool_dialects.size());
+61
View File
@@ -350,6 +350,13 @@ public:
const {
return sampling::JsonConstraint::ToolFormat::kJson;
}
/// Identity of the tool dialect that constrains and parses this runner's
/// calls: a built-in identity (BuiltinToolDialectId) or the `id` of a
/// registered ToolDialect. The default is the built-in identity of
/// ToolFormat(), so existing runners keep their format; a runner that
/// overrides this need not override ToolFormat(). The host rejects a runner
/// whose identity is neither built in nor registered at load.
[[nodiscard]] virtual std::string ToolDialectId() const;
[[nodiscard]] std::shared_ptr<const sampling::TokenConstraint> BindConstraint(
std::shared_ptr<const sampling::JsonConstraint> grammar) const;
std::size_t PrepareConstraintVocabulary() const;
@@ -655,6 +662,51 @@ struct ScannedToolCall {
bool complete{false};
};
/// A provider's native tool-call dialect: the identity runners select it by,
/// the grammar that constrains calls and the parser that reads them back.
///
/// Identity: `id` (or, when empty, BuiltinToolDialectId(format)). `json` and
/// `qwen` are parsed by the host and cannot be registered; `deepseek` is the
/// DeepSeek extension's registration. A TextModelRunner selects a dialect with
/// ToolDialectId(), and that one identity drives both constrained generation
/// and the buffered and streamed parsers of every OpenAI-compatible and
/// Messages endpoint. Only the selected dialect's `markers` start calls; other
/// dialects' markup is ordinary text.
///
/// Grammar: `syntax` describes the native call syntax (JsonConstraint::
/// ToolSyntax), compiled from each tool's JSON schema for tool_choice
/// required/auto, strict and best-effort tools; tool_choice none leaves the
/// output unconstrained. Without `syntax`, the built-in grammar of `format`
/// (kQwen or kDeepSeek) is used.
///
/// Parsing: `markers` open a call (each must contain '<' and `syntax->open`
/// must be one of them); `reasoning_markers` are complete native call headers
/// which end a constrained reasoning phase (normally `open + name_prefix`
/// when `syntax->reasoning_header`); `closers` are closing tags the host
/// removes when the model echoes them directly after an accepted call.
/// `scan_tool_calls` receives the text from the first marker to the end,
/// the markers, and must append each call it finds in order and, to
/// `spans`, the [begin, end) byte range of each complete call or call block
/// relative to `text`; text outside spans is content. The host accepts a
/// call that is `complete` and `valid` (false: malformed arguments) and names
/// a declared tool. Argument values are strings (`is_string`) or JSON text,
/// which the host checks against the declared schema: a provider whose native
/// values are not JSON converts them. With `partial`, the text may end inside
/// a call: the scanner then also appends the one unfinished call that later
/// text could still complete, with `complete` false, only the arguments
/// already closed and, in `unfinished`, a string argument still open, and
/// stops. The host streams these as argument deltas, so they must only grow;
/// a call that is already malformed is not appended. The final, non-partial
/// parse is authoritative.
///
/// Lifetime: register during extension registration, before any runner is
/// created; registration is not thread-safe with lookups. The registry owns
/// the dialect; pointers from FindToolDialect stay valid for the process,
/// and registering the same identity again replaces it in place.
/// RegisterToolDialect throws std::invalid_argument for a dialect it cannot
/// use: a built-in host identity, no markers, a marker without '<', no
/// scan_tool_calls, `syntax->open` not among the markers, or neither `syntax`
/// nor a native `format`.
struct ToolDialect {
sampling::JsonConstraint::ToolFormat format{
sampling::JsonConstraint::ToolFormat::kJson};
@@ -666,8 +718,14 @@ struct ToolDialect {
std::vector<ScannedToolCall>* calls,
std::vector<std::pair<std::size_t, std::size_t>>* spans, bool partial);
ScanToolCallsFn scan_tool_calls{nullptr};
std::string id;
std::optional<sampling::JsonConstraint::ToolSyntax> syntax;
};
/// `json`, `qwen` or `deepseek`: the identity of a built-in format.
[[nodiscard]] std::string_view BuiltinToolDialectId(
sampling::JsonConstraint::ToolFormat format);
using TextProbe = bool (*)(std::string_view model_root);
using TextProbeGguf = bool (*)(const core::GgufReader& reader);
/// Sampling defaults of the model in `reader`, for prompt and chat; `nullopt`
@@ -705,6 +763,9 @@ const TextProvider* ProbeTextProvider(std::string_view model_root);
const TextProvider* ProbeTextProvider(const core::GgufReader& reader);
void RegisterToolDialect(ToolDialect dialect);
/// The registered dialect with this identity, or null.
const ToolDialect* FindToolDialect(std::string_view id);
/// The registered dialect whose identity is BuiltinToolDialectId(format).
const ToolDialect* FindToolDialect(sampling::JsonConstraint::ToolFormat format);
std::vector<const ToolDialect*> AllToolDialects();
+4 -3
View File
@@ -33,8 +33,9 @@ struct ReasoningMarkup {
};
// Interim, by design: the request-level chat and tool contract is still the
// serve-owned `ChatRequest`, and the tool dialect is still
// `sampling::JsonConstraint::ToolFormat`. RFC step 10 moves the runner header;
// step 7 publishes both under `include/gufo/ext/`.
// serve-owned `ChatRequest`, and the built-in tool formats are still
// `sampling::JsonConstraint::ToolFormat` (provider dialects register a
// `ToolDialect` by identity). RFC step 10 moves the runner header; step 7
// publishes both under `include/gufo/ext/`.
} // namespace gufo::ext
+4 -6
View File
@@ -19,12 +19,10 @@ void RegisterToolDialect() {
DeepSeekToolSyntax::kNativeCallOpenWithSeparator,
DeepSeekToolSyntax::kNativeCallOpen,
},
.closers =
{
DeepSeekToolSyntax::kParameterCloses[0],
DeepSeekToolSyntax::kInvokeCloses[0],
DeepSeekToolSyntax::kCallsClose,
},
// As in llama.cpp, DeepSeek content is everything outside its native
// call block, which ends the output: there is no echo to strip, and
// client envelope syntax the model writes stays visible text.
.closers = {},
.scan_tool_calls = &ScanDeepSeekToolCalls,
});
}
+493 -4
View File
@@ -69,11 +69,13 @@ public:
std::optional<gufo::sampling::JsonConstraint::ToolFormat>
tool_format = {},
gufo::ext::ReasoningMarkup reasoning = {"<think>",
"</think>"})
"</think>"},
std::optional<std::string> tool_dialect = {})
: inner_(std::move(inner)),
progress_(std::move(progress)),
tool_format_(tool_format),
reasoning_(std::move(reasoning)) {}
reasoning_(std::move(reasoning)),
tool_dialect_(std::move(tool_dialect)) {}
Backend::Result Wait(const Backend::TokenCallback& on_token,
const Backend::ProgressCallback& on_progress,
@@ -94,12 +96,16 @@ public:
gufo::ext::ReasoningMarkup ReasoningDelimiters() const override {
return reasoning_;
}
std::optional<std::string> ToolDialectId() const override {
return tool_dialect_ ? tool_dialect_ : GenerationRequest::ToolDialectId();
}
private:
std::shared_ptr<GenerationRequest> inner_;
std::vector<Backend::PromptProgress> progress_;
const std::optional<gufo::sampling::JsonConstraint::ToolFormat> tool_format_;
const gufo::ext::ReasoningMarkup reasoning_;
const std::optional<std::string> tool_dialect_;
};
class FakeBackend final : public gufo::server::TextGenerationBackend {
@@ -215,7 +221,7 @@ public:
return std::make_shared<ProgressRequest>(
TextGenerationBackend::start_chat(request, max_tokens, sampling,
is_cancelled, stream_output),
progress, tool_format, reasoning);
progress, tool_format, reasoning, tool_dialect);
}
[[nodiscard]] std::size_t count_tokens(std::string_view text) const override {
@@ -241,6 +247,8 @@ public:
std::vector<std::string> pieces;
std::vector<PromptProgress> progress;
std::optional<gufo::sampling::JsonConstraint::ToolFormat> tool_format;
// Selects the parsers by identity instead of tool_format.
std::optional<std::string> tool_dialect;
gufo::ext::ReasoningMarkup reasoning{"<think>", "</think>"};
std::string cache_miss_reason;
FinishReason finish_reason{FinishReason::kStop};
@@ -6247,12 +6255,23 @@ struct ToolBoundaryOutput {
// Exercise public serializers with independent fixtures. Validate event
// ownership too: a delta cannot precede its header or follow its block's close.
// A provider dialect request: its identity, tools and reasoning markup.
struct DialectRequest {
std::string id;
std::string tool_choice{"auto"};
std::string tools;
gufo::ext::ReasoningMarkup reasoning{"<think>", "</think>"};
// Responses and Messages only: parse without the tool grammar's framing.
bool unconstrained{false};
};
ToolBoundaryOutput ToolBoundaryRequest(
const std::vector<std::string>& pieces,
gufo::sampling::JsonConstraint::ToolFormat format, int api, bool stream,
FakeBackend::InitialOutputState initial =
FakeBackend::InitialOutputState::kContent,
FakeBackend::FinishReason finish = FakeBackend::FinishReason::kStop) {
FakeBackend::FinishReason finish = FakeBackend::FinishReason::kStop,
const DialectRequest* dialect = nullptr) {
using gufo::json::Value;
FakeBackend backend;
backend.pieces = pieces;
@@ -6260,6 +6279,10 @@ ToolBoundaryOutput ToolBoundaryRequest(
backend.initial_output_state_override = initial;
backend.finish_reason = finish;
backend.stop_sequence = "HALT";
if (dialect) {
backend.tool_dialect = dialect->id;
backend.reasoning = dialect->reasoning;
}
auto body = gufo::json::parse(R"({
"model":"test-model","messages":[{"role":"user","content":"test framing"}],
"tool_choice":"auto","tools":[
@@ -6272,6 +6295,11 @@ ToolBoundaryOutput ToolBoundaryRequest(
body["stream"] = stream;
body["reasoning_effort"] =
initial == FakeBackend::InitialOutputState::kContent ? "none" : "low";
if (dialect) {
body["tool_choice"] = dialect->tool_choice;
if (!dialect->tools.empty())
body["tools"] = gufo::json::parse(dialect->tools);
}
gufo::server::HttpResponse response;
if (api == 0)
response = gufo::server::HandleOpenAiChat(Request(body.dump()), backend);
@@ -6279,6 +6307,8 @@ ToolBoundaryOutput ToolBoundaryRequest(
gufo::server::ChatRequest chat;
Expect(!gufo::server::ParseOpenAiResponseControls(body, &chat),
"tag boundary controls parse");
if (dialect && dialect->unconstrained)
chat.constrained_tools = false;
response = api == 1 ? gufo::server::CreateOpenAiResponse(
Request("{}"), backend, chat, 4096, {}, stream)
: gufo::server::CreateAnthropicMessage(
@@ -7150,8 +7180,467 @@ void TestChatUsageReportsReasoningTokens() {
}
}
// An invented provider dialect, absent from ToolFormat and distinct from the
// Qwen, DeepSeek and Gemma framings, with non-JSON value framing:
// `<<invoke:NAME>>{key:«text»,n:1,o:{k:true},a:[1]}<</invoke>>`.
constexpr std::string_view kLedgerOpen = "<<invoke:";
constexpr std::string_view kLedgerClose = "<</invoke>>";
// Reads ledger values into JSON. kShort: the text ends inside the value.
class LedgerReader {
public:
enum class Read { kOk, kBad, kShort };
explicit LedgerReader(std::string_view text, std::size_t pos)
: text_(text), pos_(pos) {}
[[nodiscard]] std::size_t pos() const { return pos_; }
[[nodiscard]] bool AtEnd() const { return pos_ >= text_.size(); }
[[nodiscard]] char Peek() const { return text_[pos_]; }
void Skip() { ++pos_; }
[[nodiscard]] std::string_view Rest() const { return text_.substr(pos_); }
// A string's text, or its prefix so far when it is still open.
Read String(std::string* out) {
constexpr std::string_view open = "«", close = "»";
if (!Rest().starts_with(open))
return Read::kBad;
const auto end = text_.find(close, pos_ + open.size());
*out = std::string(
text_.substr(pos_ + open.size(), end == std::string_view::npos
? std::string_view::npos
: end - pos_ - open.size()));
if (end == std::string_view::npos)
return Read::kShort;
pos_ = end + close.size();
return Read::kOk;
}
Read Key(std::string* out) {
if (AtEnd())
return Read::kShort;
if (Rest().starts_with("«"))
return String(out);
const auto end = text_.find(':', pos_);
if (end == std::string_view::npos)
return Read::kShort;
*out = std::string(text_.substr(pos_, end - pos_));
if (out->empty() || out->find_first_of("{}[],") != std::string::npos)
return Read::kBad;
pos_ = end;
return Read::kOk;
}
Read Value(gufo::json::Value* out, int depth = 0) {
if (AtEnd())
return Read::kShort;
if (depth > 16)
return Read::kBad;
if (Rest().starts_with("«")) {
std::string text;
const auto read = String(&text);
*out = gufo::json::Value(std::move(text));
return read;
}
if (Peek() == '{' || Peek() == '[') {
const bool object = Peek() == '{';
const char end = object ? '}' : ']';
*out = object ? gufo::json::Value::object() : gufo::json::Value::array();
Skip();
if (AtEnd())
return Read::kShort;
if (Peek() == end) {
Skip();
return Read::kOk;
}
while (true) {
std::string key;
if (object) {
if (const auto read = Key(&key); read != Read::kOk)
return read;
if (AtEnd())
return Read::kShort;
if (Peek() != ':')
return Read::kBad;
Skip();
}
gufo::json::Value item;
if (const auto read = Value(&item, depth + 1); read != Read::kOk)
return read;
if (object)
(*out)[key] = std::move(item);
else
out->push_back(std::move(item));
if (AtEnd())
return Read::kShort;
if (Peek() == end) {
Skip();
return Read::kOk;
}
if (Peek() != ',')
return Read::kBad;
Skip();
}
}
// A scalar ends at the next structural byte.
const auto end = text_.find_first_of(",}]", pos_);
if (end == std::string_view::npos)
return Read::kShort;
try {
*out = gufo::json::parse(text_.substr(pos_, end - pos_));
} catch (...) {
return Read::kBad;
}
if (out->is_string() || out->is_object() || out->is_array())
return Read::kBad;
pos_ = end;
return Read::kOk;
}
private:
std::string_view text_;
std::size_t pos_;
};
void ScanLedgerToolCalls(
std::string_view text, std::span<const std::string_view> markers,
std::vector<gufo::ext::ScannedToolCall>* calls,
std::vector<std::pair<std::size_t, std::size_t>>* spans, bool partial) {
Expect(markers.size() == 1 && markers.front() == kLedgerOpen,
"the scanner receives its own markers");
using Read = LedgerReader::Read;
std::size_t cursor = 0;
while ((cursor = text.find(kLedgerOpen, cursor)) != std::string_view::npos) {
const auto begin = cursor;
gufo::ext::ScannedToolCall call;
const auto name_end = text.find(">>", begin + kLedgerOpen.size());
if (name_end == std::string_view::npos)
return; // Nothing is known until the name ends.
call.name = std::string(text.substr(begin + kLedgerOpen.size(),
name_end - begin - kLedgerOpen.size()));
LedgerReader reader(text, name_end + 2);
// Top-level members are read one at a time so a partial call reports the
// members already closed and a string still open.
const auto members = [&]() -> Read {
if (reader.AtEnd())
return Read::kShort;
if (reader.Peek() != '{')
return Read::kBad;
reader.Skip();
if (!reader.AtEnd() && reader.Peek() == '}') {
reader.Skip();
return Read::kOk;
}
while (true) {
std::string key;
if (const auto read = reader.Key(&key); read != Read::kOk)
return read;
if (reader.AtEnd())
return Read::kShort;
if (reader.Peek() != ':')
return Read::kBad;
reader.Skip();
gufo::json::Value value;
const auto read = reader.Value(&value);
if (read == Read::kShort && value.is_string() && partial)
call.unfinished = gufo::ext::ScannedToolArgument{
.name = key, .value = value.get_str(), .is_string = true};
if (read != Read::kOk)
return read;
if (reader.AtEnd())
return Read::kShort;
if (reader.Peek() != ',' && reader.Peek() != '}')
return Read::kBad;
call.arguments.push_back(
{.name = key,
.value = value.is_string() ? value.get_str() : value.dump(),
.is_string = value.is_string()});
reader.Skip();
if (text[reader.pos() - 1] == '}')
return Read::kOk;
}
};
auto read = members();
if (read == Read::kOk) {
const auto rest = reader.Rest();
if (rest.starts_with(kLedgerClose)) {
call.valid = call.complete = true;
cursor = reader.pos() + kLedgerClose.size();
if (spans)
spans->emplace_back(begin, cursor);
calls->push_back(std::move(call));
continue;
}
read = kLedgerClose.starts_with(rest) ? Read::kShort : Read::kBad;
}
// Only a call later text could still complete is reported unfinished; a
// malformed one is rejected outright.
if (read == Read::kShort && partial)
calls->push_back(std::move(call));
return;
}
}
gufo::sampling::JsonConstraint::ToolSyntax LedgerSyntax() {
return {
.open = std::string(kLedgerOpen),
.name_suffix = ">>",
.close = std::string(kLedgerClose),
.name_delimiters = ">",
.values = {.string_open = "«",
.string_close = "»",
.bare_keys = true,
.whitespace = false,
.sorted_keys = true},
.reasoning_header = true,
};
}
void RegisterLedgerToolDialect() {
gufo::ext::RegisterToolDialect({
.markers = {kLedgerOpen},
.reasoning_markers = {kLedgerOpen},
.closers = {kLedgerClose},
.scan_tool_calls = &ScanLedgerToolCalls,
.id = "ledger",
.syntax = LedgerSyntax(),
});
}
void TestProviderToolDialect() {
using Format = gufo::sampling::JsonConstraint::ToolFormat;
using Initial = FakeBackend::InitialOutputState;
using Finish = FakeBackend::FinishReason;
const std::string tools = R"([
{"type":"function","function":{"name":"weather","parameters":{
"type":"object","properties":{"city":{"type":"string"},
"days":{"type":"integer"},"options":{"type":"object","properties":{
"metric":{"type":"boolean"}},"additionalProperties":false}},
"required":["city"],"additionalProperties":false}}},
{"type":"function","function":{"name":"clock","parameters":{
"type":"object","properties":{},"additionalProperties":false}}}])";
const std::string weather =
"<<invoke:weather>>{city:«Paris, \"FR\"»,days:3,"
"options:{metric:true}}<</invoke>>";
const std::string weather_arguments =
R"({"city":"Paris, \"FR\"","days":3,"options":{"metric":true}})";
const std::string clock = "<<invoke:clock>>{}<</invoke>>";
// Other dialects' markup, which this dialect treats as ordinary text.
const std::string foreign =
"Compare <tool_call>\n<function=weather>\n</function>\n</tool_call>, "
"<|DSML|tool_calls>, <|tool_call>call:clock{}<tool_call|> and "
"</invoke>.";
// The dialect's own marker, quoted as an example.
const std::string quoted = "Write `<<invoke:clock>>{}` to call it.";
struct Case {
std::string name;
std::string raw;
std::string choice;
std::vector<std::string> names;
std::vector<std::string> arguments;
std::string content;
std::string reasoning{};
Initial initial{Initial::kContent};
Finish finish{Finish::kStop};
};
const std::vector<Case> cases{
{"auto call after text",
"Checking. " + weather,
"auto",
{"weather"},
{weather_arguments},
"Checking."},
{"required call",
weather,
"required",
{"weather"},
{weather_arguments},
""},
{"multiple calls",
weather + "\n" + clock,
"auto",
{"weather", "clock"},
{weather_arguments, "{}"},
""},
{"echoed closer",
clock + kLedgerClose.data(),
"auto",
{"clock"},
{"{}"},
""},
{"foreign markers are prose", foreign, "auto", {}, {}, foreign},
{"foreign markers before a call",
foreign + "\n" + clock,
"auto",
{"clock"},
{"{}"},
foreign},
{"quoted marker", quoted, "auto", {}, {}, quoted},
{"quoted marker before a call",
quoted + "\n" + clock,
"auto",
{"clock"},
{"{}"},
quoted},
{"tool choice none", weather, "none", {}, {}, weather},
{"malformed arguments",
"<<invoke:weather>>{city:«Paris»,days:}" + std::string(kLedgerClose),
"auto",
{},
{},
"<<invoke:weather>>{city:«Paris»,days:}" + std::string(kLedgerClose)},
{"undeclared tool",
"<<invoke:other>>{}" + std::string(kLedgerClose),
"auto",
{},
{},
"<<invoke:other>>{}" + std::string(kLedgerClose)},
{"reasoning ends at a call",
"Need data, not <tool_call>." + clock,
"auto",
{"clock"},
{"{}"},
"",
"Need data, not <tool_call>.",
Initial::kReasoning},
{"reasoning then call",
"Plan.<<ponder]]Calling. " + clock,
"auto",
{"clock"},
{"{}"},
"Calling.",
"Plan.",
Initial::kReasoning},
{"reasoning opened by the model",
"[[ponder>>\nPlan.<<ponder]]" + weather,
"auto",
{"weather"},
{weather_arguments},
"",
"Plan.",
Initial::kAuto},
};
for (const auto& item : cases)
for (const bool unconstrained : {false, true})
for (int api = 0; api < 3; ++api) {
// Unconstrained streaming stops at a marker whose quotation is still
// open; only constrained output is covered here.
if (unconstrained &&
(api == 0 || item.name == "quoted marker before a call"))
continue;
for (const bool stream : {false, true})
for (const bool bytewise : {false, true}) {
if (bytewise && !stream)
continue;
std::vector<std::string> pieces;
if (bytewise)
for (const char byte : item.raw)
pieces.emplace_back(1, byte);
else
pieces.push_back(item.raw);
const DialectRequest dialect{
.id = "ledger",
.tool_choice = item.choice,
.tools = tools,
.reasoning = {"[[ponder>>\n", "<<ponder]]"},
.unconstrained = unconstrained,
};
const auto out =
ToolBoundaryRequest(pieces, Format::kJson, api, stream,
item.initial, item.finish, &dialect);
if (bytewise && item.name == "malformed arguments") {
// The call was streamed while it could still complete; its
// rejection then ends the stream with an error, never a call.
Expect(out.failed, "a streamed call that fails is an error");
continue;
}
if (out.failed || out.names != item.names ||
out.arguments != item.arguments ||
Trimmed(out.content) != item.content ||
Trimmed(out.reasoning) != item.reasoning)
std::cerr << "Ledger case: " << item.name << " api=" << api
<< " stream=" << stream << " bytewise=" << bytewise
<< " unconstrained=" << unconstrained
<< "\ncontent: " << out.content
<< "\nreasoning: " << out.reasoning
<< "\ncalls: " << out.names.size() << '\n';
Expect(!out.failed && out.names == item.names &&
out.arguments == item.arguments,
"the provider dialect parses exactly its own calls");
Expect(Trimmed(out.content) == item.content &&
Trimmed(out.reasoning) == item.reasoning,
"content and reasoning exclude only provider markup");
}
}
// A truncated call streams the arguments it has closed, and its open string,
// but never becomes a final call.
const std::string truncated = "<<invoke:weather>>{days:3,city:«Par";
std::vector<std::string> bytes;
for (const char byte : truncated)
bytes.emplace_back(1, byte);
const DialectRequest dialect{.id = "ledger", .tools = tools};
const auto streamed =
ToolBoundaryRequest(bytes, Format::kJson, 0, true, Initial::kContent,
Finish::kLength, &dialect);
Expect(!streamed.failed &&
streamed.names == std::vector<std::string>{"weather"} &&
streamed.arguments ==
std::vector<std::string>{R"({"days":3,"city":"Par)"},
"partial provider calls stream closed members and open strings");
Expect(Trimmed(streamed.content).empty(),
"partial provider markup is not content");
for (int api = 0; api < 3; ++api) {
const auto buffered =
ToolBoundaryRequest({truncated}, Format::kJson, api, false,
Initial::kContent, Finish::kLength, &dialect);
Expect(!buffered.failed && buffered.names.empty() &&
Trimmed(buffered.content).empty(),
"a truncated provider call is neither a call nor content");
}
// A required call that the model did not make is an error.
for (const bool stream : {false, true}) {
FakeBackend backend;
backend.tool_dialect = "ledger";
backend.pieces = {"No call."};
auto body = gufo::json::parse(R"({"model":"test-model",
"messages":[{"role":"user","content":"go"}],"tool_choice":"required"})");
body["tools"] = gufo::json::parse(tools);
body["stream"] = stream;
const auto response =
gufo::server::HandleOpenAiChat(Request(body.dump()), backend);
std::string events;
if (stream)
response.streaming_body([&](std::string_view part) {
events += part;
return true;
});
Expect(stream ? events.find("\"error\"") != std::string::npos
: response.status != 200,
"an unsatisfied required provider call is an error");
}
// Without the identity the same text is no call; an unknown identity
// recognizes none.
for (const std::optional<std::string>& id :
{std::optional<std::string>{}, std::optional<std::string>{"absent"}}) {
FakeBackend backend;
backend.tool_dialect = id;
backend.pieces = {clock};
auto body = gufo::json::parse(R"({"model":"test-model",
"messages":[{"role":"user","content":"go"}],"tool_choice":"auto"})");
body["tools"] = gufo::json::parse(tools);
const auto response =
gufo::server::HandleOpenAiChat(Request(body.dump()), backend);
const auto output = gufo::json::parse(response.body);
const auto* message = output.find("choices")->items()[0].find("message");
Expect(response.status == 200 && !message->contains("tool_calls") &&
message->member_str("content") == clock,
"only the selected dialect's markers start calls");
}
}
int main() {
gufo::models::deepseek_v4_flash::RegisterToolDialect();
RegisterLedgerToolDialect();
TestProviderToolDialect();
TestLogprobs();
TestToolCallRenderParseRoundTrip();
TestToolTagBoundaries();
+218
View File
@@ -1076,10 +1076,228 @@ void TestServerInstructionsAreFraming() {
} // namespace
// A dialect absent from ToolFormat: identity, grammar and parser come from
// its registration alone.
void ScanNothing(std::string_view, std::span<const std::string_view>,
std::vector<gufo::ext::ScannedToolCall>*,
std::vector<std::pair<std::size_t, std::size_t>>*, bool) {}
void TestProviderToolDialectSelection() {
using Constraint = gufo::sampling::JsonConstraint;
const Constraint::ToolSyntax syntax{
.open = "<<invoke:",
.name_suffix = ">>",
.close = "<</invoke>>",
.name_delimiters = ">",
.values = {.string_open = "«",
.string_close = "»",
.bare_keys = true,
.whitespace = false,
.sorted_keys = true},
};
gufo::ext::RegisterToolDialect({
.markers = {"<<invoke:"},
.scan_tool_calls = &ScanNothing,
.id = "ledger-runner",
.syntax = syntax,
});
const auto rejects = [](gufo::ext::ToolDialect dialect) {
try {
gufo::ext::RegisterToolDialect(std::move(dialect));
} catch (const std::invalid_argument&) {
return true;
}
return false;
};
Expect(rejects({.markers = {"<tool_call>"},
.scan_tool_calls = &ScanNothing,
.id = "qwen",
.syntax = syntax}),
"host-parsed identities cannot be registered");
Expect(rejects({.markers = {"[[call"},
.scan_tool_calls = &ScanNothing,
.id = "bracket",
.syntax = syntax}),
"markers must contain '<'");
Expect(rejects({.markers = {"<<call:"},
.scan_tool_calls = &ScanNothing,
.id = "mismatch",
.syntax = syntax}),
"the grammar's opener must be a parser marker");
Expect(rejects({.markers = {"<<invoke:"}, .id = "noscan", .syntax = syntax}),
"a dialect needs a parser");
Expect(rejects({.markers = {"<<invoke:"},
.scan_tool_calls = &ScanNothing,
.id = "nogrammar"}),
"a dialect needs a grammar");
Expect(gufo::ext::FindToolDialect("ledger-runner") != nullptr &&
gufo::ext::FindToolDialect("ledger-runner")->syntax == syntax,
"registered dialects are found by identity");
class DialectRunner final : public FakeRunner {
public:
explicit DialectRunner(
std::string id, std::optional<gufo::ext::ReasoningMarkup> markup = {},
gufo::ext::InitialOutputState initial =
gufo::ext::InitialOutputState::kAuto)
: FakeRunner(std::make_shared<FakeStats>()),
id_(std::move(id)),
markup_(std::move(markup)),
initial_(initial) {}
std::string ToolDialectId() const override { return id_; }
gufo::ext::ReasoningMarkup ReasoningDelimiters() const override {
return markup_ ? *markup_ : FakeRunner::ReasoningDelimiters();
}
gufo::ext::InitialOutputState InitialOutputState(
const ChatRequest&) const override {
return initial_;
}
std::shared_ptr<const gufo::sampling::ConstraintVocabulary>
BuildConstraintVocabulary() const override {
return std::make_shared<gufo::sampling::ConstraintVocabulary>(
256, [](std::uint32_t id) {
return gufo::sampling::ConstraintVocabulary::Piece{
std::string(1, static_cast<char>(id)), false};
});
}
private:
std::string id_;
std::optional<gufo::ext::ReasoningMarkup> markup_;
gufo::ext::InitialOutputState initial_;
};
const auto accepts = [](const Constraint& grammar, std::string_view text) {
auto state = grammar.Start();
for (const unsigned char byte : text) {
state = grammar.Advance(state, byte);
if (state.empty())
return false;
}
return grammar.Complete(state);
};
const DialectRunner runner("ledger-runner");
Expect(FakeRunner(std::make_shared<FakeStats>()).ToolDialectId() == "json",
"existing runners keep the identity of their format");
const std::string call = "<<invoke:record>>{text:«hi»}<</invoke>>";
for (const auto choice :
{ChatRequest::ToolChoice::kRequired, ChatRequest::ToolChoice::kAuto,
ChatRequest::ToolChoice::kNone}) {
ChatRequest request;
request.messages.emplace_back(gufo::tokenization::ChatRole::kUser, "go");
request.tool_choice = choice;
request.tools = {
{.name = "record",
.parameters_json =
R"({"type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false})"}};
gufo::sampling::SamplingConfig sampling;
std::optional<Constraint::ToolFormat> format{Constraint::ToolFormat::kQwen};
std::string dialect;
const auto constrained = gufo::server::ConstrainChatRequest(
request, runner, &sampling, &format, &dialect);
Expect(dialect == "ledger-runner" && !format,
"the runner's identity selects the provider dialect");
if (choice == ChatRequest::ToolChoice::kNone) {
Expect(!constrained && !sampling.constraint,
"tool_choice none leaves the output unconstrained");
continue;
}
Expect(constrained && sampling.constraint &&
constrained->messages.size() == 1 &&
constrained->messages.front().framing_suffix.empty(),
"a native dialect constrains without a JSON instruction");
const auto& grammar = *sampling.constraint->grammar;
Expect(accepts(grammar, call),
"the provider grammar constrains the request");
Expect(
accepts(
grammar,
R"(<tool_call>{"name":"record","arguments":{"text":"hi"}}</tool_call>)") ==
(choice == ChatRequest::ToolChoice::kAuto),
"the JSON envelope is no call of this dialect, only prose");
Expect(accepts(grammar, "Plain answer.") ==
(choice == ChatRequest::ToolChoice::kAuto),
"only automatic tool choice admits a plain answer");
}
// The reasoning phase of the grammar uses the runner's delimiters.
const auto constrain = [](const DialectRunner& reasoner, bool tools,
std::optional<bool> enabled = {}) {
ChatRequest request;
request.messages.emplace_back(gufo::tokenization::ChatRole::kUser, "go");
request.reasoning.enabled = enabled;
if (tools) {
request.tool_choice = ChatRequest::ToolChoice::kRequired;
request.tools = {
{.name = "record",
.parameters_json =
R"({"type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false})"}};
} else {
request.response_format = Constraint::Object();
}
gufo::sampling::SamplingConfig sampling;
(void)gufo::server::ConstrainChatRequest(request, reasoner, &sampling);
return sampling.constraint->grammar;
};
const gufo::ext::ReasoningMarkup ponder{"[[ponder>>\n", "<<ponder]]"};
using Initial = gufo::ext::InitialOutputState;
for (const bool tools : {true, false}) {
const std::string answer = tools ? call : R"({"x":1})";
const auto opened = constrain(
DialectRunner("ledger-runner", ponder, Initial::kReasoning), tools);
Expect(accepts(*opened, "Plan.<<ponder]]" + answer),
"prompt-opened reasoning ends at the provider's close");
Expect(!accepts(*opened, "Plan.</think>" + answer + "</think>"),
"the classic close does not end provider reasoning");
const auto automatic = constrain(
DialectRunner("ledger-runner", ponder, Initial::kAuto), tools);
Expect(accepts(*automatic, "[[ponder>>\nPlan.<<ponder]]" + answer) &&
accepts(*automatic, answer),
"the model may open reasoning with the provider's opener");
Expect(!accepts(*automatic, "<think>\nPlan.</think>" + answer) &&
!accepts(*automatic, "[[ponder>>Plan.<<ponder]]" + answer),
"other reasoning framing is no reasoning of this provider");
const auto disabled = constrain(
DialectRunner("ledger-runner", ponder, Initial::kAuto), tools, false);
Expect(!accepts(*disabled, "[[ponder>>\nPlan.<<ponder]]" + answer) &&
accepts(*disabled, answer),
"disabled reasoning admits no reasoning phase");
const auto content = constrain(
DialectRunner("ledger-runner", ponder, Initial::kContent), tools);
Expect(!accepts(*content, "[[ponder>>\nPlan.<<ponder]]" + answer) &&
accepts(*content, answer),
"content output admits no reasoning phase");
}
// The default markup builds the same reasoning grammar as before.
{
const DialectRunner classic("json", std::nullopt, Initial::kReasoning);
const auto grammar = constrain(classic, false);
Expect(grammar == Constraint::WithReasoning(Constraint::Object()),
"default reasoning markup keeps the classic grammar");
}
bool rejected = false;
try {
gufo::sampling::SamplingConfig sampling;
(void)gufo::server::ConstrainChatRequest(
ChatRequest{}, DialectRunner("unregistered"), &sampling);
} catch (const std::invalid_argument&) {
rejected = true;
}
Expect(rejected, "an unknown identity is a request error");
rejected = false;
try {
TextRunnerPool pool(std::make_shared<DialectRunner>("unregistered"), 1);
} catch (const std::invalid_argument& error) {
rejected = std::string_view(error.what()).find("unregistered") !=
std::string_view::npos;
}
Expect(rejected, "an unknown identity is rejected at load");
TextRunnerPool pool(std::make_shared<DialectRunner>("ledger-runner"), 1);
}
int main() {
TestPromptCountingValidatesCapabilitiesBeforeInference();
TestServerInstructionsAreFraming();
TestReasoningDelimitersFollowTheRunner();
TestProviderToolDialectSelection();
TestPreparedConstraintVocabularyKeepsGrammarBehavior();
TestBoundedPrefillDecodeAndPrefixReuse();
TestAbandonedRequestRollsBackState();
+356
View File
@@ -2262,6 +2262,359 @@ void TestLlamaCppToolGrammarGoldens() {
assert(rows == 688 && differences < rows / 5);
}
// An invented native syntax, absent from ToolFormat, with non-JSON value
// framing: `<<invoke:NAME>>{key:«text»,n:1}<</invoke>>`, guillemet strings,
// bare keys, no whitespace and sorted members.
JsonConstraint::ToolSyntax LedgerSyntax() {
return {
.open = "<<invoke:",
.name_prefix = "",
.name_suffix = ">>",
.close = "<</invoke>>",
.name_delimiters = ">",
.values = {.string_open = "«",
.string_close = "»",
.bare_keys = true,
.whitespace = false,
.sorted_keys = true},
.reasoning_header = true,
};
}
void TestProviderToolSyntax() {
const auto syntax = LedgerSyntax();
const auto schema = parse(R"({"type":"object","properties":{
"units":{"type":"string","enum":["metric","imperial"]},
"city":{"type":"string","minLength":1},
"days":{"type":"integer","minimum":1,"maximum":7},
"a:b":{"type":"boolean"},
"options":{"type":"object","properties":{"zone":{"type":"string"},
"alerts":{"type":"array","items":{"type":"integer"}}},
"required":["zone"],"additionalProperties":false}},
"required":["city","days"],"additionalProperties":false})");
const auto parameters = JsonConstraint::ToolParameters(schema, false, syntax);
assert(parameters == JsonConstraint::ToolParameters(schema, false, syntax));
// Sorted members, bare keys, guillemet strings and compact JSON scalars.
assert(Accepts(*parameters, "{city:«Paris»,days:3}"));
assert(Accepts(*parameters,
"{«a:b»:true,city:«Paris»,days:3,"
"options:{alerts:[1,2],zone:«CET»},units:«metric»}"));
// Undelimited keys need quoting, string text may contain JSON quotes and
// other dialects' markers, and the predicates still apply.
assert(Accepts(*parameters, "{city:«\"<tool_call>\"»,days:7}"));
assert(!Accepts(*parameters, "{city:«»,days:3}"));
assert(!Accepts(*parameters, "{city:«Paris»,days:8}"));
assert(!Accepts(*parameters, "{city:«Paris»,days:«3»}"));
assert(!Accepts(*parameters, "{city:«Paris»,days:3,units:«kelvin»}"));
assert(!Accepts(*parameters, "{days:3}"));
assert(!Accepts(*parameters, "{city:«Paris»,days:}"));
assert(!Accepts(*parameters, "{city:«Pa»ris»,days:3}"));
assert(!Accepts(*parameters, "{days:3,city:«Paris»}"));
assert(!Accepts(*parameters, "{city:«Paris»,days:3,extra:1}"));
assert(!Accepts(*parameters, "{a:b:true,city:«Paris»,days:3}"));
assert(!Accepts(*parameters, "{city: «Paris»,days:3}"));
assert(!Accepts(*parameters, R"({"city":"Paris","days":3})"));
assert(!Accepts(*parameters, "{city:«Paris»,days:3,options:{}}"));
// Strict schemas keep their contract; unsupported ones are errors.
const auto strict = parse(R"({"type":"object","properties":{
"path":{"type":"string"},"lines":{"type":["integer","null"]}},
"required":["path","lines"],"additionalProperties":false})");
const auto exact = JsonConstraint::ToolParameters(strict, true, syntax);
assert(Accepts(*exact, "{lines:null,path:«/tmp/a»}"));
assert(Accepts(*exact, "{lines:4,path:«/tmp/a»}"));
assert(!Accepts(*exact, "{path:«/tmp/a»}"));
bool rejected = false;
try {
(void)JsonConstraint::ToolParameters(
parse(R"({"type":"object","properties":{"x":{"not":{}}},
"required":["x"],"additionalProperties":false})"),
true, syntax);
} catch (const std::invalid_argument&) {
rejected = true;
}
assert(rejected);
// A string the delimiters cannot carry is no exact grammar either.
rejected = false;
try {
(void)JsonConstraint::ToolParameters(
parse(R"({"type":"object","properties":{"x":{"const":"a»b"}},
"required":["x"],"additionalProperties":false})"),
true, syntax);
} catch (const std::invalid_argument&) {
rejected = true;
}
assert(rejected);
// Best effort: an untyped or unsupported schema keeps the envelope with any
// object in the provider's value syntax.
// An open schema keeps the envelope with any object in the value syntax.
const auto open = JsonConstraint::ToolParameters(
parse(R"({"type":"object","additionalProperties":true})"), false, syntax);
assert(open == JsonConstraint::OpenToolParameters(syntax));
assert(Accepts(*open, "{}"));
assert(Accepts(*open, "{x:1,y:«s»,z:[true,null,{w:«v»}]}"));
assert(!Accepts(*open, R"({x:"s"})"));
assert(!Accepts(*open, "{x:1 }"));
assert(!Accepts(*open, "{«x:1}"));
// An empty schema declares no arguments, as on the built-in routes.
const auto none = JsonConstraint::ToolParameters(parse("{}"), false, syntax);
assert(Accepts(*none, "{}"));
assert(!Accepts(*none, "{x:1}"));
// Where the JSON route falls back to any object, so does the native one.
const auto guided = JsonConstraint::ToolParameters(
parse(R"({"type":"object","properties":{"x":{"not":{}}}})"), false,
syntax);
assert(guided == open);
assert(JsonConstraint::OpenToolParameters(syntax) ==
JsonConstraint::OpenToolParameters(syntax));
const std::string call = "<<invoke:weather>>{city:«Paris»,days:3}<</invoke>>";
const std::string other = "<<invoke:clock>>{}<</invoke>>";
const auto empty =
JsonConstraint::ToolParameters(parse(R"({"type":"object","properties":{},
"additionalProperties":false})"),
false, syntax);
const std::vector<JsonConstraint::Tool> tools{{"weather", parameters},
{"clock", empty}};
// tool_choice=required: only whitespace may precede the native marker.
const auto required =
JsonConstraint::WithTools(nullptr, tools, true, false, syntax);
assert(required ==
JsonConstraint::WithTools(nullptr, tools, true, false, syntax));
assert(Accepts(*required, call));
assert(Accepts(*required, "\n" + other));
assert(!Accepts(*required, "I will call it. " + call));
assert(!Accepts(*required, "plain answer"));
assert(!Accepts(*required, call + other));
assert(!Accepts(*required,
"<<invoke:unknown>>{city:«Paris»,days:3}<</invoke>>"));
assert(!Accepts(*required, "<<invoke:weather>>{city:«Paris»}<</invoke>>"));
assert(
!Accepts(*required,
R"(<tool_call>{"name":"weather","arguments":{}}</tool_call>)"));
const auto parallel =
JsonConstraint::WithTools(nullptr, tools, true, true, syntax);
assert(Accepts(*parallel, call + other));
assert(Accepts(*parallel, call + "\n" + other));
// tool_choice=auto: prose, including another dialect's markers, is text.
const auto automatic =
JsonConstraint::WithTools(nullptr, tools, false, true, syntax);
assert(Accepts(*automatic, "plain answer"));
assert(Accepts(*automatic,
R"(<tool_call>{"name":"weather"}</tool_call> <|tool_call>)"));
assert(Accepts(*automatic, "Checking. " + call + " Then " + other));
assert(!Accepts(*automatic, "Checking. <<invoke:weather>>{days:3}"));
// A response format closes the output after the calls.
const auto answer = JsonConstraint::Object();
const auto formatted =
JsonConstraint::WithTools(answer, tools, false, false, syntax);
assert(Accepts(*formatted, R"({"final":true})"));
assert(Accepts(*formatted, call));
assert(!Accepts(*formatted, call + " trailing"));
// The reasoning phase ends at the native call header.
const auto thinking = JsonConstraint::WithReasoning(required);
assert(Accepts(*thinking, "Need the weather." + call));
assert(Accepts(*thinking, "Need the weather.</think>" + call));
assert(
Accepts(*thinking, "Quoted `<<invoke:` stays thought.</think>" + call));
assert(!Accepts(*thinking, "Need the weather.</think>no call"));
// Tool names the parser could not delimit cannot be constrained.
rejected = false;
try {
(void)JsonConstraint::WithTools(nullptr, {{"a>b", empty}}, true, false,
syntax);
} catch (const std::invalid_argument&) {
rejected = true;
}
assert(rejected);
}
// Reasoning framed by provider delimiters: a multi-token opener with a newline,
// a close whose prefix overlaps itself, and both the prompt-opened phase and
// the phase the model opens itself.
void TestProviderReasoningMarkup() {
constexpr std::string_view open = "[[ponder>>\n", close = "<<ponder]]";
// The default markup is the classic grammar, for every answer shape.
const auto object = JsonConstraint::Object();
const auto qwen_tools = JsonConstraint::WithTools(
nullptr,
{{"a",
JsonConstraint::OpenToolParameters(JsonConstraint::ToolFormat::kQwen)}},
false, true, JsonConstraint::ToolFormat::kQwen);
const auto dsml_tools = JsonConstraint::WithTools(
object,
{{"a", JsonConstraint::OpenToolParameters(
JsonConstraint::ToolFormat::kDeepSeek)}},
true, false, JsonConstraint::ToolFormat::kDeepSeek);
for (const auto& answer : {object, qwen_tools, dsml_tools}) {
assert(JsonConstraint::WithReasoning(answer) ==
JsonConstraint::WithReasoning(answer, "</think>"));
assert(JsonConstraint::WithReasoning(answer) ==
JsonConstraint::WithReasoning(answer, "</think>", ""));
assert(JsonConstraint::WithReasoning(answer, close) !=
JsonConstraint::WithReasoning(answer));
}
bool rejected = false;
try {
(void)JsonConstraint::WithReasoning(object, "");
} catch (const std::invalid_argument&) {
rejected = true;
}
assert(rejected);
const auto syntax = LedgerSyntax();
const auto schema = parse(R"({"type":"object","properties":{
"city":{"type":"string"}},"required":["city"],
"additionalProperties":false})");
const std::vector<JsonConstraint::Tool> tools{
{"weather", JsonConstraint::ToolParameters(schema, true, syntax)}};
const std::string call = "<<invoke:weather>>{city:«Paris»}<</invoke>>";
const auto required =
JsonConstraint::WithTools(nullptr, tools, true, false, syntax);
// Prompt-opened reasoning, then a call.
const auto opened = JsonConstraint::WithReasoning(required, close);
assert(Accepts(*opened, "Need the weather.<<ponder]]" + call));
assert(Accepts(*opened, "Need the weather.<<ponder]]\n" + call));
assert(Accepts(*opened, "Need the weather." + call));
assert(Accepts(*opened, "Overlap <<<ponder]]" + call));
assert(Accepts(*opened, "Quoted `<<invoke:` stays.<<ponder]]" + call));
assert(!Accepts(*opened, "Need the weather.<<ponder]]no call"));
assert(!Accepts(*opened, "Need the weather.</think>no call"));
// Reasoning the model opens itself.
const auto automatic = JsonConstraint::WithReasoning(required, close, open);
assert(Accepts(*automatic, "[[ponder>>\nNeed the weather.<<ponder]]" + call));
assert(Accepts(*automatic, "[[ponder>>\nNeed the weather." + call));
assert(Accepts(*automatic, call));
assert(!Accepts(*automatic, "<think>\nNeed the weather.</think>" + call));
assert(!Accepts(*automatic, "[[ponder>>Need the weather.<<ponder]]" + call));
assert(!Accepts(*automatic, "Need the weather.<<ponder]]" + call));
// Automatic tool choice keeps reasoning-only and plain answers.
const auto optional = JsonConstraint::WithReasoning(
JsonConstraint::WithTools(nullptr, tools, false, false, syntax), close,
open);
assert(Accepts(*optional, "[[ponder>>\nThinking only"));
assert(Accepts(*optional, "[[ponder>>\nPlan.<<ponder]]Plain answer."));
assert(Accepts(*optional, "Plain answer."));
// A response format without tools: the provider's close alone ends the
// phase, including after an overlapping prefix.
const auto formatted = JsonConstraint::WithReasoning(object, close);
assert(Accepts(*formatted, "Plan.<<ponder]]{\"x\":1}"));
assert(Accepts(*formatted, "Plan <<<ponder]]{\"x\":1}"));
assert(Accepts(*formatted, "Plan.</think> is text.<<ponder]]{}"));
assert(!Accepts(*formatted, "Plan.</think>{\"x\":1}"));
const auto framed = JsonConstraint::WithReasoning(object, close, open);
assert(Accepts(*framed, "[[ponder>>\nPlan.<<ponder]]{}"));
assert(Accepts(*framed, "{}"));
assert(!Accepts(*framed, "<think>\nPlan.</think>{}"));
assert(!Accepts(*framed, "[[ponder>>\nPlan.</think>{}"));
}
// V4 DSML tag text in the V4.1 spelling: a space after |DSML| and the call
// block named `calls`.
std::string DsmlVariant(std::string text) {
static constexpr std::pair<std::string_view, std::string_view> kTags[] = {
{"|DSML|tool_calls", "|DSML| calls"},
{"|DSML|invoke", "|DSML| invoke"},
{"|DSML|parameter", "|DSML| parameter"},
};
for (const auto& [from, to] : kTags)
for (auto at = text.find(from); at != std::string::npos;
at = text.find(from, at + to.size()))
text.replace(at, from.size(), to);
return text;
}
// A tagged dialect variant needs only provider data: the built-in DeepSeek
// descriptor with other tag text accepts exactly the translated outputs.
void TestProviderTaggedSyntax() {
using Format = JsonConstraint::ToolFormat;
auto variant = JsonConstraint::Syntax(Format::kDeepSeek);
for (auto* text : {&variant.open, &variant.name_prefix, &variant.close,
&variant.block_close, &variant.tagged->parameter_open,
&variant.tagged->value_close})
*text = DsmlVariant(*text);
assert(variant.open == "<|DSML| calls>");
assert(variant.tagged->parameter_open == "<|DSML| parameter name=\"");
bool rejected = false;
try {
(void)JsonConstraint::Syntax(Format::kJson);
} catch (const std::invalid_argument&) {
rejected = true;
}
assert(rejected);
std::ifstream in(GUFO_LLAMA_TOOL_GRAMMAR_GOLDENS);
const auto fixture =
parse(std::string(std::istreambuf_iterator<char>(in), {}));
std::size_t rows = 0;
for (const auto& row : fixture.find("rows")->items()) {
if (row.member_str("format") != "deepseek")
continue;
const auto& parameters = *row.find("parameters");
for (const bool parallel : {false, true}) {
const auto v4 = JsonConstraint::WithTools(
nullptr,
{{"record", JsonConstraint::ToolParameters(parameters, false,
Format::kDeepSeek)}},
true, parallel, Format::kDeepSeek);
const auto v41 = JsonConstraint::WithTools(
nullptr,
{{"record",
JsonConstraint::ToolParameters(parameters, false, variant)}},
true, parallel, variant);
const auto output = row.member_str("output");
assert(Accepts(*v4, output) == Accepts(*v41, DsmlVariant(output)));
assert(!Accepts(*v41, output) || DsmlVariant(output) == output);
}
++rows;
}
assert(rows > 300);
// Untyped key/value tags in another layout, where only the schema types
// values: strings raw, other values JSON.
const JsonConstraint::ToolSyntax pairs{
.open = "<tool_call>",
.name_prefix = "",
.name_suffix = "\n",
.close = "</tool_call>",
.name_delimiters = "\n<",
.tagged =
JsonConstraint::TaggedArguments{
.parameter_open = "<arg_key>",
.raw_value_open = "</arg_key>\n<arg_value>",
.typed_value_open = "</arg_key>\n<arg_value>",
.value_close = "</arg_value>",
.parameter_separator = "\n",
},
};
const auto weather = parse(R"({"type":"object","properties":{
"city":{"type":"string"},"days":{"type":"integer"}},
"required":["city"],"additionalProperties":false})");
const auto paired = JsonConstraint::WithTools(
nullptr, {{"get", JsonConstraint::ToolParameters(weather, false, pairs)}},
true, true, pairs);
const std::string city =
"<arg_key>city</arg_key>\n<arg_value>Paris 75</arg_value>\n";
assert(Accepts(*paired, "<tool_call>get\n" + city + "</tool_call>"));
assert(Accepts(*paired,
"<tool_call>get\n<arg_key>days</arg_key>\n<arg_value>3"
"</arg_value>\n" +
city + "</tool_call>\n<tool_call>get\n" + city +
"</tool_call>"));
assert(!Accepts(*paired,
"<tool_call>get\n<arg_key>days</arg_key>\n"
"<arg_value>x</arg_value>\n" +
city + "</tool_call>"));
assert(!Accepts(*paired, "<tool_call>get\n</tool_call>"));
assert(!Accepts(*paired, "<tool_call>get\n" + city + "</tool_call> more"));
// The built-in formats are these descriptors.
const auto schema = parse(R"({"type":"object","properties":{
"text":{"type":"string"}},"required":["text"],"additionalProperties":false})");
for (const auto format : {Format::kQwen, Format::kDeepSeek})
assert(JsonConstraint::ToolParameters(schema, false, format) ==
JsonConstraint::ToolParameters(schema, false,
JsonConstraint::Syntax(format)));
}
int main(int argc, char** argv) {
// Batch probes for the independent Python JSON Schema validator. This
// exercises the production byte matcher without requiring model weights.
@@ -2318,6 +2671,9 @@ int main(int argc, char** argv) {
TestToolSchemaSafety();
TestNativeToolSchemaMatrix();
TestLlamaCppToolGrammarGoldens();
TestProviderToolSyntax();
TestProviderReasoningMarkup();
TestProviderTaggedSyntax();
std::cout << "JSON constraints: language, schema, Unicode and sampler checks "
"passed\n";
}