[Rust Frontend] Add Harmony Renderer for GPT-OSS (#46800)

Signed-off-by: Bugen Zhao <i@bugenzhao.com>

Bugen Zhao committed Jun 29, 2026 at 11:39 UTC 5274c1181dc61bdf6e5eb610d37ebef694b1340d
27 files changed +1033 -32
rust/Cargo.lock
+1
@@ -5049,6 +5049,7 @@ dependencies = [
5049 "tempfile",
5050 "thiserror 2.0.18",
5051 "thiserror-ext",
5052 + "time",
5053 "tokio",
5054 "tracing",
5055 "tracing-subscriber",
rust/src/chat/Cargo.toml
+1
@@ -25,6 +25,7 @@ strum.workspace = true
25 subenum.workspace = true
26 thiserror.workspace = true
27 thiserror-ext.workspace = true
28 +time.workspace = true
29 tokio.workspace = true
30 tracing.workspace = true
31 trait-set.workspace = true
rust/src/chat/src/backend/hf.rs
+48 -8
@@ -15,7 +15,9 @@ use crate::output::{
15 DefaultChatOutputProcessor, HarmonyChatOutputProcessor, validate_harmony_parser_overrides,
16 };
17 use crate::renderer::hf::{HfChatRenderer, MultimodalRenderInfo};
18 -use crate::renderer::{DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer};
18 +use crate::renderer::{
19 + DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer, HarmonyChatRenderer,
20 +};
21 use crate::request::ChatRequest;
22 use crate::{DynChatOutputProcessor, RendererSelection};
23
@@ -61,6 +63,7 @@ impl HfChatBackend {
63 )?),
64 RendererSelection::DeepSeekV32 => Arc::new(DeepSeekV32ChatRenderer::new()),
65 RendererSelection::DeepSeekV4 => Arc::new(DeepSeekV4ChatRenderer::new()),
66 + RendererSelection::Harmony => Arc::new(HarmonyChatRenderer::new()?),
67 };
68
69 info!(
@@ -148,13 +151,15 @@ mod tests {
151 use std::sync::Arc;
152
153 use tempfile::tempdir;
154 + use thiserror_ext::AsReport as _;
155 + use vllm_text::Prompt;
156 use vllm_text::backend::hf::TokenizerSource;
157 use vllm_text::tokenizer::{DynTokenizer, Tokenizer};
158
159 use super::HfChatBackend;
155 - use crate::RendererSelection;
156 - use crate::backend::{ChatBackend, LoadModelBackendsOptions};
160 + use crate::backend::{ChatBackend, LoadModelBackendsOptions, NewChatOutputProcessorOptions};
161 use crate::request::{ChatContent, ChatMessage, ChatRequest};
162 + use crate::{ParserSelection, RendererSelection};
163
164 fn request_with_user_text(text: &str) -> ChatRequest {
165 ChatRequest {
@@ -219,12 +224,12 @@ mod tests {
224 Arc::new(TestTokenizer)
225 }
226
222 - fn render_prompt(
227 + fn backend_for_selection(
228 renderer: RendererSelection,
229 config_json: &str,
230 tokenizer_config_json: &str,
226 - ) -> String {
227 - let backend = HfChatBackend::from_resolved_model_files(
231 + ) -> HfChatBackend {
232 + HfChatBackend::from_resolved_model_files(
233 resolved_files(config_json, tokenizer_config_json),
234 "test-model".to_string(),
235 LoadModelBackendsOptions {
@@ -236,9 +241,15 @@ mod tests {
241 },
242 test_tokenizer(),
243 )
239 - .unwrap();
244 + .unwrap()
245 + }
246
241 - backend
247 + fn render_prompt(
248 + renderer: RendererSelection,
249 + config_json: &str,
250 + tokenizer_config_json: &str,
251 + ) -> String {
252 + backend_for_selection(renderer, config_json, tokenizer_config_json)
253 .chat_renderer()
254 .render(&request_with_user_text("hello"))
255 .unwrap()
@@ -272,6 +283,35 @@ mod tests {
283 assert_eq!(prompt, "hello");
284 }
285
286 + #[test]
287 + fn auto_uses_harmony_renderer_and_output_processor_for_gpt_oss_model_type() {
288 + let backend = backend_for_selection(
289 + RendererSelection::Auto,
290 + r#"{"model_type":"gpt_oss"}"#,
291 + r#"{"chat_template":"{{ messages[0].content }}"}"#,
292 + );
293 +
294 + let prompt =
295 + backend.chat_renderer().render(&request_with_user_text("hello")).unwrap().prompt;
296 + assert!(matches!(prompt, Prompt::TokenIds(_)));
297 +
298 + let mut request = request_with_user_text("hello");
299 + let error = match backend.new_chat_output_processor(
300 + &mut request,
301 + NewChatOutputProcessorOptions {
302 + tool_call_parser: &ParserSelection::Explicit("json".to_string()),
303 + reasoning_parser: &ParserSelection::Auto,
304 + },
305 + ) {
306 + Ok(_) => panic!("gpt_oss should reject generic parser overrides"),
307 + Err(error) => error,
308 + };
309 + assert_eq!(
310 + error.to_report_string(),
311 + "gpt_oss uses native Harmony output parsing; generic tool parser override `json` is not supported"
312 + );
313 + }
314 +
315 #[test]
316 fn language_model_only_skips_multimodal_preprocessor_config() {
317 let mut files = resolved_files(
rust/src/chat/src/lib.rs
+2 -2
@@ -29,8 +29,8 @@ pub use parser::reasoning::{
29 pub use parser::tool::{ToolParser, ToolParserError, ToolParserFactory};
30 pub use renderer::hf::ChatTemplateContentFormatOption;
31 pub use renderer::{
32 - ChatRenderer, DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer, RenderedPrompt,
33 - RendererSelection,
32 + ChatRenderer, DeepSeekV4ChatRenderer, DeepSeekV32ChatRenderer, DynChatRenderer,
33 + HarmonyChatRenderer, RenderedPrompt, RendererSelection,
34 };
35 pub use request::{
36 ChatContent, ChatContentPart, ChatMessage, ChatOptions, ChatRequest, ChatRole, ChatTool,
rust/src/chat/src/output/harmony/mod.rs
+2 -19
@@ -4,16 +4,10 @@
4 //! `DecodedTextEvent` token IDs directly and lets the official `openai-harmony`
5 //! parser recover the structured assistant message shape at token granularity.
6
7 -use std::sync::LazyLock;
8 -
9 -use anyhow::Context;
7 use asynk_strim_attr::{TryYielder, try_stream};
8 use futures::StreamExt as _;
9 use openai_harmony::chat::{Content as HarmonyContent, Message as HarmonyMessage, Role};
13 -use openai_harmony::{
14 - HarmonyEncoding, HarmonyEncodingName, StreamableParser, load_harmony_encoding,
15 -};
16 -use thiserror_ext::AsReport;
10 +use openai_harmony::{HarmonyEncoding, StreamableParser};
11 use vllm_text::output::DecodedTextEvent;
12
13 use crate::Result as ChatResult;
@@ -24,6 +18,7 @@ use crate::output::{
18 generate_tool_call_id,
19 };
20 use crate::parser::ParserSelection;
21 +use crate::renderer::harmony::encoding::harmony_encoding;
22 use crate::request::ChatRequest;
23
24 /// Request-scoped Harmony output processor used for `model_type == "gpt_oss"`.
@@ -384,18 +379,6 @@ async fn harmony_assistant_event_stream(
379 Ok(())
380 }
381
387 -/// Lazily load the shared GPT-OSS Harmony encoding once per process.
388 -fn harmony_encoding() -> Result<&'static HarmonyEncoding> {
389 - static ENCODING: LazyLock<anyhow::Result<HarmonyEncoding>> = LazyLock::new(|| {
390 - load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss)
391 - .context("failed to load harmony encoding for gpt-oss")
392 - });
393 -
394 - ENCODING.as_ref().map_err(|error| Error::HarmonyOutputParsing {
395 - error: error.to_report_string().into(),
396 - })
397 -}
398 -
382 fn harmony_output_parsing_error(
383 error: impl Into<Box<dyn std::error::Error + Send + Sync>>,
384 ) -> Error {
rust/src/chat/src/renderer/harmony/encoding.rs new
+21
@@ -0,0 +1,21 @@
1 +//! Shared Harmony encoding helper for the GPT-OSS renderer and output parser.
2 +
3 +use std::sync::LazyLock;
4 +
5 +use anyhow::Context as _;
6 +use openai_harmony::{HarmonyEncoding, HarmonyEncodingName, load_harmony_encoding};
7 +use thiserror_ext::AsReport as _;
8 +
9 +use crate::error::{Error, Result};
10 +
11 +/// Lazily load the shared GPT-OSS Harmony encoding once per process.
12 +pub(crate) fn harmony_encoding() -> Result<&'static HarmonyEncoding> {
13 + static ENCODING: LazyLock<anyhow::Result<HarmonyEncoding>> = LazyLock::new(|| {
14 + load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss)
15 + .context("failed to load harmony encoding for gpt-oss")
16 + });
17 +
18 + ENCODING.as_ref().map_err(|error| Error::HarmonyOutputParsing {
19 + error: error.to_report_string().into(),
20 + })
21 +}
rust/src/chat/src/renderer/harmony/fixtures/assistant_history.json new
+14
@@ -0,0 +1,14 @@
1 +{
2 + "add_generation_prompt": false,
3 + "messages": [
4 + {
5 + "role": "user",
6 + "content": "What is 2 + 2?"
7 + },
8 + {
9 + "role": "assistant",
10 + "reasoning_content": "Need simple arithmetic.",
11 + "content": "4"
12 + }
13 + ]
14 +}
rust/src/chat/src/renderer/harmony/fixtures/assistant_history.txt new
+7
@@ -0,0 +1,7 @@
1 +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.
2 +Knowledge cutoff: 2024-06
3 +Current date: 2025-06-28
4 +
5 +Reasoning: medium
6 +
7 +# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|><|start|>user<|message|>What is 2 + 2?<|end|><|start|>assistant<|channel|>final<|message|>4<|end|>
rust/src/chat/src/renderer/harmony/fixtures/developer_tools.json new
+27
@@ -0,0 +1,27 @@
1 +[
2 + {
3 + "role": "developer",
4 + "content": "Use tools when needed.",
5 + "tools": [
6 + {
7 + "function": {
8 + "name": "lookup",
9 + "description": "Lookup a record.",
10 + "parameters": {
11 + "type": "object",
12 + "properties": {
13 + "id": {
14 + "type": "string"
15 + }
16 + },
17 + "required": ["id"]
18 + }
19 + }
20 + }
21 + ]
22 + },
23 + {
24 + "role": "user",
25 + "content": "Find record abc."
26 + }
27 +]
rust/src/chat/src/renderer/harmony/fixtures/developer_tools.txt new
+23
@@ -0,0 +1,23 @@
1 +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.
2 +Knowledge cutoff: 2024-06
3 +Current date: 2025-06-28
4 +
5 +Reasoning: medium
6 +
7 +# Valid channels: analysis, commentary, final. Channel must be included for every message.
8 +Calls to these tools must go to the commentary channel: 'functions'.<|end|><|start|>developer<|message|># Instructions
9 +
10 +Use tools when needed.
11 +
12 +# Tools
13 +
14 +## functions
15 +
16 +namespace functions {
17 +
18 +// Lookup a record.
19 +type lookup = (_: {
20 +id: string,
21 +}) => any;
22 +
23 +} // namespace functions<|end|><|start|>user<|message|>Find record abc.<|end|><|start|>assistant
rust/src/chat/src/renderer/harmony/fixtures/drop_analysis.json new
+15
@@ -0,0 +1,15 @@
1 +[
2 + {
3 + "role": "user",
4 + "content": "What is 2 + 2?"
5 + },
6 + {
7 + "role": "assistant",
8 + "reasoning_content": "This should be dropped.",
9 + "content": "4"
10 + },
11 + {
12 + "role": "user",
13 + "content": "What is 3 + 5?"
14 + }
15 +]
rust/src/chat/src/renderer/harmony/fixtures/drop_analysis.txt new
+7
@@ -0,0 +1,7 @@
1 +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.
2 +Knowledge cutoff: 2024-06
3 +Current date: 2025-06-28
4 +
5 +Reasoning: medium
6 +
7 +# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|><|start|>user<|message|>What is 2 + 2?<|end|><|start|>assistant<|channel|>final<|message|>4<|end|><|start|>user<|message|>What is 3 + 5?<|end|><|start|>assistant
rust/src/chat/src/renderer/harmony/fixtures/leading_system.json new
+13
@@ -0,0 +1,13 @@
1 +{
2 + "reasoning_effort": "high",
3 + "messages": [
4 + {
5 + "role": "system",
6 + "content": "Answer tersely."
7 + },
8 + {
9 + "role": "user",
10 + "content": "What is 2 + 2?"
11 + }
12 + ]
13 +}
rust/src/chat/src/renderer/harmony/fixtures/leading_system.txt new
+9
@@ -0,0 +1,9 @@
1 +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.
2 +Knowledge cutoff: 2024-06
3 +Current date: 2025-06-28
4 +
5 +Reasoning: high
6 +
7 +# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|><|start|>developer<|message|># Instructions
8 +
9 +Answer tersely.<|end|><|start|>user<|message|>What is 2 + 2?<|end|><|start|>assistant
rust/src/chat/src/renderer/harmony/fixtures/request_tools.json new
+26
@@ -0,0 +1,26 @@
1 +{
2 + "tools": [
3 + {
4 + "function": {
5 + "name": "get_weather",
6 + "description": "Get weather for a city.",
7 + "parameters": {
8 + "type": "object",
9 + "properties": {
10 + "city": {
11 + "type": "string"
12 + }
13 + },
14 + "required": ["city"]
15 + },
16 + "strict": true
17 + }
18 + }
19 + ],
20 + "messages": [
21 + {
22 + "role": "user",
23 + "content": "Check Hangzhou weather."
24 + }
25 + ]
26 +}
rust/src/chat/src/renderer/harmony/fixtures/request_tools.txt new
+19
@@ -0,0 +1,19 @@
1 +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.
2 +Knowledge cutoff: 2024-06
3 +Current date: 2025-06-28
4 +
5 +Reasoning: medium
6 +
7 +# Valid channels: analysis, commentary, final. Channel must be included for every message.
8 +Calls to these tools must go to the commentary channel: 'functions'.<|end|><|start|>developer<|message|># Tools
9 +
10 +## functions
11 +
12 +namespace functions {
13 +
14 +// Get weather for a city.
15 +type get_weather = (_: {
16 +city: string,
17 +}) => any;
18 +
19 +} // namespace functions<|end|><|start|>user<|message|>Check Hangzhou weather.<|end|><|start|>assistant
rust/src/chat/src/renderer/harmony/fixtures/simple_user.json new
+6
@@ -0,0 +1,6 @@
1 +[
2 + {
3 + "role": "user",
4 + "content": "Hello, who are you?"
5 + }
6 +]
rust/src/chat/src/renderer/harmony/fixtures/simple_user.txt new
+7
@@ -0,0 +1,7 @@
1 +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.
2 +Knowledge cutoff: 2024-06
3 +Current date: 2025-06-28
4 +
5 +Reasoning: medium
6 +
7 +# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|><|start|>user<|message|>Hello, who are you?<|end|><|start|>assistant
rust/src/chat/src/renderer/harmony/fixtures/system_instructions_env.txt new
+8
@@ -0,0 +1,8 @@
1 +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.
2 +Answer tersely.
3 +Knowledge cutoff: 2024-06
4 +Current date: 2025-06-28
5 +
6 +Reasoning: high
7 +
8 +# Valid channels: analysis, commentary, final. Channel must be included for every message.<|end|><|start|>user<|message|>What is 2 + 2?<|end|><|start|>assistant
rust/src/chat/src/renderer/harmony/fixtures/tool_roundtrip.json new
+43
@@ -0,0 +1,43 @@
1 +{
2 + "tools": [
3 + {
4 + "function": {
5 + "name": "get_weather",
6 + "description": "Get weather for a city.",
7 + "parameters": {
8 + "type": "object",
9 + "properties": {
10 + "city": {
11 + "type": "string"
12 + }
13 + },
14 + "required": ["city"]
15 + }
16 + }
17 + }
18 + ],
19 + "messages": [
20 + {
21 + "role": "user",
22 + "content": "Check Hangzhou weather."
23 + },
24 + {
25 + "role": "assistant",
26 + "reasoning_content": "Need current weather.",
27 + "tool_calls": [
28 + {
29 + "id": "call-weather",
30 + "function": {
31 + "name": "get_weather",
32 + "arguments": "{\"city\":\"Hangzhou\"}"
33 + }
34 + }
35 + ]
36 + },
37 + {
38 + "role": "tool",
39 + "tool_call_id": "call-weather",
40 + "content": "{\"temperature\":20}"
41 + }
42 + ]
43 +}
rust/src/chat/src/renderer/harmony/fixtures/tool_roundtrip.txt new
+19
@@ -0,0 +1,19 @@
1 +<|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI.
2 +Knowledge cutoff: 2024-06
3 +Current date: 2025-06-28
4 +
5 +Reasoning: medium
6 +
7 +# Valid channels: analysis, commentary, final. Channel must be included for every message.
8 +Calls to these tools must go to the commentary channel: 'functions'.<|end|><|start|>developer<|message|># Tools
9 +
10 +## functions
11 +
12 +namespace functions {
13 +
14 +// Get weather for a city.
15 +type get_weather = (_: {
16 +city: string,
17 +}) => any;
18 +
19 +} // namespace functions<|end|><|start|>user<|message|>Check Hangzhou weather.<|end|><|start|>assistant<|channel|>analysis<|message|>Need current weather.<|end|><|start|>assistant<|channel|>commentary to=functions.get_weather <|constrain|>json<|message|>{"city":"Hangzhou"}<|call|><|start|>functions.get_weather<|channel|>commentary to=assistant<|message|>{"temperature":20}<|end|><|start|>assistant
rust/src/chat/src/renderer/harmony/mod.rs new
+487
@@ -0,0 +1,487 @@
1 +//! Native Harmony chat renderer for `gpt_oss`.
2 +
3 +pub(crate) mod encoding;
4 +
5 +use openai_harmony::HarmonyEncoding;
6 +use openai_harmony::chat::{
7 + Author, Conversation, DeveloperContent, Message, ReasoningEffort as HarmonyReasoningEffort,
8 + Role, SystemContent, ToolDescription,
9 +};
10 +use thiserror_ext::AsReport as _;
11 +use time::macros::format_description;
12 +use vllm_text::Prompt;
13 +
14 +use self::encoding::harmony_encoding;
15 +use super::{ChatRenderer, RenderedPrompt, request_template_kwargs};
16 +use crate::error::{Error, Result};
17 +use crate::event::AssistantContentBlock;
18 +use crate::request::{ChatContent, ChatMessage, ChatRequest, ChatTool, GenerationPromptMode};
19 +use crate::{AssistantMessageExt as _, ReasoningEffort};
20 +
21 +const SYSTEM_START_DATE_ENV: &str = "VLLM_SYSTEM_START_DATE";
22 +const HARMONY_SYSTEM_INSTRUCTIONS_ENV: &str = "VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS";
23 +
24 +/// GPT-OSS renderer backed by the official Harmony encoding.
25 +pub struct HarmonyChatRenderer {
26 + encoding: &'static HarmonyEncoding,
27 + options: Options,
28 +}
29 +
30 +struct Options {
31 + system_start_date: String,
32 + use_system_instructions: bool,
33 +}
34 +
35 +impl HarmonyChatRenderer {
36 + /// Create a Harmony renderer for production use.
37 + ///
38 + /// Environment-derived options are resolved once at construction time:
39 + ///
40 + /// - `VLLM_SYSTEM_START_DATE` pins the Harmony system start date. When it is
41 + /// unset, the renderer uses the current local date with a UTC fallback.
42 + /// - `VLLM_GPT_OSS_HARMONY_SYSTEM_INSTRUCTIONS` moves leading instructions
43 + /// into the system model identity when set to a non-zero integer.
44 + pub fn new() -> Result<Self> {
45 + Self::with_options(
46 + env_system_start_date(),
47 + env_use_harmony_system_instructions(),
48 + )
49 + }
50 +
51 + /// Create a Harmony renderer with explicit preamble options.
52 + ///
53 + /// Tests use this constructor to avoid process-global environment mutation.
54 + /// Production code should call [`Self::new`] so the renderer observes the
55 + /// same environment contract as the Python Harmony path.
56 + pub fn with_options(
57 + system_start_date: impl Into<String>,
58 + use_system_instructions: bool,
59 + ) -> Result<Self> {
60 + Ok(Self {
61 + encoding: harmony_encoding()?,
62 + options: Options {
63 + system_start_date: system_start_date.into(),
64 + use_system_instructions,
65 + },
66 + })
67 + }
68 +
69 + /// Render a chat request directly to Harmony token IDs.
70 + ///
71 + /// Harmony owns both prompt formatting and tokenization, so the Rust
72 + /// frontend bypasses the generic HF tokenizer path for GPT-OSS input.
73 + fn render_token_ids(&self, request: &ChatRequest) -> Result<Vec<u32>> {
74 + if request.has_multimodal() {
75 + return Err(Error::UnsupportedMultimodalContent("image_url"));
76 + }
77 + if matches!(
78 + request.chat_options.generation_prompt_mode,
79 + GenerationPromptMode::ContinueFinalAssistant
80 + ) {
81 + return Err(Error::ChatTemplate(
82 + "Harmony renderer does not support continue_final_message".to_string(),
83 + ));
84 + }
85 +
86 + let messages = auto_drop_analysis_messages(to_harmony_messages(request, &self.options)?);
87 + let conversation = Conversation::from_messages(messages);
88 + // Pass `None` so oss-harmony does not apply its narrower built-in
89 + // analysis-drop policy after the Rust-side Python-parity cleanup above.
90 + let token_ids = match request.chat_options.generation_prompt_mode {
91 + GenerationPromptMode::StartNewAssistant => self
92 + .encoding
93 + .render_conversation_for_completion(&conversation, Role::Assistant, None),
94 + GenerationPromptMode::NoGenerationPrompt => {
95 + self.encoding.render_conversation(&conversation, None)
96 + }
97 + GenerationPromptMode::ContinueFinalAssistant => unreachable!("checked above"),
98 + }
99 + .map_err(|error| {
100 + Error::ChatTemplate(format!(
101 + "failed to render Harmony prompt: {}",
102 + error.as_report()
103 + ))
104 + })?;
105 +
106 + Ok(token_ids)
107 + }
108 +}
109 +
110 +impl ChatRenderer for HarmonyChatRenderer {
111 + /// Render a chat request as [`Prompt::TokenIds`] with template kwargs echoed
112 + /// for downstream accounting/debugging.
113 + fn render(&self, request: &ChatRequest) -> Result<RenderedPrompt> {
114 + Ok(RenderedPrompt {
115 + prompt: Prompt::TokenIds(self.render_token_ids(request)?),
116 + effective_template_kwargs: request_template_kwargs(request),
117 + })
118 + }
119 +}
120 +
121 +/// Convert a vLLM chat request into a full Harmony conversation.
122 +///
123 +/// This adds the Harmony system/developer preamble, peels at most one leading
124 +/// system/developer instruction message, and then lowers the remaining chat
125 +/// history message-by-message.
126 +fn to_harmony_messages(request: &ChatRequest, options: &Options) -> Result<Vec<Message>> {
127 + let (instructions, leading_developer_tools, remaining_messages) =
128 + peel_leading_instructions(&request.messages)?;
129 + let tool_call_names = tool_call_names(&request.messages);
130 + let mut messages =
131 + build_harmony_preamble(request, instructions, leading_developer_tools, options)?;
132 +
133 + for message in remaining_messages {
134 + messages.extend(to_harmony_message(message, &tool_call_names, options)?);
135 + }
136 +
137 + Ok(messages)
138 +}
139 +
140 +/// Extract the optional leading instruction message used by the Harmony preamble.
141 +///
142 +/// Python only peels the first leading `system` or `developer` message. Later
143 +/// system/developer messages stay in the conversation and are lowered normally.
144 +#[allow(clippy::type_complexity)]
145 +fn peel_leading_instructions(
146 + messages: &[ChatMessage],
147 +) -> Result<(Option<String>, Option<&[ChatTool]>, &[ChatMessage])> {
148 + let Some(first) = messages.first() else {
149 + return Ok((None, None, messages));
150 + };
151 +
152 + match first {
153 + ChatMessage::System { content } => Ok((Some(flatten_text(content)?), None, &messages[1..])),
154 + ChatMessage::Developer { content, tools } => Ok((
155 + Some(flatten_text(content)?),
156 + tools.as_deref(),
157 + &messages[1..],
158 + )),
159 + ChatMessage::User { .. }
160 + | ChatMessage::Assistant { .. }
161 + | ChatMessage::ToolResponse { .. } => Ok((None, None, messages)),
162 + }
163 +}
164 +
165 +/// Build the Harmony preamble for one request.
166 +///
167 +/// The preamble always contains a system message with date and reasoning-effort
168 +/// metadata. Leading instructions live either in the system model identity or in
169 +/// a developer message depending on `use_system_instructions`; request-level and
170 +/// leading developer tools are attached to the developer message.
171 +fn build_harmony_preamble(
172 + request: &ChatRequest,
173 + instructions: Option<String>,
174 + leading_developer_tools: Option<&[ChatTool]>,
175 + options: &Options,
176 +) -> Result<Vec<Message>> {
177 + let mut messages = vec![Message::from_role_and_content(
178 + Role::System,
179 + system_content(
180 + instructions.as_deref().filter(|_| options.use_system_instructions),
181 + request.chat_options.reasoning_effort,
182 + &options.system_start_date,
183 + )?,
184 + )];
185 +
186 + let mut developer = DeveloperContent::new();
187 + let mut has_developer_content = false;
188 +
189 + if !options.use_system_instructions
190 + && let Some(instructions) = instructions.as_deref().filter(|text| !text.is_empty())
191 + {
192 + developer = developer.with_instructions(instructions);
193 + has_developer_content = true;
194 + }
195 +
196 + let tool_descriptions = preamble_tool_descriptions(request, leading_developer_tools);
197 + if !tool_descriptions.is_empty() {
198 + developer = developer.with_function_tools(tool_descriptions);
199 + has_developer_content = true;
200 + }
201 +
202 + if has_developer_content {
203 + messages.push(Message::from_role_and_content(Role::Developer, developer));
204 + }
205 +
206 + Ok(messages)
207 +}
208 +
209 +/// Collect request-level and leading developer function tools for the preamble.
210 +fn preamble_tool_descriptions(
211 + request: &ChatRequest,
212 + leading_developer_tools: Option<&[ChatTool]>,
213 +) -> Vec<ToolDescription> {
214 + let mut tools = Vec::new();
215 + if request.tool_parsing_enabled() {
216 + tools.extend(to_tool_descriptions(&request.tools));
217 + }
218 + if let Some(leading_developer_tools) = leading_developer_tools {
219 + tools.extend(to_tool_descriptions(leading_developer_tools));
220 + }
221 + tools
222 +}
223 +
224 +/// Construct the Harmony system content for the request preamble.
225 +///
226 +/// Harmony defaults the reasoning effort to `medium` when none is provided, so
227 +/// this only sets an explicit effort after validating vLLM's request value.
228 +fn system_content(
229 + instructions: Option<&str>,
230 + reasoning_effort: Option<ReasoningEffort>,
231 + system_start_date: &str,
232 +) -> Result<SystemContent> {
233 + let mut content =
234 + SystemContent::new().with_conversation_start_date(system_start_date.to_string());
235 +
236 + if let Some(reasoning_effort) = reasoning_effort {
237 + content = content.with_reasoning_effort(to_harmony_reasoning_effort(reasoning_effort)?);
238 + }
239 +
240 + if let Some(instructions) = instructions.filter(|text| !text.is_empty()) {
241 + let model_identity = match content.model_identity.as_deref() {
242 + Some(identity) if !identity.is_empty() => format!("{identity}\n{instructions}"),
243 + _ => instructions.to_string(),
244 + };
245 + content = content.with_model_identity(model_identity);
246 + }
247 +
248 + Ok(content)
249 +}
250 +
251 +/// Lower a single vLLM chat message into one or more Harmony messages.
252 +///
253 +/// Assistant messages can split into separate analysis, final, commentary, and
254 +/// tool-call messages. Tool responses require the earlier assistant tool-call ID
255 +/// map so the Harmony tool author can include `functions.{name}`.
256 +fn to_harmony_message(
257 + message: &ChatMessage,
258 + tool_call_names: &std::collections::HashMap<String, String>,
259 + options: &Options,
260 +) -> Result<Vec<Message>> {
261 + Ok(match message {
262 + ChatMessage::System { content } => {
263 + let instructions = flatten_text(content)?;
264 + vec![system_or_developer_message(
265 + "system",
266 + instructions,
267 + None,
268 + options,
269 + )?]
270 + }
271 + ChatMessage::Developer { content, tools } => {
272 + let instructions = flatten_text(content)?;
273 + vec![developer_message(Some(instructions), tools.as_deref())]
274 + }
275 + ChatMessage::User { content } => {
276 + vec![Message::from_role_and_content(
277 + Role::User,
278 + flatten_text(content)?,
279 + )]
280 + }
281 + ChatMessage::Assistant { content } => assistant_messages(content),
282 + ChatMessage::ToolResponse {
283 + content,
284 + tool_call_id,
285 + } => {
286 + let name = tool_call_names.get(tool_call_id).ok_or_else(|| {
287 + Error::ChatTemplate(format!(
288 + "invalid Harmony tool message: unknown tool_call_id `{tool_call_id}`"
289 + ))
290 + })?;
291 + vec![
292 + Message::from_author_and_content(
293 + Author::new(Role::Tool, format!("functions.{name}")),
294 + flatten_text(content)?,
295 + )
296 + .with_channel("commentary")
297 + .with_recipient("assistant"),
298 + ]
299 + }
300 + })
301 +}
302 +
303 +/// Lower a non-leading system/developer message.
304 +///
305 +/// Harmony treats most extra system/developer messages as developer
306 +/// instructions. When system-instructions mode is enabled, system messages are
307 +/// rendered as system model-identity additions to match Python.
308 +fn system_or_developer_message(
309 + role: &str,
310 + instructions: String,
311 + tools: Option<&[ChatTool]>,
312 + options: &Options,
313 +) -> Result<Message> {
314 + if role == "system" && options.use_system_instructions {
315 + return Ok(Message::from_role_and_content(
316 + Role::System,
317 + system_content(Some(&instructions), None, &options.system_start_date)?,
318 + ));
319 + }
320 +
321 + Ok(developer_message(Some(instructions), tools))
322 +}
323 +
324 +/// Build a Harmony developer message with optional instructions and function tools.
325 +fn developer_message(instructions: Option<String>, tools: Option<&[ChatTool]>) -> Message {
326 + let mut content = DeveloperContent::new();
327 + if let Some(instructions) = instructions.filter(|text| !text.is_empty()) {
328 + content = content.with_instructions(instructions);
329 + }
330 + if let Some(tools) = tools {
331 + let tools = to_tool_descriptions(tools);
332 + if !tools.is_empty() {
333 + content = content.with_function_tools(tools);
334 + }
335 + }
336 + Message::from_role_and_content(Role::Developer, content)
337 +}
338 +
339 +/// Lower assistant history into Harmony channels.
340 +///
341 +/// Plain assistant text goes to `final`. When the assistant has tool calls,
342 +/// visible text goes to `commentary`, reasoning goes to `analysis`, and each
343 +/// function call becomes a `commentary` message to `functions.{name}` with JSON
344 +/// constrained content.
345 +fn assistant_messages(content: &[AssistantContentBlock]) -> Vec<Message> {
346 + let mut messages = Vec::new();
347 + let has_tool_calls = content.has_tool_calls();
348 +
349 + if has_tool_calls {
350 + let text = content.text();
351 + if !text.is_empty() {
352 + messages.push(
353 + Message::from_role_and_content(Role::Assistant, text).with_channel("commentary"),
354 + );
355 + }
356 + }
357 +
358 + if let Some(reasoning) = content.reasoning() {
359 + messages.push(
360 + Message::from_role_and_content(Role::Assistant, reasoning).with_channel("analysis"),
361 + );
362 + }
363 +
364 + if has_tool_calls {
365 + for tool_call in content.tool_calls() {
366 + messages.push(
367 + Message::from_role_and_content(Role::Assistant, tool_call.arguments.clone())
368 + .with_channel("commentary")
369 + .with_recipient(format!("functions.{}", tool_call.name))
370 + .with_content_type("<|constrain|>json"),
371 + );
372 + }
373 + } else {
374 + let text = content.text();
375 + if !text.is_empty() {
376 + messages
377 + .push(Message::from_role_and_content(Role::Assistant, text).with_channel("final"));
378 + }
379 + }
380 +
381 + messages
382 +}
383 +
384 +/// Build the tool-call ID to function-name map used by later tool responses.
385 +fn tool_call_names(messages: &[ChatMessage]) -> std::collections::HashMap<String, String> {
386 + let mut names = std::collections::HashMap::new();
387 + for message in messages {
388 + let ChatMessage::Assistant { content } = message else {
389 + continue;
390 + };
391 + for tool_call in content.tool_calls() {
392 + names.insert(tool_call.id.clone(), tool_call.name.clone());
393 + }
394 + }
395 + names
396 +}
397 +
398 +/// Drop stale assistant analysis messages using vLLM Python's policy.
399 +///
400 +/// Once an assistant final message exists, earlier analysis messages represent
401 +/// chain-of-thought for completed turns and should not be replayed to the model.
402 +fn auto_drop_analysis_messages(messages: Vec<Message>) -> Vec<Message> {
403 + // Match vLLM Python's Harmony cleanup: once an assistant final message exists,
404 + // previous assistant analysis messages are stale chain-of-thought and should
405 + // be removed. oss-harmony can also drop analysis with `Some(Default::default())`,
406 + // but that built-in path only triggers when the last assistant message is final
407 + // and drops relative to the first final message, which misses longer multi-turn
408 + // histories with later user/tool turns.
409 + let Some(last_assistant_final_index) = messages.iter().rposition(|message| {
410 + message.author.role == Role::Assistant && message.channel.as_deref() == Some("final")
411 + }) else {
412 + return messages;
413 + };
414 +
415 + messages
416 + .into_iter()
417 + .enumerate()
418 + .filter_map(|(index, message)| {
419 + (index >= last_assistant_final_index || message.channel.as_deref() != Some("analysis"))
420 + .then_some(message)
421 + })
422 + .collect()
423 +}
424 +
425 +/// Flatten vLLM text content and reject unsupported multimodal parts.
426 +fn flatten_text(content: &ChatContent) -> Result<String> {
427 + content.try_flatten_to_text()
428 +}
429 +
430 +/// Convert vLLM function tool definitions to Harmony tool descriptions.
431 +fn to_tool_descriptions(tools: &[ChatTool]) -> Vec<ToolDescription> {
432 + tools
433 + .iter()
434 + .map(|tool| {
435 + ToolDescription::new(
436 + tool.name.clone(),
437 + tool.description.clone().unwrap_or_default(),
438 + Some(tool.parameters.clone()),
439 + )
440 + })
441 + .collect()
442 +}
443 +
444 +/// Map supported OpenAI reasoning-effort values onto Harmony's enum.
445 +fn to_harmony_reasoning_effort(
446 + reasoning_effort: ReasoningEffort,
447 +) -> Result<HarmonyReasoningEffort> {
448 + match reasoning_effort {
449 + ReasoningEffort::Low => Ok(HarmonyReasoningEffort::Low),
450 + ReasoningEffort::Medium => Ok(HarmonyReasoningEffort::Medium),
451 + ReasoningEffort::High => Ok(HarmonyReasoningEffort::High),
452 + ReasoningEffort::None
453 + | ReasoningEffort::Minimal
454 + | ReasoningEffort::XHigh
455 + | ReasoningEffort::Max => Err(Error::ChatTemplate(format!(
456 + "reasoning_effort={:?} is not supported by Harmony. Supported values are: low, medium, high.",
457 + reasoning_effort.as_str()
458 + ))),
459 + }
460 +}
461 +
462 +/// Resolve the system start date from the environment or the current date.
463 +fn env_system_start_date() -> String {
464 + std::env::var(SYSTEM_START_DATE_ENV)
465 + .ok()
466 + .filter(|date| !date.is_empty())
467 + .unwrap_or_else(current_date)
468 +}
469 +
470 +/// Format today's date as `YYYY-MM-DD`, preferring local time.
471 +fn current_date() -> String {
472 + const DATE_FORMAT: &[time::format_description::FormatItem<'static>] =
473 + format_description!("[year]-[month]-[day]");
474 + let now = time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
475 + now.format(DATE_FORMAT).expect("static date format should be valid")
476 +}
477 +
478 +/// Resolve the env flag that places leading instructions in system identity.
479 +fn env_use_harmony_system_instructions() -> bool {
480 + std::env::var(HARMONY_SYSTEM_INSTRUCTIONS_ENV)
481 + .ok()
482 + .and_then(|value| value.parse::<i32>().ok())
483 + .is_some_and(|value| value != 0)
484 +}
485 +
486 +#[cfg(test)]
487 +mod tests;
rust/src/chat/src/renderer/harmony/tests.rs new
+212
@@ -0,0 +1,212 @@
1 +use std::path::PathBuf;
2 +
3 +use expect_test::{ExpectFile, expect, expect_file};
4 +use thiserror_ext::AsReport as _;
5 +
6 +use super::HarmonyChatRenderer;
7 +use super::encoding::harmony_encoding;
8 +use crate::ChatRenderer;
9 +use crate::error::Error;
10 +use crate::event::{AssistantContentBlock, AssistantToolCall};
11 +use crate::renderer::test_utils::{FixtureRequestOptions, fixture_chat_request};
12 +use crate::request::{
13 + ChatContentPart, ChatMessage, ChatRequest, GenerationPromptMode, ReasoningEffort,
14 +};
15 +
16 +const PINNED_DATE: &str = "2025-06-28";
17 +
18 +fn fixture_request(input_name: &str) -> ChatRequest {
19 + fixture_chat_request(
20 + &fixture_path(input_name),
21 + FixtureRequestOptions {
22 + enable_thinking: false,
23 + no_generation_prompt_when_last_assistant: false,
24 + },
25 + )
26 +}
27 +
28 +fn fixture_path(name: &str) -> PathBuf {
29 + PathBuf::from(env!("CARGO_MANIFEST_DIR"))
30 + .join("src/renderer/harmony")
31 + .join("fixtures")
32 + .join(name)
33 +}
34 +
35 +fn test_renderer(use_system_instructions: bool) -> HarmonyChatRenderer {
36 + HarmonyChatRenderer::with_options(PINNED_DATE, use_system_instructions).unwrap()
37 +}
38 +
39 +fn render_token_ids(request: &ChatRequest) -> Vec<u32> {
40 + render_token_ids_with(&test_renderer(false), request)
41 +}
42 +
43 +fn render_token_ids_with(renderer: &HarmonyChatRenderer, request: &ChatRequest) -> Vec<u32> {
44 + renderer
45 + .render(request)
46 + .unwrap()
47 + .prompt
48 + .into_token_ids()
49 + .expect("Harmony renderer returns token IDs")
50 +}
51 +
52 +fn render_prompt_text(request: &ChatRequest) -> String {
53 + render_prompt_text_with(&test_renderer(false), request)
54 +}
55 +
56 +fn render_prompt_text_with(renderer: &HarmonyChatRenderer, request: &ChatRequest) -> String {
57 + let token_ids = render_token_ids_with(renderer, request);
58 + harmony_encoding().unwrap().tokenizer().decode_utf8(&token_ids).unwrap()
59 +}
60 +
61 +fn assert_fixture(input_name: &str, expected: ExpectFile) {
62 + let request = fixture_request(input_name);
63 + let rendered = format!("{}\n", render_prompt_text(&request));
64 + expected.assert_eq(&rendered);
65 +}
66 +
67 +#[test]
68 +fn renders_token_ids() {
69 + let request = fixture_request("simple_user.json");
70 +
71 + assert!(!render_token_ids(&request).is_empty());
72 +}
73 +
74 +#[test]
75 +fn renders_simple_user_fixture() {
76 + assert_fixture("simple_user.json", expect_file!["fixtures/simple_user.txt"]);
77 +}
78 +
79 +#[test]
80 +fn renders_leading_system_fixture() {
81 + assert_fixture(
82 + "leading_system.json",
83 + expect_file!["fixtures/leading_system.txt"],
84 + );
85 +}
86 +
87 +#[test]
88 +fn renders_system_instructions_env_fixture() {
89 + let renderer = test_renderer(true);
90 + let request = fixture_request("leading_system.json");
91 + let rendered = format!("{}\n", render_prompt_text_with(&renderer, &request));
92 + expect_file!["fixtures/system_instructions_env.txt"].assert_eq(&rendered);
93 +}
94 +
95 +#[test]
96 +fn renders_request_tools_fixture() {
97 + assert_fixture(
98 + "request_tools.json",
99 + expect_file!["fixtures/request_tools.txt"],
100 + );
101 +}
102 +
103 +#[test]
104 +fn renders_developer_tools_fixture() {
105 + assert_fixture(
106 + "developer_tools.json",
107 + expect_file!["fixtures/developer_tools.txt"],
108 + );
109 +}
110 +
111 +#[test]
112 +fn renders_assistant_history_fixture() {
113 + assert_fixture(
114 + "assistant_history.json",
115 + expect_file!["fixtures/assistant_history.txt"],
116 + );
117 +}
118 +
119 +#[test]
120 +fn renders_tool_roundtrip_fixture() {
121 + assert_fixture(
122 + "tool_roundtrip.json",
123 + expect_file!["fixtures/tool_roundtrip.txt"],
124 + );
125 +}
126 +
127 +#[test]
128 +fn drops_stale_analysis_fixture() {
129 + assert_fixture(
130 + "drop_analysis.json",
131 + expect_file!["fixtures/drop_analysis.txt"],
132 + );
133 +}
134 +
135 +#[test]
136 +fn rejects_invalid_reasoning_effort() {
137 + let mut request = ChatRequest::for_test();
138 + request.chat_options.reasoning_effort = Some(ReasoningEffort::None);
139 +
140 + let error = test_renderer(false).render(&request).unwrap_err();
141 +
142 + expect![[r#"chat template error: reasoning_effort="none" is not supported by Harmony. Supported values are: low, medium, high."#]]
143 + .assert_eq(&error.to_report_string());
144 +}
145 +
146 +#[test]
147 +fn rejects_unknown_tool_response_id() {
148 + let request = ChatRequest {
149 + messages: vec![
150 + ChatMessage::assistant_blocks(vec![AssistantContentBlock::ToolCall(
151 + AssistantToolCall {
152 + id: "call-known".to_string(),
153 + name: "lookup".to_string(),
154 + arguments: "{}".to_string(),
155 + },
156 + )]),
157 + ChatMessage::tool_response("{}", "call-unknown"),
158 + ],
159 + ..ChatRequest::for_test()
160 + };
161 +
162 + let error = test_renderer(false).render(&request).unwrap_err();
163 +
164 + expect![
165 + "chat template error: invalid Harmony tool message: unknown tool_call_id `call-unknown`"
166 + ]
167 + .assert_eq(&error.to_report_string());
168 +}
169 +
170 +#[test]
171 +fn rejects_multimodal_input() {
172 + let request = ChatRequest {
173 + messages: vec![ChatMessage::user(vec![ChatContentPart::image_url(
174 + "data:image/png;base64,test",
175 + )])],
176 + ..ChatRequest::for_test()
177 + };
178 +
179 + let error = test_renderer(false).render(&request).unwrap_err();
180 +
181 + assert!(matches!(
182 + error,
183 + Error::UnsupportedMultimodalContent("image_url")
184 + ));
185 +}
186 +
187 +#[test]
188 +fn rejects_continue_final_assistant() {
189 + let mut request = ChatRequest {
190 + messages: vec![
191 + ChatMessage::user("write"),
192 + ChatMessage::assistant_text("partial"),
193 + ],
194 + ..ChatRequest::for_test()
195 + };
196 + request.chat_options.generation_prompt_mode = GenerationPromptMode::ContinueFinalAssistant;
197 +
198 + let error = test_renderer(false).render(&request).unwrap_err();
199 +
200 + expect!["chat template error: Harmony renderer does not support continue_final_message"]
201 + .assert_eq(&error.to_report_string());
202 +}
203 +
204 +#[test]
205 +fn no_generation_prompt_omits_trailing_assistant_start() {
206 + let mut request = fixture_request("simple_user.json");
207 + request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
208 +
209 + let rendered = render_prompt_text(&request);
210 +
211 + assert!(!rendered.ends_with("<|start|>assistant"));
212 +}
rust/src/chat/src/renderer/mod.rs
+2
@@ -9,6 +9,7 @@ use crate::request::{ChatRequest, ReasoningEffort};
9
10 pub mod deepseek_v32;
11 pub mod deepseek_v4;
12 +pub mod harmony;
13 pub mod hf;
14 mod selection;
15 #[cfg(test)]
@@ -16,6 +17,7 @@ mod test_utils;
17
18 pub use deepseek_v4::DeepSeekV4ChatRenderer;
19 pub use deepseek_v32::DeepSeekV32ChatRenderer;
20 +pub use harmony::HarmonyChatRenderer;
21 pub use selection::RendererSelection;
22
23 /// Rendered chat prompt submitted to the text backend.
rust/src/chat/src/renderer/selection.rs
+9 -1
@@ -19,12 +19,16 @@ pub enum RendererSelection {
19 DeepSeekV32,
20 /// Force the DeepSeek V4 renderer.
21 DeepSeekV4,
22 + /// Force the GPT-OSS Harmony renderer.
23 + Harmony,
24 }
25
26 impl RendererSelection {
27 pub const AUTO_LITERAL: &str = "auto";
28 pub const DEEPSEEK_V32_LITERAL: &str = "deepseek_v32";
29 pub const DEEPSEEK_V4_LITERAL: &str = "deepseek_v4";
30 + pub const GPT_OSS_MODEL_TYPE: &str = "gpt_oss";
31 + pub const HARMONY_LITERAL: &str = "harmony";
32 pub const HF_LITERAL: &str = "hf";
33
34 /// Resolve the renderer selection using the given model type string, if
@@ -34,6 +38,7 @@ impl RendererSelection {
38 Self::Auto => match model_type {
39 Self::DEEPSEEK_V32_LITERAL => Self::DeepSeekV32,
40 Self::DEEPSEEK_V4_LITERAL => Self::DeepSeekV4,
41 + Self::GPT_OSS_MODEL_TYPE => Self::Harmony,
42 _ => Self::Hf,
43 },
44 selection => selection,
@@ -53,6 +58,8 @@ impl FromStr for RendererSelection {
58 Ok(Self::DeepSeekV32)
59 } else if value.eq_ignore_ascii_case(Self::DEEPSEEK_V4_LITERAL) {
60 Ok(Self::DeepSeekV4)
61 + } else if value.eq_ignore_ascii_case(Self::HARMONY_LITERAL) {
62 + Ok(Self::Harmony)
63 } else {
64 Err(format!(
65 "unknown renderer `{value}` (expected one of: {})",
@@ -69,6 +76,7 @@ impl fmt::Display for RendererSelection {
76 Self::Hf => f.write_str(Self::HF_LITERAL),
77 Self::DeepSeekV32 => f.write_str(Self::DEEPSEEK_V32_LITERAL),
78 Self::DeepSeekV4 => f.write_str(Self::DEEPSEEK_V4_LITERAL),
79 + Self::Harmony => f.write_str(Self::HARMONY_LITERAL),
80 }
81 }
82 }
@@ -95,7 +103,7 @@ mod tests {
103 fn renderer_selection_expected_error_message() {
104 let err = RendererSelection::from_str("unknown").unwrap_err();
105 expect_test::expect![
98 - "unknown renderer `unknown` (expected one of: auto, hf, deepseek_v32, deepseek_v4)"
106 + "unknown renderer `unknown` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony)"
107 ]
108 .assert_eq(&err);
109 }
rust/src/chat/src/renderer/test_utils.rs
+4 -1
@@ -7,7 +7,7 @@ use serde_json::Value;
7 use crate::event::{AssistantContentBlock, AssistantToolCall};
8 use crate::request::{
9 ChatContent, ChatContentPart, ChatMessage, ChatRequest, ChatTool, ChatToolChoice,
10 - GenerationPromptMode,
10 + GenerationPromptMode, ReasoningEffort,
11 };
12
13 /// Options for constructing a [`ChatRequest`] from a fixture file.
@@ -42,6 +42,7 @@ pub(crate) struct FixtureRequest {
42 tools: Vec<FixtureTool>,
43 messages: Vec<FixtureMessage>,
44 add_generation_prompt: Option<bool>,
45 + reasoning_effort: Option<ReasoningEffort>,
46 }
47
48 impl FixtureFile {
@@ -52,6 +53,7 @@ impl FixtureFile {
53 tools: Vec::new(),
54 messages,
55 add_generation_prompt: None,
56 + reasoning_effort: None,
57 },
58 }
59 }
@@ -154,6 +156,7 @@ impl FixtureRequest {
156 if self.add_generation_prompt == Some(false) {
157 request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
158 }
159 + request.chat_options.reasoning_effort = self.reasoning_effort;
160 if options.enable_thinking {
161 for key in ["thinking", "enable_thinking"] {
162 request.chat_options.template_kwargs.insert(key.to_string(), Value::Bool(true));
rust/src/cmd/src/cli/tests.rs
+1 -1
@@ -468,7 +468,7 @@ fn serve_args_reject_unknown_renderer_value() {
468 .unwrap_err();
469
470 expect![[r#"
471 - error: invalid value 'definitely_missing' for '--tokenizer-mode <RENDERER>': unknown renderer `definitely_missing` (expected one of: auto, hf, deepseek_v32, deepseek_v4)
471 + error: invalid value 'definitely_missing' for '--tokenizer-mode <RENDERER>': unknown renderer `definitely_missing` (expected one of: auto, hf, deepseek_v32, deepseek_v4, harmony)
472
473 For more information, try '--help'.
474 "#]]