[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
"#]]