1use serde::{Deserialize, Serialize};
8
9use crate::{FerrumError, Result};
10
11const START: &str = "<|start|>";
12const END: &str = "<|end|>";
13const MESSAGE: &str = "<|message|>";
14const CHANNEL: &str = "<|channel|>";
15const CONSTRAIN: &str = "<|constrain|>";
16const CALL: &str = "<|call|>";
17const RETURN: &str = "<|return|>";
18const FUNCTION_RECIPIENT_PREFIX: &str = "functions.";
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct HarmonyToolCall {
23 pub name: String,
24 pub arguments_json: String,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30pub struct ParsedHarmonyResponse {
31 pub reasoning_content: Option<String>,
32 pub content: String,
33 pub tool_call: Option<HarmonyToolCall>,
34}
35
36pub fn parse_harmony_response(output: &str) -> Result<ParsedHarmonyResponse> {
47 parse_harmony_response_internal(output, false)
48}
49
50pub fn parse_length_truncated_harmony_response(output: &str) -> Result<ParsedHarmonyResponse> {
56 parse_harmony_response_internal(output, true)
57}
58
59fn parse_harmony_response_internal(
60 output: &str,
61 allow_missing_text_terminal: bool,
62) -> Result<ParsedHarmonyResponse> {
63 let first = parse_message(output, true, allow_missing_text_terminal)?;
64 match first.channel {
65 HarmonyChannel::Final => {
66 validate_plain_message(&first, "final")?;
67 require_text_terminal(&first, HarmonyTerminal::Return, allow_missing_text_terminal)?;
68 require_no_trailing_output(&first)?;
69 Ok(ParsedHarmonyResponse {
70 reasoning_content: None,
71 content: first.payload.to_string(),
72 tool_call: None,
73 })
74 }
75 HarmonyChannel::Analysis => {
76 validate_plain_message(&first, "analysis")?;
77 if first.terminal.is_none() && allow_missing_text_terminal {
78 return Ok(ParsedHarmonyResponse {
79 reasoning_content: Some(first.payload.to_string()),
80 content: String::new(),
81 tool_call: None,
82 });
83 }
84 require_terminal(&first, HarmonyTerminal::End)?;
85 if first.remaining.is_empty() {
86 if allow_missing_text_terminal {
87 return Ok(ParsedHarmonyResponse {
88 reasoning_content: Some(first.payload.to_string()),
89 content: String::new(),
90 tool_call: None,
91 });
92 }
93 return Err(invalid_harmony(
94 "analysis message was not followed by a terminal final answer or tool call",
95 ));
96 }
97
98 if allow_missing_text_terminal && is_length_truncated_followup_envelope(first.remaining)
99 {
100 return Ok(ParsedHarmonyResponse {
101 reasoning_content: Some(first.payload.to_string()),
102 content: String::new(),
103 tool_call: None,
104 });
105 }
106
107 let second = parse_message(first.remaining, false, allow_missing_text_terminal)?;
108 match second.channel {
109 HarmonyChannel::Final => {
110 validate_plain_message(&second, "final")?;
111 require_text_terminal(
112 &second,
113 HarmonyTerminal::Return,
114 allow_missing_text_terminal,
115 )?;
116 require_no_trailing_output(&second)?;
117 Ok(ParsedHarmonyResponse {
118 reasoning_content: Some(first.payload.to_string()),
119 content: second.payload.to_string(),
120 tool_call: None,
121 })
122 }
123 HarmonyChannel::Commentary => {
124 let tool_call = parse_tool_call(&second)?;
125 require_terminal(&second, HarmonyTerminal::Call)?;
126 require_no_trailing_output(&second)?;
127 Ok(ParsedHarmonyResponse {
128 reasoning_content: Some(first.payload.to_string()),
129 content: String::new(),
130 tool_call: Some(tool_call),
131 })
132 }
133 HarmonyChannel::Analysis => Err(invalid_harmony(
134 "only one analysis message is supported before the terminal response",
135 )),
136 }
137 }
138 HarmonyChannel::Commentary => Err(invalid_harmony(
139 "a commentary tool call must follow one complete analysis message",
140 )),
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum HarmonyChannel {
146 Analysis,
147 Commentary,
148 Final,
149}
150
151impl HarmonyChannel {
152 fn parse(value: &str) -> Result<Self> {
153 match value {
154 "analysis" => Ok(Self::Analysis),
155 "commentary" => Ok(Self::Commentary),
156 "final" => Ok(Self::Final),
157 _ => Err(invalid_harmony(format!(
158 "unsupported Harmony channel {value:?}"
159 ))),
160 }
161 }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165enum HarmonyTerminal {
166 End,
167 Call,
168 Return,
169}
170
171impl HarmonyTerminal {
172 const fn text(self) -> &'static str {
173 match self {
174 Self::End => END,
175 Self::Call => CALL,
176 Self::Return => RETURN,
177 }
178 }
179}
180
181#[derive(Debug)]
182struct ParsedMessage<'a> {
183 channel: HarmonyChannel,
184 recipient: Option<&'a str>,
185 content_type: Option<&'a str>,
186 payload: &'a str,
187 terminal: Option<HarmonyTerminal>,
188 remaining: &'a str,
189}
190
191fn parse_message(
192 output: &str,
193 first: bool,
194 allow_missing_terminal: bool,
195) -> Result<ParsedMessage<'_>> {
196 if output.is_empty() {
197 return Err(invalid_harmony("Harmony output is empty"));
198 }
199
200 let (role_recipient, after_channel_marker) =
201 if let Some(after_start) = output.strip_prefix(START) {
202 let channel_offset = after_start.find(CHANNEL).ok_or_else(|| {
203 invalid_harmony("message start was not followed by a channel marker")
204 })?;
205 let role_header = &after_start[..channel_offset];
206 reject_raw_marker(role_header, "assistant role header")?;
207 let recipient = parse_role_header(role_header)?;
208 (recipient, &after_start[channel_offset + CHANNEL.len()..])
209 } else if first {
210 let after_channel = output
211 .strip_prefix(CHANNEL)
212 .ok_or_else(|| invalid_harmony("first message must begin with a channel marker"))?;
213 (None, after_channel)
214 } else {
215 return Err(invalid_harmony(
216 "a message after analysis must begin with <|start|>assistant",
217 ));
218 };
219
220 let message_offset = after_channel_marker.find(MESSAGE);
221 let channel_header = message_offset
222 .map(|offset| &after_channel_marker[..offset])
223 .unwrap_or(after_channel_marker);
224 let (channel, channel_recipient, content_type) = parse_channel_header(channel_header)?;
225 let recipient = match (role_recipient, channel_recipient) {
226 (Some(_), Some(_)) => {
227 return Err(invalid_harmony(
228 "recipient was repeated in both role and channel headers",
229 ));
230 }
231 (Some(recipient), None) | (None, Some(recipient)) => Some(recipient),
232 (None, None) => None,
233 };
234
235 let Some(message_offset) = message_offset else {
236 if allow_missing_terminal
237 && matches!(channel, HarmonyChannel::Analysis | HarmonyChannel::Final)
238 && recipient.is_none()
239 && content_type.is_none()
240 {
241 return Ok(ParsedMessage {
242 channel,
243 recipient,
244 content_type,
245 payload: "",
246 terminal: None,
247 remaining: "",
248 });
249 }
250 return Err(invalid_harmony(
251 "channel header was not followed by a message marker",
252 ));
253 };
254
255 let after_message = &after_channel_marker[message_offset + MESSAGE.len()..];
256 let Some(terminal_offset) = after_message.find("<|") else {
257 reject_raw_marker(after_message, "message payload")?;
258 if !allow_missing_terminal {
259 return Err(invalid_harmony(
260 "message is missing a terminal control token",
261 ));
262 }
263 return Ok(ParsedMessage {
264 channel,
265 recipient,
266 content_type,
267 payload: after_message,
268 terminal: None,
269 remaining: "",
270 });
271 };
272 let payload = &after_message[..terminal_offset];
273 reject_raw_marker(payload, "message payload")?;
274 let terminal_and_remaining = &after_message[terminal_offset..];
275 let (terminal, remaining) = if let Some(remaining) = terminal_and_remaining.strip_prefix(END) {
276 (Some(HarmonyTerminal::End), remaining)
277 } else if let Some(remaining) = terminal_and_remaining.strip_prefix(CALL) {
278 (Some(HarmonyTerminal::Call), remaining)
279 } else if let Some(remaining) = terminal_and_remaining.strip_prefix(RETURN) {
280 (Some(HarmonyTerminal::Return), remaining)
281 } else {
282 return Err(invalid_harmony(
283 "message payload contains an unknown or misplaced raw control marker",
284 ));
285 };
286
287 Ok(ParsedMessage {
288 channel,
289 recipient,
290 content_type,
291 payload,
292 terminal,
293 remaining,
294 })
295}
296
297fn is_length_truncated_followup_envelope(output: &str) -> bool {
298 output == START
299 || output == concat!("<|start|>", "assistant")
300 || output == concat!("<|start|>", "assistant", "<|channel|>")
301}
302
303fn parse_role_header(header: &str) -> Result<Option<&str>> {
304 let mut parts = header.split_ascii_whitespace();
305 if parts.next() != Some("assistant") {
306 return Err(invalid_harmony(
307 "generated Harmony messages must have the assistant role",
308 ));
309 }
310 let recipient = parts.next().map(parse_recipient_token).transpose()?;
311 if parts.next().is_some() {
312 return Err(invalid_harmony(
313 "assistant role header contains unsupported metadata",
314 ));
315 }
316 Ok(recipient)
317}
318
319fn parse_channel_header(header: &str) -> Result<(HarmonyChannel, Option<&str>, Option<&str>)> {
320 let mut constrain_parts = header.split(CONSTRAIN);
321 let channel_part = constrain_parts.next().unwrap_or_default();
322 let content_type = constrain_parts.next().map(str::trim);
323 if constrain_parts.next().is_some() {
324 return Err(invalid_harmony(
325 "channel header contains repeated constrain markers",
326 ));
327 }
328 reject_raw_marker(channel_part, "channel header")?;
329 if let Some(content_type) = content_type {
330 reject_raw_marker(content_type, "content type")?;
331 if content_type.is_empty() {
332 return Err(invalid_harmony("constrain marker has no content type"));
333 }
334 }
335
336 let mut parts = channel_part.split_ascii_whitespace();
337 let channel = parts
338 .next()
339 .ok_or_else(|| invalid_harmony("channel marker has no channel value"))?;
340 let channel = HarmonyChannel::parse(channel)?;
341 let recipient = parts.next().map(parse_recipient_token).transpose()?;
342 if parts.next().is_some() {
343 return Err(invalid_harmony(
344 "channel header contains unsupported metadata",
345 ));
346 }
347 Ok((channel, recipient, content_type))
348}
349
350fn parse_recipient_token(token: &str) -> Result<&str> {
351 let recipient = token
352 .strip_prefix("to=")
353 .ok_or_else(|| invalid_harmony("recipient metadata must use the to= form"))?;
354 if recipient.is_empty() {
355 return Err(invalid_harmony("recipient must not be empty"));
356 }
357 Ok(recipient)
358}
359
360fn parse_tool_call(message: &ParsedMessage<'_>) -> Result<HarmonyToolCall> {
361 let recipient = message
362 .recipient
363 .ok_or_else(|| invalid_harmony("commentary tool call has no recipient"))?;
364 let name = recipient
365 .strip_prefix(FUNCTION_RECIPIENT_PREFIX)
366 .ok_or_else(|| invalid_harmony(format!("unknown tool recipient {recipient:?}")))?;
367 if name.is_empty() {
368 return Err(invalid_harmony("tool name must not be empty"));
369 }
370 if let Some(content_type) = message.content_type {
371 if content_type != "json" {
372 return Err(invalid_harmony(format!(
373 "tool arguments must use the json content type, got {content_type:?}"
374 )));
375 }
376 }
377
378 let arguments_json = message.payload.trim();
379 let arguments: serde_json::Value = serde_json::from_str(arguments_json)
380 .map_err(|error| invalid_harmony(format!("tool arguments are not valid JSON: {error}")))?;
381 if !arguments.is_object() {
382 return Err(invalid_harmony("tool arguments must be a JSON object"));
383 }
384
385 Ok(HarmonyToolCall {
386 name: name.to_string(),
387 arguments_json: arguments_json.to_string(),
388 })
389}
390
391fn validate_plain_message(message: &ParsedMessage<'_>, channel: &str) -> Result<()> {
392 if message.recipient.is_some() {
393 return Err(invalid_harmony(format!(
394 "{channel} channel must not contain a recipient"
395 )));
396 }
397 if message.content_type.is_some() {
398 return Err(invalid_harmony(format!(
399 "{channel} channel must not contain a constrain marker"
400 )));
401 }
402 Ok(())
403}
404
405fn require_terminal(message: &ParsedMessage<'_>, expected: HarmonyTerminal) -> Result<()> {
406 if message.terminal != Some(expected) {
407 return Err(invalid_harmony(format!(
408 "{} channel must end with {}, got {}",
409 match message.channel {
410 HarmonyChannel::Analysis => "analysis",
411 HarmonyChannel::Commentary => "commentary",
412 HarmonyChannel::Final => "final",
413 },
414 expected.text(),
415 message
416 .terminal
417 .map(HarmonyTerminal::text)
418 .unwrap_or("no terminal token"),
419 )));
420 }
421 Ok(())
422}
423
424fn require_text_terminal(
425 message: &ParsedMessage<'_>,
426 expected: HarmonyTerminal,
427 allow_missing: bool,
428) -> Result<()> {
429 if allow_missing && message.terminal.is_none() {
430 return Ok(());
431 }
432 require_terminal(message, expected)
433}
434
435fn require_no_trailing_output(message: &ParsedMessage<'_>) -> Result<()> {
436 if !message.remaining.is_empty() {
437 return Err(invalid_harmony(
438 "terminal control token was followed by duplicate terminal data or trailing garbage",
439 ));
440 }
441 Ok(())
442}
443
444fn reject_raw_marker(value: &str, location: &str) -> Result<()> {
445 if value.contains("<|") || value.contains("|>") {
446 return Err(invalid_harmony(format!(
447 "{location} contains a raw or incomplete control marker"
448 )));
449 }
450 Ok(())
451}
452
453fn invalid_harmony(message: impl Into<String>) -> FerrumError {
454 FerrumError::invalid_format(format!(
455 "invalid GPT-OSS Harmony output: {}",
456 message.into()
457 ))
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn parses_direct_final_response() {
466 let parsed =
467 parse_harmony_response("<|channel|>final<|message|>The capital is Paris.<|return|>")
468 .unwrap();
469
470 assert_eq!(
471 parsed,
472 ParsedHarmonyResponse {
473 reasoning_content: None,
474 content: "The capital is Paris.".to_string(),
475 tool_call: None,
476 }
477 );
478 }
479
480 #[test]
481 fn parses_analysis_then_final_response() {
482 let parsed = parse_harmony_response(
483 "<|channel|>analysis<|message|>Need the capital.<|end|>\
484 <|start|>assistant<|channel|>final<|message|>Paris.<|return|>",
485 )
486 .unwrap();
487
488 assert_eq!(
489 parsed.reasoning_content.as_deref(),
490 Some("Need the capital.")
491 );
492 assert_eq!(parsed.content, "Paris.");
493 assert_eq!(parsed.tool_call, None);
494 }
495
496 #[test]
497 fn parses_length_truncated_text_messages_without_weakening_strict_parser() {
498 for (output, reasoning, content) in [
499 ("<|channel|>final", None, ""),
500 ("<|channel|>analysis", Some(""), ""),
501 (
502 "<|channel|>final<|message|>Partial answer",
503 None,
504 "Partial answer",
505 ),
506 (
507 "<|channel|>analysis<|message|>Partial reasoning",
508 Some("Partial reasoning"),
509 "",
510 ),
511 (
512 "<|channel|>analysis<|message|>Reason.<|end|>",
513 Some("Reason."),
514 "",
515 ),
516 (
517 "<|channel|>analysis<|message|>Reason.<|end|>\
518 <|start|>assistant<|channel|>final",
519 Some("Reason."),
520 "",
521 ),
522 (
523 "<|channel|>analysis<|message|>Reason.<|end|>\
524 <|start|>assistant<|channel|>final<|message|>Partial answer",
525 Some("Reason."),
526 "Partial answer",
527 ),
528 ] {
529 assert!(parse_harmony_response(output).is_err());
530 let parsed = parse_length_truncated_harmony_response(output).unwrap();
531 assert_eq!(parsed.reasoning_content.as_deref(), reasoning);
532 assert_eq!(parsed.content, content);
533 assert!(parsed.tool_call.is_none());
534 }
535 }
536
537 #[test]
538 fn parses_length_truncation_between_followup_envelope_markers() {
539 for output in [
540 "<|channel|>analysis<|message|>Reason.<|end|>\
541 <|start|>",
542 "<|channel|>analysis<|message|>Reason.<|end|>\
543 <|start|>assistant",
544 "<|channel|>analysis<|message|>Reason.<|end|>\
545 <|start|>assistant<|channel|>",
546 ] {
547 assert!(parse_harmony_response(output).is_err());
548 let parsed = parse_length_truncated_harmony_response(output).unwrap();
549 assert_eq!(parsed.reasoning_content.as_deref(), Some("Reason."));
550 assert!(parsed.content.is_empty());
551 assert!(parsed.tool_call.is_none());
552 }
553 }
554
555 #[test]
556 fn length_truncation_keeps_tool_calls_and_control_markers_fail_closed() {
557 for output in [
558 "<|channel|>analysis<|message|>Reason.<|end|>\
559 <|start|>assistant<|channel|>commentary to=functions.weather\
560 <|constrain|>json<|message|>{\"city\":\"Paris\"}",
561 "<|channel|>final<|message|>leak <|bogus|>",
562 "<|channel|>final<|message|>incomplete <|",
563 "<|channel|>analysis<|message|>Reason.<|end|>\
564 <|start|>assistant<|channel|>commentary to=functions.weather\
565 <|constrain|>json",
566 "<|channel|>analysis<|message|>Reason.<|end|>\
567 <|start|>assistant<|channel|>final<|mess",
568 "<|channel|>analysis<|message|>Reason.<|end|>\
569 <|start|>assistant<|channel|>fina",
570 "<|channel|>analysis<|message|>Reason.<|end|>\
571 <|start|>assistant to=functions.weather<|channel|>final",
572 ] {
573 assert!(
574 parse_length_truncated_harmony_response(output).is_err(),
575 "accepted {output:?}"
576 );
577 }
578 }
579
580 #[test]
581 fn parses_analysis_then_function_tool_call_in_both_header_orders() {
582 for output in [
583 "<|channel|>analysis<|message|>Need weather.<|end|>\
584 <|start|>assistant<|channel|>commentary to=functions.weather<|constrain|>json\
585 <|message|>{\"city\":\"Paris\"}<|call|>",
586 "<|channel|>analysis<|message|>Need weather.<|end|>\
587 <|start|>assistant to=functions.weather<|channel|>commentary<|constrain|>json\
588 <|message|>{\"city\":\"Paris\"}<|call|>",
589 ] {
590 let parsed = parse_harmony_response(output).unwrap();
591 assert_eq!(parsed.reasoning_content.as_deref(), Some("Need weather."));
592 assert!(parsed.content.is_empty());
593 assert_eq!(
594 parsed.tool_call,
595 Some(HarmonyToolCall {
596 name: "weather".to_string(),
597 arguments_json: "{\"city\":\"Paris\"}".to_string(),
598 })
599 );
600 }
601 }
602
603 #[test]
604 fn rejects_invalid_or_non_object_tool_json() {
605 for arguments in ["{", "[]", "null", "\"Paris\""] {
606 let output = format!(
607 "<|channel|>analysis<|message|>Need weather.<|end|>\
608 <|start|>assistant<|channel|>commentary to=functions.weather<|constrain|>json\
609 <|message|>{arguments}<|call|>"
610 );
611 assert!(
612 parse_harmony_response(&output).is_err(),
613 "accepted {arguments:?}"
614 );
615 }
616 }
617
618 #[test]
619 fn rejects_raw_marker_leakage_and_incomplete_envelopes() {
620 for output in [
621 "<|channel|>final<|message|>leak <|bogus|> marker<|return|>",
622 "<|channel|>final answer<|return|>",
623 "<|channel|>final<|message|>incomplete <| marker",
624 ] {
625 assert!(
626 parse_harmony_response(output).is_err(),
627 "accepted {output:?}"
628 );
629 }
630 }
631
632 #[test]
633 fn rejects_wrong_missing_or_repeated_terminal_tokens() {
634 for output in [
635 "<|channel|>final<|message|>Paris<|end|>",
636 "<|channel|>final<|message|>Paris<|call|>",
637 "<|channel|>final<|message|>Paris",
638 "<|channel|>final<|message|>Paris<|return|><|return|>",
639 "<|channel|>final<|message|>Paris<|return|>garbage",
640 "<|channel|>analysis<|message|>Need weather.<|end|>\
641 <|start|>assistant<|channel|>commentary to=functions.weather<|message|>{}<|return|>",
642 ] {
643 assert!(
644 parse_harmony_response(output).is_err(),
645 "accepted {output:?}"
646 );
647 }
648 }
649
650 #[test]
651 fn rejects_unknown_or_empty_tool_recipients() {
652 for recipient in ["browser.search", "python", "functions."] {
653 let output = format!(
654 "<|channel|>analysis<|message|>Need a tool.<|end|>\
655 <|start|>assistant<|channel|>commentary to={recipient}<|constrain|>json\
656 <|message|>{{}}<|call|>"
657 );
658 assert!(
659 parse_harmony_response(&output).is_err(),
660 "accepted {recipient:?}"
661 );
662 }
663 }
664
665 #[test]
666 fn rejects_invalid_channel_or_extra_message() {
667 for output in [
668 "<|channel|>developer<|message|>no<|return|>",
669 "<|channel|>analysis<|message|>one<|end|>\
670 <|start|>assistant<|channel|>analysis<|message|>two<|end|>\
671 <|start|>assistant<|channel|>final<|message|>answer<|return|>",
672 ] {
673 assert!(
674 parse_harmony_response(output).is_err(),
675 "accepted {output:?}"
676 );
677 }
678 }
679}