mirror of
https://github.com/gufo-org/gufo.git
synced 2026-11-07 09:35:59 -10:00
feat(ext): let text providers register native tool dialects (#1013)
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d02a1d2d5f
commit
2c97b72d86
18 files changed
+2133
-405
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+509
-224
File diff suppressed because it is too large.
Load diff
@@ -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_;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in new issue
Block a user