Skip to content

Commit 5208e2d

Browse files
authored
fix: gemma 4 template (#21326)
1 parent 7992aa7 commit 5208e2d

File tree

9 files changed

+670
-15
lines changed

9 files changed

+670
-15
lines changed

common/chat-auto-parser-generator.cpp

Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,109 @@
77
#include "log.h"
88
#include "nlohmann/json.hpp"
99

10+
#include <algorithm>
1011
#include <stdexcept>
1112
#include <string>
1213

1314
using json = nlohmann::ordered_json;
1415

16+
namespace {
17+
18+
// Gemma4-specific PEG builder extending the standard chat builder.
19+
// Adds value type parsers that use <|\"|> as string delimiters
20+
// instead of JSON's double quotes, and disables json-to-schema
21+
// conversion for these types.
22+
class common_peg_gemma4_builder {
23+
common_chat_peg_builder & p_;
24+
static constexpr const char * QUOTE = "<|\"|>";
25+
26+
public:
27+
explicit common_peg_gemma4_builder(common_chat_peg_builder & p) : p_(p) {}
28+
29+
common_peg_parser gemma4_string() {
30+
return p_.rule("gemma4-string", [&]() {
31+
return p_.literal(QUOTE) + p_.until(QUOTE) + p_.literal(QUOTE);
32+
});
33+
}
34+
35+
common_peg_parser gemma4_number() {
36+
return p_.rule("gemma4-number", [&]() {
37+
auto digit1_9 = p_.chars("[1-9]", 1, 1);
38+
auto digits = p_.chars("[0-9]");
39+
auto int_part = p_.choice({p_.literal("0"), p_.sequence({digit1_9, p_.chars("[0-9]", 0, -1)})});
40+
auto frac = p_.sequence({p_.literal("."), digits});
41+
auto exp = p_.sequence({p_.choice({p_.literal("e"), p_.literal("E")}),
42+
p_.optional(p_.chars("[+-]", 1, 1)), digits});
43+
auto not_number_continuation = p_.negate(p_.chars("[0-9.eE+-]", 1, 1));
44+
return p_.sequence({p_.optional(p_.literal("-")), int_part, p_.optional(frac),
45+
p_.optional(exp), not_number_continuation});
46+
});
47+
}
48+
49+
common_peg_parser gemma4_bool() {
50+
return p_.rule("gemma4-bool", [&]() {
51+
return p_.choice({p_.literal("true"), p_.literal("false")});
52+
});
53+
}
54+
55+
common_peg_parser gemma4_null() {
56+
return p_.rule("gemma4-null", [&]() {
57+
return p_.literal("null");
58+
});
59+
}
60+
61+
common_peg_parser gemma4_dict() {
62+
return p_.rule("gemma4-dict", [&]() {
63+
auto ws = p_.space();
64+
auto key = p_.until(":");
65+
auto member = p_.sequence({key, p_.literal(":"), ws, gemma4_value()});
66+
auto members = p_.sequence({member, p_.zero_or_more(p_.sequence({p_.literal(","), ws, member}))});
67+
return p_.sequence({
68+
p_.literal("{"), ws,
69+
p_.choice({p_.literal("}"), p_.sequence({members, ws, p_.literal("}")})})
70+
});
71+
});
72+
}
73+
74+
common_peg_parser gemma4_array() {
75+
return p_.rule("gemma4-array", [&]() {
76+
auto ws = p_.space();
77+
auto elements = p_.sequence({gemma4_value(), p_.zero_or_more(p_.sequence({p_.literal(","), ws, gemma4_value()}))});
78+
return p_.sequence({
79+
p_.literal("["), ws,
80+
p_.choice({p_.literal("]"), p_.sequence({elements, ws, p_.literal("]")})})
81+
});
82+
});
83+
}
84+
85+
common_peg_parser gemma4_value() {
86+
return p_.rule("gemma4-value", [&]() {
87+
return p_.choice({gemma4_string(), gemma4_dict(), gemma4_array(),
88+
gemma4_number(), gemma4_bool(), gemma4_null()});
89+
});
90+
}
91+
92+
// Select the appropriate value parser based on JSON schema type.
93+
// Does NOT use schema() - the gemma4 types are pure PEG without
94+
// JSON schema metadata, so GBNF is generated directly from the
95+
// PEG structure.
96+
common_peg_parser gemma4_value_for_type(const json & schema) {
97+
if (!schema.contains("type") || !schema.at("type").is_string()) {
98+
return gemma4_value();
99+
}
100+
std::string type = schema.at("type").get<std::string>();
101+
if (type == "string") { return gemma4_string(); }
102+
if (type == "number") { return gemma4_number(); }
103+
if (type == "integer") { return gemma4_number(); }
104+
if (type == "boolean") { return gemma4_bool(); }
105+
if (type == "object") { return gemma4_dict(); }
106+
if (type == "array") { return gemma4_array(); }
107+
return gemma4_value();
108+
}
109+
};
110+
111+
} // anonymous namespace
112+
15113
// Helper to iterate over tools/functions
16114
static void foreach_function(const json & tools, const std::function<void(const json &)> & fn) {
17115
for (const auto & tool : tools) {
@@ -43,7 +141,9 @@ common_chat_params peg_generator::generate_parser(const common_chat_template &
43141
// Create the result structure
44142
common_chat_params data;
45143
data.prompt = common_chat_template_direct_apply(tmpl, inputs);
46-
data.format = COMMON_CHAT_FORMAT_PEG_NATIVE;
144+
data.format = (autoparser.tools.format.mode == tool_format::TAG_WITH_GEMMA4_DICT)
145+
? COMMON_CHAT_FORMAT_PEG_GEMMA4
146+
: COMMON_CHAT_FORMAT_PEG_NATIVE;
47147
data.preserved_tokens = autoparser.preserved_tokens;
48148

49149
auto parser = autoparser.build_parser(inputs);
@@ -92,6 +192,7 @@ common_peg_arena autoparser::build_parser(const generation_params & inputs) cons
92192

93193
ctx.extracting_reasoning = extract_reasoning && reasoning.mode != reasoning_mode::NONE;
94194
ctx.content = &content;
195+
ctx.reasoning = &reasoning;
95196

96197
// Build reasoning parser
97198
ctx.reasoning_parser = reasoning.build_parser(ctx);
@@ -440,7 +541,7 @@ common_peg_parser analyze_tools::build_tool_parser_tag_gemma4_dict(parser_build_
440541
const auto & inputs = ctx.inputs;
441542
bool force_tools = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED;
442543

443-
// The Gemma4 string quote token used in place of JSON "
544+
common_peg_gemma4_builder g4(p);
444545
static const std::string QUOTE = "<|\"|>";
445546

446547
common_peg_parser tool_choice = p.choice();
@@ -451,7 +552,6 @@ common_peg_parser analyze_tools::build_tool_parser_tag_gemma4_dict(parser_build_
451552
const auto & params = func.at("parameters");
452553

453554
if (!params.contains("properties") || !params.at("properties").is_object()) {
454-
// No arguments - just match the function name with empty braces
455555
auto func_parser = p.atomic(
456556
p.tool_open(p.literal(function.name_prefix) + p.tool_name(p.literal(name)) + p.literal("{")) +
457557
p.tool_args(p.eps()) +
@@ -486,9 +586,18 @@ common_peg_parser analyze_tools::build_tool_parser_tag_gemma4_dict(parser_build_
486586
p.tool_arg_string_value(p.schema(p.until(QUOTE),
487587
"tool-" + name + "-arg-" + param_name + "-schema", param_schema, true)) +
488588
p.literal(QUOTE);
589+
} else if (type == "number" || type == "integer") {
590+
value_parser = p.tool_arg_value(g4.gemma4_number());
591+
} else if (type == "boolean") {
592+
value_parser = p.tool_arg_value(g4.gemma4_bool());
593+
} else if (type == "null") {
594+
value_parser = p.tool_arg_value(g4.gemma4_null());
595+
} else if (type == "object") {
596+
value_parser = p.tool_arg_value(g4.gemma4_dict());
597+
} else if (type == "array") {
598+
value_parser = p.tool_arg_value(g4.gemma4_array());
489599
} else {
490-
// Numbers, booleans: raw text up to the next comma or closing brace
491-
value_parser = p.tool_arg_value(p.until_one_of({",", "}"}));
600+
value_parser = p.tool_arg_value(g4.gemma4_value());
492601
}
493602

494603
auto arg = p.tool_arg(
@@ -538,9 +647,9 @@ common_peg_parser analyze_tools::build_tool_parser_tag_gemma4_dict(parser_build_
538647
tool_calls = p.optional(tool_calls);
539648
}
540649

541-
auto content_before_tools = p.until(format.per_call_start);
650+
auto content_before_tools = p.until_one_of({ format.per_call_start, ctx.reasoning->start });
542651
return ctx.reasoning_parser +
543-
(force_tools ? p.eps() : p.optional(p.content(content_before_tools))) +
652+
(force_tools ? p.eps() : p.optional(p.content(content_before_tools) + p.optional(ctx.reasoning_parser))) +
544653
tool_calls + p.end();
545654
}
546655

common/chat-auto-parser.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,12 +215,14 @@ struct tool_id_analysis {
215215
// ============================================================================
216216

217217
struct analyze_content;
218+
struct analyze_reasoning;
218219

219220
struct parser_build_context {
220221
common_chat_peg_builder & p;
221-
const generation_params & inputs;
222+
const generation_params & inputs;
222223
common_peg_parser reasoning_parser;
223224
bool extracting_reasoning = false;
225+
const analyze_reasoning * reasoning = nullptr;
224226
const analyze_content * content = nullptr;
225227

226228
parser_build_context(common_chat_peg_builder & p, const generation_params & inputs);

common/chat-diff-analyzer.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,11 @@ static std::vector<std::function<void(const common_chat_template & tmpl, autopar
104104
analysis.tools.function.name_suffix = "";
105105
analysis.tools.arguments.start = "{";
106106
analysis.tools.arguments.end = "}";
107+
analysis.tools.arguments.name_prefix = "";
107108
analysis.tools.arguments.name_suffix = ":";
108109
analysis.tools.arguments.separator = ",";
109110
analysis.reasoning.mode = reasoning_mode::TAG_BASED;
110-
analysis.reasoning.start = "<|channel>thought\n";
111+
analysis.reasoning.start = "<|channel>thought";
111112
analysis.reasoning.end = "<channel|>";
112113
analysis.preserved_tokens.clear();
113114
analysis.preserved_tokens.push_back("<|tool_call>");

common/chat-peg-parser.cpp

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,84 @@ static std::string escape_json_string_inner(const std::string & s) {
7575
return escaped;
7676
}
7777

78+
static const std::string GEMMA4_QUOTE = "<|\"|>";
79+
80+
static std::string normalize_gemma4_to_json(const std::string & input) {
81+
std::string result;
82+
result.reserve(input.size() * 2);
83+
84+
enum Ctx { DICT, ARRAY };
85+
std::vector<Ctx> ctx;
86+
87+
auto is_ws = [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; };
88+
auto skip_ws = [&](size_t & pos) {
89+
while (pos < input.size() && is_ws(input[pos])) {
90+
result += input[pos++];
91+
}
92+
};
93+
94+
auto quote_unquoted_key = [&](size_t & pos) {
95+
if (pos < input.size() && input[pos] != '"' && input[pos] != '}') {
96+
result += '"';
97+
while (pos < input.size() && input[pos] != ':' && !is_ws(input[pos])) {
98+
result += input[pos++];
99+
}
100+
result += '"';
101+
skip_ws(pos);
102+
}
103+
};
104+
105+
size_t i = 0;
106+
while (i < input.size()) {
107+
if (i + GEMMA4_QUOTE.size() <= input.size() &&
108+
input.compare(i, GEMMA4_QUOTE.size(), GEMMA4_QUOTE) == 0) {
109+
result += '"';
110+
i += GEMMA4_QUOTE.size();
111+
continue;
112+
}
113+
114+
char c = input[i];
115+
116+
if (c == '{') {
117+
result += c;
118+
ctx.push_back(DICT);
119+
++i;
120+
skip_ws(i);
121+
quote_unquoted_key(i);
122+
continue;
123+
}
124+
if (c == '}') {
125+
result += c;
126+
if (!ctx.empty()) ctx.pop_back();
127+
++i;
128+
continue;
129+
}
130+
if (c == '[') {
131+
result += c;
132+
ctx.push_back(ARRAY);
133+
++i;
134+
continue;
135+
}
136+
if (c == ']') {
137+
result += c;
138+
if (!ctx.empty()) ctx.pop_back();
139+
++i;
140+
continue;
141+
}
142+
if (c == ',' && !ctx.empty() && ctx.back() == DICT) {
143+
result += c;
144+
++i;
145+
skip_ws(i);
146+
quote_unquoted_key(i);
147+
continue;
148+
}
149+
150+
result += c;
151+
++i;
152+
}
153+
return result;
154+
}
155+
78156
// Convert Python-style single-quoted strings to JSON double-quoted strings
79157
// Only converts outer string delimiters, properly handling escape sequences:
80158
// - {'key': 'value'} -> {"key": "value"}
@@ -214,6 +292,14 @@ std::string & common_chat_peg_mapper::args_target() {
214292
return (current_tool && !current_tool->name.empty()) ? current_tool->arguments : args_buffer;
215293
}
216294

295+
std::string common_chat_peg_mapper::normalize_container_value(const std::string & input) {
296+
return normalize_quotes_to_json(input);
297+
}
298+
299+
std::string common_chat_peg_gemma4_mapper::normalize_container_value(const std::string & input) {
300+
return normalize_quotes_to_json(normalize_gemma4_to_json(input));
301+
}
302+
217303
void common_chat_peg_mapper::from_ast(const common_peg_ast_arena & arena,
218304
const common_peg_parse_result & parse_result_arg) {
219305
arena.visit(parse_result_arg, [this](const common_peg_ast_node & node) { map(node); });
@@ -352,7 +438,7 @@ void common_chat_peg_mapper::map(const common_peg_ast_node & node) {
352438
// For potential containers, normalize Python-style single quotes to JSON double quotes
353439
bool is_potential_container = value_content[0] == '[' || value_content[0] == '{';
354440
if (is_potential_container) {
355-
value_content = normalize_quotes_to_json(value_content);
441+
value_content = normalize_container_value(value_content);
356442
}
357443

358444
// Try to parse as JSON value (number, bool, null, object, array)

common/chat-peg-parser.h

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ class common_chat_peg_mapper {
1717

1818
virtual void from_ast(const common_peg_ast_arena & arena, const common_peg_parse_result & result);
1919
virtual void map(const common_peg_ast_node & node);
20-
private:
20+
protected:
21+
virtual std::string normalize_container_value(const std::string & input);
22+
private:
2123
// Tool call handling state
2224
std::optional<common_chat_tool_call> pending_tool_call; // Tool call waiting for name
2325
common_chat_tool_call * current_tool = nullptr;
@@ -30,6 +32,13 @@ class common_chat_peg_mapper {
3032
std::string & args_target();
3133
};
3234

35+
class common_chat_peg_gemma4_mapper : public common_chat_peg_mapper {
36+
public:
37+
common_chat_peg_gemma4_mapper(common_chat_msg & msg) : common_chat_peg_mapper(msg) {}
38+
protected:
39+
std::string normalize_container_value(const std::string & input) override;
40+
};
41+
3342
struct content_structure;
3443
struct tool_call_structure;
3544

common/chat.cpp

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,8 @@ const char * common_chat_format_name(common_chat_format format) {
694694
return "peg-simple";
695695
case COMMON_CHAT_FORMAT_PEG_NATIVE:
696696
return "peg-native";
697+
case COMMON_CHAT_FORMAT_PEG_GEMMA4:
698+
return "peg-gemma4";
697699
default:
698700
throw std::runtime_error("Unknown chat format");
699701
}
@@ -1905,8 +1907,13 @@ common_chat_msg common_chat_peg_parse(const common_peg_arena & src_pars
19051907
// Try to extract any partial results from what was successfully parsed
19061908
common_chat_msg msg;
19071909
msg.role = "assistant";
1908-
auto mapper = common_chat_peg_mapper(msg);
1909-
mapper.from_ast(ctx.ast, result);
1910+
std::unique_ptr<common_chat_peg_mapper> mapper;
1911+
if (params.format == COMMON_CHAT_FORMAT_PEG_GEMMA4) {
1912+
mapper = std::make_unique<common_chat_peg_gemma4_mapper>(msg);
1913+
} else {
1914+
mapper = std::make_unique<common_chat_peg_mapper>(msg);
1915+
}
1916+
mapper->from_ast(ctx.ast, result);
19101917

19111918
if (ctx.is_debug()) {
19121919
fprintf(stderr, "\nAST for partial parse (fail):\n%s\n", ctx.ast.dump().c_str());
@@ -1921,8 +1928,13 @@ common_chat_msg common_chat_peg_parse(const common_peg_arena & src_pars
19211928
common_chat_msg msg;
19221929
msg.role = "assistant";
19231930

1924-
auto mapper = common_chat_peg_mapper(msg);
1925-
mapper.from_ast(ctx.ast, result);
1931+
std::unique_ptr<common_chat_peg_mapper> mapper;
1932+
if (params.format == COMMON_CHAT_FORMAT_PEG_GEMMA4) {
1933+
mapper = std::make_unique<common_chat_peg_gemma4_mapper>(msg);
1934+
} else {
1935+
mapper = std::make_unique<common_chat_peg_mapper>(msg);
1936+
}
1937+
mapper->from_ast(ctx.ast, result);
19261938

19271939
if (ctx.is_debug()) {
19281940
fprintf(stderr, "\nAST for %s parse:\n%s\n", is_partial ? "partial" : "full", ctx.ast.dump().c_str());

common/chat.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ enum common_chat_format {
184184
// These are intended to be parsed by the PEG parser
185185
COMMON_CHAT_FORMAT_PEG_SIMPLE,
186186
COMMON_CHAT_FORMAT_PEG_NATIVE,
187+
COMMON_CHAT_FORMAT_PEG_GEMMA4,
187188

188189
COMMON_CHAT_FORMAT_COUNT, // Not a format, just the # formats
189190
};

0 commit comments

Comments
 (0)