Skip to content

Commit 5e8910a

Browse files
authored
common : rework gpt-oss parser (#20393)
* common : rework gpt-oss parser * cont : fix gpt-oss tests * cont : add structured output test * cont : rename final to final_msg
1 parent fe00a84 commit 5e8910a

2 files changed

Lines changed: 59 additions & 116 deletions

File tree

common/chat.cpp

Lines changed: 45 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -933,17 +933,12 @@ static common_chat_params common_chat_params_init_gpt_oss(const common_chat_temp
933933

934934
// Copy reasoning to the "thinking" field as expected by the gpt-oss template
935935
auto adjusted_messages = json::array();
936-
for (const auto & msg : inputs.messages) {
937-
auto has_reasoning_content = msg.contains("reasoning_content") && msg.at("reasoning_content").is_string();
938-
auto has_tool_calls = msg.contains("tool_calls") && msg.at("tool_calls").is_array();
939-
940-
if (has_reasoning_content && has_tool_calls) {
941-
auto adjusted_message = msg;
942-
adjusted_message["thinking"] = msg.at("reasoning_content");
943-
adjusted_messages.push_back(adjusted_message);
944-
} else {
945-
adjusted_messages.push_back(msg);
936+
for (auto msg : inputs.messages) {
937+
if (msg.contains("reasoning_content") && msg.at("reasoning_content").is_string()) {
938+
msg["thinking"] = msg.at("reasoning_content");
939+
msg.erase("content");
946940
}
941+
adjusted_messages.push_back(msg);
947942
}
948943

949944
auto prompt = common_chat_template_direct_apply(tmpl, inputs, /* messages_override= */ adjusted_messages);
@@ -969,45 +964,31 @@ static common_chat_params common_chat_params_init_gpt_oss(const common_chat_temp
969964
"<|channel|>", "<|constrain|>", "<|message|>", "<|start|>", "<|end|>",
970965
};
971966

972-
auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
973-
auto extract_reasoning = inputs.reasoning_format != COMMON_REASONING_FORMAT_NONE;
974-
auto include_grammar = inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE && has_tools;
967+
auto has_tools = inputs.tools.is_array() && !inputs.tools.empty();
968+
auto has_response_format = !inputs.json_schema.is_null() && inputs.json_schema.is_object();
969+
auto include_grammar = has_response_format || (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE);
975970

976971
auto parser = build_chat_peg_parser([&](common_chat_peg_builder & p) {
977-
const std::string END = "<|end|>";
978-
const std::string START = "<|start|>";
979-
const std::string MESSAGE = "<|message|>";
980-
const std::string CHANNEL = "<|channel|>";
981-
const std::string CONSTRAIN = "<|constrain|>";
982-
const std::string START_ASSISTANT = START + "assistant";
983-
const std::string CHANNEL_ANALYSIS = CHANNEL + "analysis";
984-
const std::string CHANNEL_COMMENTARY = CHANNEL + "commentary";
985-
const std::string CHANNEL_FINAL = CHANNEL + "final";
986-
987-
auto the_end = END | p.end();
988-
989-
const std::string analysis_header = CHANNEL_ANALYSIS + MESSAGE;
990-
auto segment_content = p.until(END);
991-
auto analysis_segment = extract_reasoning ?
992-
p.literal(analysis_header) + p.reasoning(segment_content) + p.until(END) + the_end :
993-
p.content(analysis_header + p.until(END) + the_end);
994-
995-
auto channel_header_content = p.until_one_of({ " to=functions.", MESSAGE });
996-
auto content_header = p.choice({ p.literal(CHANNEL_COMMENTARY), p.literal(CHANNEL_FINAL) });
997-
auto content_segment = p.rule("content-segment", content_header + channel_header_content + MESSAGE +
998-
p.content(segment_content) + the_end);
999-
1000-
if (!inputs.json_schema.is_null()) {
1001-
auto final_header = p.literal(CHANNEL_FINAL);
1002-
auto constraint = p.optional(p.space() + p.literal(CONSTRAIN) + channel_header_content);
1003-
return p.optional(analysis_segment) + final_header + constraint + MESSAGE +
1004-
p.content(p.schema(p.json(), "response-format", inputs.json_schema));
972+
auto start = p.rule("start", p.literal("<|start|>assistant"));
973+
auto end = p.rule("end", p.literal("<|end|>"));
974+
auto content = p.rule("message-content", p.until("<|end|>"));
975+
auto channel = p.literal("<|channel|>") + (p.literal("commentary") | p.literal("analysis"));
976+
auto constrain_type = p.chars("[A-Za-z0-9_-]", 1, -1);
977+
978+
auto analysis = p.rule("analysis", p.literal("<|channel|>analysis<|message|>") + p.reasoning(content) + end);
979+
auto preamble = p.rule("preamble", p.literal("<|channel|>commentary<|message|>") + p.content(content) + end);
980+
auto final_msg = p.rule("final", p.literal("<|channel|>final<|message|>") + p.content(content));
981+
auto any = p.rule("any", preamble | analysis);
982+
983+
if (has_response_format) {
984+
auto constraint = p.optional(p.space() + p.literal("<|constrain|>") + constrain_type);
985+
auto response_format = p.rule("response-format",
986+
p.literal("<|channel|>final") + constraint + p.literal("<|message|>") +
987+
p.content(p.schema(p.json(), "response-format-schema", inputs.json_schema)));
988+
989+
return response_format | (analysis + p.zero_or_more(start + analysis) + start + response_format);
1005990
}
1006991

1007-
auto segment = p.optional(START_ASSISTANT + p.space()) + p.choice({ content_segment, analysis_segment });
1008-
auto contents = p.optional(segment + p.repeat(p.optional(p.space()) + segment, 0, -1)) + p.end();
1009-
1010-
// Tool call parser
1011992
if (has_tools && inputs.tool_choice != COMMON_CHAT_TOOL_CHOICE_NONE) {
1012993
auto tool_choice = p.choice();
1013994

@@ -1016,42 +997,37 @@ static common_chat_params common_chat_params_init_gpt_oss(const common_chat_temp
1016997
std::string name = function.at("name");
1017998
const auto & params = function.at("parameters");
1018999

1019-
// Tool call can appear as:
1020-
// 1. In role header: " to=functions.NAME<|channel|>..."
1021-
// 2. In channel: "<|channel|>(analysis|commentary) to=functions.NAME..."
1022-
auto func_name = p.literal(" to=functions.") + p.tool_name(p.literal(name));
1023-
1024-
auto channel = p.literal(CHANNEL_COMMENTARY) | p.literal(CHANNEL_ANALYSIS);
1025-
auto constraint = p.space() + p.optional(p.literal(CONSTRAIN) + channel_header_content);
1000+
auto func_name = p.literal(" to=functions.") + p.tool_name(p.literal(name));
1001+
auto constraint = p.optional(p.space() + p.literal("<|constrain|>") + constrain_type);
10261002
auto args = p.tool_args(p.schema(p.json(), "tool-" + name + "-schema", params));
10271003

1028-
// Pattern 1: recipient in role header
1029-
// " to=functions.NAME<|channel|>(analysis|commentary)[constraint]<|message|>ARGS"
1030-
auto tool_in_role = p.tool(p.tool_open(func_name + channel) + constraint + MESSAGE + args);
1004+
// recipient in role header
1005+
// <|start|>assistant to=functions.NAME<|channel|>(commentary|analysis)[constraint]<|message|>ARGS
1006+
auto tool_in_role = p.tool(p.tool_open(func_name + channel + constraint + p.literal("<|message|>")) + args);
10311007

1032-
// Pattern 2: recipient in channel header
1033-
// "<|channel|>(analysis|commentary) to=functions.NAME[constraint]<|message|>ARGS"
1034-
auto tool_in_channel = p.tool(channel + p.tool_open(func_name + constraint + MESSAGE) + args);
1008+
// recipient in channel header
1009+
// <|channel|>(commentary|analysis) to=functions.NAME[constraint]<|message|>ARGS
1010+
auto tool_in_channel = p.tool(p.tool_open(channel + func_name + constraint + p.literal("<|message|>")) + args);
10351011

1036-
tool_choice |= tool_in_role | tool_in_channel;
1012+
tool_choice |= p.rule("tool-" + name, tool_in_role | tool_in_channel);
10371013
});
10381014

1039-
auto min_calls = inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED ? 1 : 0;
1040-
auto max_calls = inputs.parallel_tool_calls ? -1 : 1;
1015+
auto tool_call = p.trigger_rule("tool-call", tool_choice);
10411016

1042-
auto role_start = p.optional(p.space() + p.literal(START_ASSISTANT));
1043-
auto tool_call = p.rule("tool-call", p.repeat(role_start + tool_choice, min_calls, max_calls) + p.end());
1017+
if (inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED) {
1018+
return tool_call | ( any + p.zero_or_more(start + any) + start + tool_call);
1019+
}
10441020

1045-
return p.choice({ p.trigger_rule("single-tool", tool_call), p.trigger_rule("tools", p.one_or_more(segment) + tool_call) });
1021+
return tool_call | final_msg | (any + p.zero_or_more(start + any) + start + (tool_call | final_msg));
10461022
}
10471023

1048-
return contents;
1024+
return final_msg | (any + p.zero_or_more(start + any) + start + final_msg);
10491025
});
10501026

10511027
data.parser = parser.save();
10521028

10531029
if (include_grammar) {
1054-
data.grammar_lazy = has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_AUTO;
1030+
data.grammar_lazy = !(has_response_format || (has_tools && inputs.tool_choice == COMMON_CHAT_TOOL_CHOICE_REQUIRED));
10551031
data.grammar = build_grammar([&](const common_grammar_builder & builder) {
10561032
foreach_function(inputs.tools, [&](const json & tool) {
10571033
const auto & function = tool.at("function");
@@ -1062,10 +1038,9 @@ static common_chat_params common_chat_params_init_gpt_oss(const common_chat_temp
10621038
});
10631039

10641040
data.grammar_triggers = {
1065-
{ COMMON_GRAMMAR_TRIGGER_TYPE_PATTERN, "^(?:<\\|start\\|>assistant\\s*)?(\\s+to=functions)" },
1066-
{ COMMON_GRAMMAR_TRIGGER_TYPE_PATTERN, "(?:<\\|end\\|>)(?:<\\|start\\|>assistant\\s*)?(\\s+to=functions)" },
1067-
{ COMMON_GRAMMAR_TRIGGER_TYPE_PATTERN,
1068-
"(?:<\\|start\\|>assistant\\s*)?(<\\|channel\\|>(?:commentary|analysis)\\s+to=functions)" }
1041+
{ COMMON_GRAMMAR_TRIGGER_TYPE_PATTERN, "^\\s+to$" },
1042+
{ COMMON_GRAMMAR_TRIGGER_TYPE_PATTERN, "<\\|start\\|>assistant(\\s+to)" },
1043+
{ COMMON_GRAMMAR_TRIGGER_TYPE_PATTERN, "<\\|start\\|>assistant(<\\|channel\\|>(?:commentary|analysis)\\s+to)" }
10691044
};
10701045
}
10711046

tests/test-chat.cpp

Lines changed: 14 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -2448,7 +2448,7 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
24482448

24492449
// Analysis channel (reasoning) with final channel (content)
24502450
tst.test(
2451-
"<|channel|>analysis<|message|>I'm\nthinking<|end|>\n<|channel|>final<|message|>Hello, world!\nWhat's "
2451+
"<|channel|>analysis<|message|>I'm\nthinking<|end|><|start|>assistant<|channel|>final<|message|>Hello, world!\nWhat's "
24522452
"up?")
24532453
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
24542454
.expect(message_assist_thoughts)
@@ -2461,15 +2461,6 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
24612461
.expect_reasoning("I'm\nthinking")
24622462
.run();
24632463

2464-
// Reasoning format none - reasoning stays in content
2465-
tst.test(
2466-
"<|channel|>analysis<|message|>I'm\nthinking<|end|>\n<|channel|>final<|message|>Hello, world!\nWhat's "
2467-
"up?")
2468-
.reasoning_format(COMMON_REASONING_FORMAT_NONE)
2469-
.expect_content(
2470-
"<|channel|>analysis<|message|>I'm\nthinking<|end|>Hello, world!\nWhat's up?")
2471-
.run();
2472-
24732464
// Tool call with recipient in role header: " to=functions.NAME<|channel|>analysis<|message|>JSON"
24742465
tst.test(" to=functions.special_function<|channel|>analysis<|message|>{\"arg1\": 1}")
24752466
.tools({ special_function_tool })
@@ -2496,37 +2487,16 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
24962487

24972488
// Tool call with reasoning + content (analysis first, then tool call)
24982489
tst.test(
2499-
"<|channel|>analysis<|message|>I'm\nthinking<|end|>\n"
2490+
"<|channel|>analysis<|message|>I'm\nthinking<|end|>"
25002491
"<|start|>assistant to=functions.special_function<|channel|>analysis<|message|>{\"arg1\": 1}")
25012492
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
25022493
.tools({ special_function_tool })
25032494
.expect(message_assist_call_thoughts)
25042495
.run();
25052496

2506-
// Tool calling with extra channel before
2507-
tst.test(
2508-
"<|channel|>analysis<|message|>I'm\nthinking<|end|><|start|>assistant<|channel|>commentary"
2509-
" to=functions.special_function <|message|>{\"arg1\": 1}")
2510-
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2511-
.tools({ special_function_tool })
2512-
.expect(message_assist_call_thoughts)
2513-
.run();
2514-
2515-
// Reasoning after final channel
2516-
// Tool calling after final channel
2517-
tst.test(
2518-
"<|channel|>final<|message|><|end|>"
2519-
"<|start|>assistant<|channel|>analysis<|message|>Thinking about edit..."
2520-
)
2521-
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2522-
.expect_reasoning("Thinking about edit...")
2523-
.expect_content("")
2524-
.run();
2525-
2526-
// Tool calling after final channel
2497+
// Complex tool calling
25272498
tst.test(
2528-
"<|channel|>final<|message|><|end|>"
2529-
"<|start|>assistant<|channel|>analysis<|message|>Thinking about edit...<|end|>"
2499+
"<|channel|>analysis<|message|>Thinking about edit...<|end|>"
25302500
"<|start|>assistant<|channel|>commentary to=functions.edit <|constrain|>json"
25312501
"<|message|>{\"oldString\": \"if (part < railCount - 1) {\", \"newString\": \"if (part < 4) {\", \"replaceAll\": false}"
25322502
)
@@ -2561,19 +2531,17 @@ static void test_template_output_peg_parsers(bool detailed_debug) {
25612531
})
25622532
.run();
25632533

2564-
// Parallel tool calls
2534+
// Structured output
25652535
tst.test(
2566-
" to=functions.special_function<|channel|>analysis<|message|>{\"arg1\": 1}\n"
2567-
"<|start|>assistant to=functions.special_function_with_opt<|channel|>analysis<|message|>{\"arg1\": 1, "
2568-
"\"arg2\": 2}")
2569-
.parallel_tool_calls(true)
2570-
.tools({
2571-
special_function_tool, special_function_tool_with_optional_param
2572-
})
2573-
.expect_tool_calls({
2574-
{ "special_function", R"({"arg1": 1})", {} },
2575-
{ "special_function_with_opt", R"({"arg1": 1, "arg2": 2})", {} },
2576-
})
2536+
"<|channel|>analysis<|message|>I need to output the invoice details in JSON<|end|>"
2537+
"<|start|>assistant<|channel|>final <|constrain|>json"
2538+
"<|message|>"
2539+
R"({"amount": 123.45, "date": "2025-12-03"})"
2540+
)
2541+
.reasoning_format(COMMON_REASONING_FORMAT_AUTO)
2542+
.json_schema(invoice_schema)
2543+
.expect_reasoning("I need to output the invoice details in JSON")
2544+
.expect_content(R"({"amount": 123.45, "date": "2025-12-03"})")
25772545
.run();
25782546
}
25792547

0 commit comments

Comments
 (0)