From 41a404a90b66f013b52e9ad4803bfa75eb2abfc5 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 09:06:56 -0700 Subject: [PATCH 01/44] added description --- Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.toml b/Cargo.toml index 7e3fa9c..9a876f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ version = "0.0.1" edition = "2021" license = "Apache-2.0" repository = "https://github.com/openai/harmony" +description = "OpenAI's response format for its open-weight model series gpt-oss" [lib] name = "openai_harmony" From f50af9696bc6db67abe856485fa184b564b1ec59 Mon Sep 17 00:00:00 2001 From: Zhuohan Li Date: Tue, 5 Aug 2025 09:47:41 -0700 Subject: [PATCH 02/44] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b095226..a2428fd 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@
-The [gpt-oss models][gpt-oss] were trained on the [harmony response format][harmony-format] for defining conversation structures, generating reasoning output and structuring function calls. If you are not using gpt-oss directly but through an API or a provider like Ollama, you will not have to be concerned about this as your inference solution will handle the formatting. If you are building your own inference solution, this guide will walk you through the prompt format. The format is designed to mimic the OpenAI Responses API, so if you have used that API before, this format should hopefully feel familiar to you. gpt-oss should not be used without using the harmony format as it will not work correctly. +The [gpt-oss models][gpt-oss] were trained on the [harmony response format][harmony-format] for defining conversation structures, generating reasoning output and structuring function calls. If you are not using gpt-oss directly but through an API or a provider like HuggingFace, Ollama, or vLLM, you will not have to be concerned about this as your inference solution will handle the formatting. If you are building your own inference solution, this guide will walk you through the prompt format. The format is designed to mimic the OpenAI Responses API, so if you have used that API before, this format should hopefully feel familiar to you. gpt-oss should not be used without using the harmony format as it will not work correctly. The format enables the model to output to multiple different channels for chain of thought, and tool calling premables along with regular responses. It also enables specifying various tool namespaces, and structured outputs along with a clear instruction hierarchy. [Check out the guide][harmony-format] to learn more about the format itself. From 4bc693354917c46e842c109cb37d9a20c3ab6c92 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 09:53:51 -0700 Subject: [PATCH 03/44] unified --- pyproject.toml | 4 + python/openai_harmony/__init__.py | 6 +- src/encoding.rs | 494 +++++++----------------------- src/py_module.rs | 7 + src/tests.rs | 36 +++ test_python.sh | 5 + tests/test_harmony.py | 13 +- 7 files changed, 175 insertions(+), 390 deletions(-) create mode 100755 test_python.sh diff --git a/pyproject.toml b/pyproject.toml index 251e497..ccf3dfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,3 +20,7 @@ demo = ["uvicorn", "fastapi"] features = ["pyo3/extension-module"] module-name = "openai_harmony" python-source = "python" + +[tool.pytest.ini_options] +# Only collect tests from the top-level tests directory +testpaths = ["tests"] diff --git a/python/openai_harmony/__init__.py b/python/openai_harmony/__init__.py index e763af1..3485864 100644 --- a/python/openai_harmony/__init__.py +++ b/python/openai_harmony/__init__.py @@ -29,7 +29,7 @@ from typing import ( ) import re -from pydantic import BaseModel, Field, root_validator, validator +from pydantic import BaseModel, Field # Re-export the low-level Rust bindings under a private name so that we can # keep the *public* namespace clean and purely Pythonic. @@ -612,6 +612,10 @@ class StreamableParser: self._inner.process(token) return self + def process_eos(self) -> "StreamableParser": + self._inner.process_eos() + return self + @property def current_content(self) -> str: return self._inner.current_content diff --git a/src/encoding.rs b/src/encoding.rs index 75b5cf5..f5d0378 100644 --- a/src/encoding.rs +++ b/src/encoding.rs @@ -368,26 +368,17 @@ impl HarmonyEncoding { pub fn parse_messages_from_completion_tokens( &self, tokens: I, - mut role: Option, + role: Option, ) -> anyhow::Result> where I: IntoIterator, { - let mut messages = Vec::::new(); - let mut parser = Parser { - encoding: self, - tokens: tokens.into_iter().peekable(), - }; - loop { - let (message, did_reach_end_of_stream) = parser.parse_message(role)?; - messages.push(message); - role = None; - if did_reach_end_of_stream { - break; - } + let mut parser = StreamableParser::new(self.clone(), role)?; + for token in tokens { + parser.process(token)?; } - anyhow::ensure!(parser.tokens.next().is_none(), "Expected end of stream"); - Ok(messages) + parser.process_eos()?; + Ok(parser.into_messages()) } /// Helper to convert a JSON schema (OpenAPI style) to a TypeScript type definition. @@ -982,305 +973,6 @@ impl Render for HarmonyEncoding { } } -enum TakeUntilStatus { - Found, - EndOfStream, -} - -impl TakeUntilStatus { - fn was_found(&self) -> bool { - matches!(self, TakeUntilStatus::Found) - } -} - -struct Parser<'a, I> -where - I: Iterator, -{ - tokens: std::iter::Peekable, - encoding: &'a HarmonyEncoding, -} - -impl Parser<'_, I> -where - I: Iterator, -{ - fn expect_special(&mut self, token: FormattingToken) -> anyhow::Result { - let next = self.tokens.next().context(format!( - "Expected special token ({}), but out of tokens", - token - ))?; - let expected = self.encoding.render_formatting_token(token)?; - if next != expected { - anyhow::bail!( - "Expected special token ({}) {} but got {}", - token, - expected, - next, - ); - } - Ok(next) - } - - fn take_until_any(&mut self, ends: &HashSet) -> (Vec, TakeUntilStatus) { - let mut out = vec![]; - for t in &mut self.tokens { - if ends.contains(&t) { - return (out, TakeUntilStatus::Found); - } - out.push(t); - } - (out, TakeUntilStatus::EndOfStream) - } - - fn take_until(&mut self, end: Rank) -> (Vec, TakeUntilStatus) { - self.take_until_any(&HashSet::from([end])) - } - - fn parse_header(&mut self, role: Option) -> anyhow::Result { - // FormattingToken::Message marks the end of the header. - // Everything before that belongs to the header. - let message_start_token = self - .encoding - .render_formatting_token(FormattingToken::Message)?; - - let (header_tokens, status) = self.take_until(message_start_token); - if !status.was_found() { - anyhow::bail!("Expected message start token but ran out of tokens"); - } - - // Decode the header into a UTF-8 string so we can reason about its structure. - let mut header_string = self - .encoding - .tokenizer - .decode_utf8(header_tokens) - .context("could not decode header")?; - - // -------------------------------------------------------------------- - // 1. Extract the channel (if any) - // -------------------------------------------------------------------- - // A channel, when present, is encoded as: - // <|channel|>CHANNEL_VALUE - // where <|channel|> is the literal rendering of FormattingToken::Channel - // and CHANNEL_VALUE is a contiguous string (no whitespace) naming the - // channel. The <|channel|> marker can appear before or after the - // recipient part, but *always* before the optional content-type (which - // must be last). - let mut channel: Option = None; - if let Some(channel_marker) = self.encoding.mapped_format_token(FormattingToken::Channel) { - if let Some(idx) = header_string.find(channel_marker) { - // Slice parts around the marker - let after_marker = &header_string[idx + channel_marker.len()..]; - - // The channel value continues until the next ASCII whitespace, - // the start of another special token ("<"), or end-of-string. - let channel_end = after_marker - .find(|c: char| c.is_whitespace() || c == '<') - .unwrap_or(after_marker.len()); - let channel_value = &after_marker[..channel_end]; - if channel_value.is_empty() { - anyhow::bail!("channel marker present but no channel value found in header"); - } - channel = Some(channel_value.to_string()); - - // Remove the marker *and* the channel value from the header so - // the remaining pieces can be parsed independently. - let mut new_header = String::new(); - new_header.push_str(&header_string[..idx]); - new_header.push_str(&after_marker[channel_end..]); - header_string = new_header; - } - } - - // Trim extraneous whitespace that may have been introduced when we - // removed the channel section. - header_string = header_string.trim().to_string(); - - // If the constrained format marker is present but not preceded by - // whitespace (e.g. "to=foo<|constrain|>json"), insert a space before - // the marker so that splitting on whitespace treats the content type - // as a separate token. - if let Some(constrain_marker) = self - .encoding - .mapped_format_token(FormattingToken::ConstrainedFormat) - { - if header_string.contains(constrain_marker) { - header_string = header_string - .replace(constrain_marker, &format!(" {}", constrain_marker)) - .trim() - .to_string(); - } - } - - // -------------------------------------------------------------------- - // 2. Split the remaining header into whitespace-separated tokens. - // -------------------------------------------------------------------- - // Debug output for development (only active when the `debug_header_parsing` cfg flag is - // enabled). - // For debugging purposes one might want to inspect the header string - // at this point. To avoid unwanted stdout noise in production use - // the following (commented) line and recompile as needed. - // println!("[DEBUG header] '{}'", header_string); - - let mut parts: Vec<&str> = header_string.split_ascii_whitespace().collect(); - - // -------------------------------------------------------------------- - // 3. Determine the role (if not already provided). - // -------------------------------------------------------------------- - let mut role_str_opt: Option = None; - let role = match role { - Some(r) => r, - None => { - let role_str = parts - .first() - .context("message header did not contain a role")?; - role_str_opt = Some((*role_str).to_string()); - let parsed_role = Role::try_from(*role_str); - let out = match parsed_role { - Ok(r) => r, - Err(_) => { - // If recipient is present, treat as tool call - if parts.len() > 1 || (parts.len() == 1 && parts[0].starts_with("to=")) { - parts.remove(0); // Remove the unknown role string - Role::Tool - } else { - return Err(anyhow::anyhow!("Unknown role: {}", role_str)); - } - } - }; - out - } - }; - // If the role was supplied externally but also redundantly present in the - // header itself, strip it off so that it does not interfere with the - // parsing of the remaining fields. - if let Some(first) = parts.first() { - if *first == role.as_str() { - parts.remove(0); - } - } - - // -------------------------------------------------------------------- - // 4. Identify recipient and content-type. - // -------------------------------------------------------------------- - let mut recipient: Option = None; - let mut content_type: Option = None; - - if !parts.is_empty() { - // Determine whether the last token is a content-type or part of the - // recipient specification. - let num_tokens_before_pop = parts.len(); - let last_token_owned = parts.pop().unwrap().to_string(); - - if last_token_owned.starts_with("to=") { - // The header contains a recipient but *no* content-type. - recipient = Some(last_token_owned.trim_start_matches("to=").to_string()); - } else if num_tokens_before_pop == 1 { - // Only one token total (after potential role removal) and it doesn't start - // with "to=" => interpret it as a standalone recipient. - recipient = Some(last_token_owned); - } else { - // More than one token and the last one is not a recipient -> treat as content-type. - content_type = Some(last_token_owned); - - // After removing the content-type there may be exactly one token describing the recipient. - if !parts.is_empty() { - if parts.len() != 1 { - anyhow::bail!("Could not parse header: too many tokens remaining after extracting content-type and recipient"); - } - let raw_recipient = parts.pop().unwrap(); - recipient = if let Some(stripped) = raw_recipient.strip_prefix("to=") { - Some(stripped.to_string()) - } else { - Some(raw_recipient.to_string()) - }; - } - } - } - - // After processing, no unparsed tokens should remain. - anyhow::ensure!( - parts.is_empty(), - "unexpected tokens remaining in message header: {:?}", - parts - ); - // We have successfully parsed the header. - let author = if role == Role::Tool { - let name = role_str_opt; - Author { role, name } - } else { - Author { role, name: None } - }; - Ok(ParsedHeader { - author, - recipient, - channel, - content_type, - }) - } - - /// Parses a message from the remaining tokens. - /// - /// Returns the message and a boolean indicating whether end of stream was reached. - fn parse_message(&mut self, role: Option) -> anyhow::Result<(Message, bool)> { - let start_token = self - .encoding - .render_formatting_token(FormattingToken::Start)?; - match role { - Some(_) => { - if let Some(&next) = self.tokens.peek() { - if next == start_token { - self.tokens.next(); - } - } else { - anyhow::bail!("Expected at least one token while parsing message"); - } - } - None => { - self.expect_special(FormattingToken::Start)?; - } - } - let header = self.parse_header(role)?; - let ParsedHeader { - author, - recipient, - channel, - content_type, - } = header; - - // TODO other content types - // since we bail on anything other than just the role in the header for now, we can assume - // that the content type is text - let end_tokens = self.encoding.stop_tokens()?; - let (remaining_tokens, status) = self.take_until_any(&end_tokens); - let remaining_text = self - .encoding - .tokenizer - .decode_utf8(remaining_tokens) - .context("could not decode message content")?; - let did_reach_end_of_stream = match status { - TakeUntilStatus::Found => self.tokens.peek().is_none(), - TakeUntilStatus::EndOfStream => true, - }; - Ok(( - Message { - author, - content: vec![Content::Text(TextContent { - text: remaining_text, - })], - channel, - recipient, - content_type, - }, - did_reach_end_of_stream, - )) - } -} - -// --------------------------------------------------------------------------- -// Streamable parsing --------------------------------------------------------- -// --------------------------------------------------------------------------- - /// Incremental parser that can consume tokens one by one. /// /// It keeps track of all tokens seen so far, exposes all fully parsed messages @@ -1334,8 +1026,11 @@ impl StreamableParser { } /// Consume a single token and update the internal state. - pub fn process(&mut self, token: Rank) -> anyhow::Result<&mut Self> { - self.tokens.push(token); + /// Consume a single token and update the internal state. + fn process_next(&mut self, token: Option) -> anyhow::Result<&mut Self> { + if let Some(token) = token { + self.tokens.push(token); + } // Clone next_role up front to avoid borrow checker issues let next_role_clone = self.next_role.clone(); match &mut self.state { @@ -1343,44 +1038,89 @@ impl StreamableParser { let start = self .encoding .render_formatting_token(FormattingToken::Start)?; - if token == start { - self.state = StreamState::Header { - header_tokens: Vec::new(), - }; - } else { - anyhow::bail!( - "Unexpected token {} while expecting start token {}", - token, - start - ); + match token { + Some(token) if token == start => { + self.state = StreamState::Header { + header_tokens: Vec::new(), + }; + } + Some(token) => { + anyhow::bail!( + "Unexpected token {} while expecting start token {}", + token, + start + ); + } + None => { + // receiving EOS while waiting for start token is actually fine + // as we may have just parsed a stop token. in this case we can + // simple keep state as is + } } } StreamState::Header { header_tokens } => { let msg_tok = self .encoding .render_formatting_token(FormattingToken::Message)?; - if token == msg_tok { - // Clone the tokens and next_role, then clear the state before parsing - let header_tokens_cloned = header_tokens.clone(); - let next_role_cloned = next_role_clone; - // Set state to dummy to drop mutable borrow - self.state = StreamState::ExpectStart; - let header = - self.parse_header_from_tokens(&header_tokens_cloned, next_role_cloned)?; - self.next_role = None; - self.state = StreamState::Content { - header, - content_tokens: Vec::new(), - }; - } else { - header_tokens.push(token); + match token { + Some(token) if token == msg_tok => { + // Clone the tokens and next_role, then clear the state before parsing + let header_tokens_cloned = header_tokens.clone(); + let next_role_cloned = next_role_clone; + // Set state to dummy to drop mutable borrow + self.state = StreamState::ExpectStart; + let header = + self.parse_header_from_tokens(&header_tokens_cloned, next_role_cloned)?; + self.next_role = None; + self.state = StreamState::Content { + header, + content_tokens: Vec::new(), + }; + } + Some(token) => { + header_tokens.push(token); + } + None => { + anyhow::bail!( + "Unexpected EOS while waiting for message header to complete" + ); + } } } StreamState::Content { header, content_tokens, } => { - if self.stop_tokens.contains(&token) { + let is_eos = if let Some(token) = token { + if self.stop_tokens.contains(&token) { + // this is a stop token, dont parse and mark EOS + true + } else { + self.undecoded_tokens.push(token); + // some tokens might not appropriately decode on their own. If they don't + // we will collect them until they eventually decode + match self + .encoding + .tokenizer() + .decode_utf8(&self.undecoded_tokens) + { + Ok(decoded) => { + content_tokens.extend(self.undecoded_tokens.iter().copied()); + self.last_content_delta = Some(decoded); + self.undecoded_tokens.clear(); + } + Err(_) => { + self.last_content_delta = None; + } + } + // this was not an EOS + false + } + } else { + // token = None signals EOS to this function + true + }; + if is_eos { let text = self.encoding.tokenizer().decode_utf8(content_tokens)?; let message = Message { author: header.author.clone(), @@ -1393,30 +1133,21 @@ impl StreamableParser { self.state = StreamState::ExpectStart; self.last_content_delta = None; self.undecoded_tokens.clear(); - } else { - self.undecoded_tokens.push(token); - // some tokens might not appropriately decode on their own. If they don't - // we will collect them until they eventually decode - match self - .encoding - .tokenizer() - .decode_utf8(&self.undecoded_tokens) - { - Ok(decoded) => { - content_tokens.extend(self.undecoded_tokens.iter().copied()); - self.last_content_delta = Some(decoded); - self.undecoded_tokens.clear(); - } - Err(_) => { - self.last_content_delta = None; - } - } } } } Ok(self) } + pub fn process(&mut self, token: Rank) -> anyhow::Result<&mut Self> { + self.process_next(Some(token)) + } + + pub fn process_eos(&mut self) -> anyhow::Result<&mut Self> { + self.process_next(None)?; + Ok(self) + } + fn parse_header_from_tokens( &self, header_tokens: &[Rank], @@ -1462,7 +1193,7 @@ impl StreamableParser { { if header_string.contains(constrain_marker) { header_string = header_string - .replace(constrain_marker, &format!(" {constrain_marker}")) + .replace(constrain_marker, &format!(" {}", constrain_marker)) .trim() .to_string(); } @@ -1495,8 +1226,8 @@ impl StreamableParser { } }; - if let Some(first) = parts.first() { - if *first == role.as_str() { + if let Some(&first) = parts.first() { + if first == role.as_str() { parts.remove(0); } } @@ -1505,23 +1236,25 @@ impl StreamableParser { let mut content_type: Option = None; if !parts.is_empty() { - let num_tokens_before_pop = parts.len(); - let last_token_owned = parts.pop().unwrap().to_string(); + // Determine whether the last token is a content-type or part of the + // recipient specification. + let num_parts = parts.len(); + // SAFETY: we know that there is at least one part remaining, because of is_empty check above + let last_part = parts.pop().unwrap(); - if last_token_owned.starts_with("to=") { - recipient = Some(last_token_owned.trim_start_matches("to=").to_string()); - } else if num_tokens_before_pop == 1 { - recipient = Some(last_token_owned); + if let Some(stripped) = last_part.strip_prefix("to=") { + // The header contains a recipient but *no* content-type. + recipient = Some(stripped.to_string()); + } else if num_parts == 1 { + // Only one part total (after potential role removal) and it doesn't start + // with "to=" => interpret it as a standalone recipient. + recipient = Some(last_part.to_string()); } else { - content_type = Some(last_token_owned); + // More than one token and the last one is not a recipient -> treat as content-type. + content_type = Some(last_part.to_string()); - if !parts.is_empty() { - if parts.len() != 1 { - anyhow::bail!( - "Could not parse header: too many tokens remaining after extracting content-type and recipient" - ); - } - let raw_recipient = parts.pop().unwrap(); + // After removing the content-type there may be exactly one token describing the recipient. + if let Some(raw_recipient) = parts.pop() { recipient = if let Some(stripped) = raw_recipient.strip_prefix("to=") { Some(stripped.to_string()) } else { @@ -1530,12 +1263,12 @@ impl StreamableParser { } } } - anyhow::ensure!( parts.is_empty(), "unexpected tokens remaining in message header: {:?}", parts ); + let author = if role == Role::Tool { let name = role_str_opt; Author { role, name } @@ -1583,6 +1316,11 @@ impl StreamableParser { Ok(self.last_content_delta.clone()) } + /// Consume the parser and return all parsed messages. + pub fn into_messages(self) -> Vec { + self.messages + } + /// All fully parsed messages so far. pub fn messages(&self) -> &[Message] { &self.messages diff --git a/src/py_module.rs b/src/py_module.rs index fe68b3c..fbb3129 100644 --- a/src/py_module.rs +++ b/src/py_module.rs @@ -306,6 +306,13 @@ impl PyStreamableParser { .map_err(|e| PyErr::new::(e.to_string())) } + fn process_eos(&mut self) -> PyResult<()> { + self.inner + .process_eos() + .map(|_| ()) + .map_err(|e| PyErr::new::(e.to_string())) + } + #[getter] fn current_content(&self) -> PyResult { self.inner diff --git a/src/tests.rs b/src/tests.rs index fe74f49..2f0e117 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -663,3 +663,39 @@ fn test_streamable_parser_tool_call_with_constrain_adjacent() { parser.messages()[0] ); } + +#[test] +fn test_tool_call_with_constrain_marker_adjacent() { + let encoding = load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss).unwrap(); + let text = "<|start|>assistant to=functions.get_weather<|channel|>commentary<|constrain|>json<|message|>{\"location\": \"Tokyo\"}<|end|>"; + let tokens = encoding.tokenizer().encode_with_special_tokens(text); + let parsed = encoding + .parse_messages_from_completion_tokens(tokens, None) + .expect("expected to parse"); + let expected = + vec![ + Message::from_role_and_content(Role::Assistant, "{\"location\": \"Tokyo\"}") + .with_channel("commentary") + .with_recipient("functions.get_weather") + .with_content_type("<|constrain|>json"), + ]; + assert_eq!(parsed, expected); +} + +#[test] +fn test_tool_call_with_channel_before_recipient_and_constrain_adjacent() { + let encoding = load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss).unwrap(); + let text = "<|start|>assistant<|channel|>commentary to=functions.get_weather<|constrain|>json<|message|>{\"latitude\":48.8566,\"longitude\":2.3522}<|call|>"; + let tokens = encoding.tokenizer().encode_with_special_tokens(text); + let parsed = encoding + .parse_messages_from_completion_tokens(tokens, None) + .expect("expected to parse"); + let expected = vec![Message::from_role_and_content( + Role::Assistant, + "{\"latitude\":48.8566,\"longitude\":2.3522}", + ) + .with_channel("commentary") + .with_recipient("functions.get_weather") + .with_content_type("<|constrain|>json")]; + assert_eq!(parsed, expected); +} diff --git a/test_python.sh b/test_python.sh new file mode 100755 index 0000000..09a24de --- /dev/null +++ b/test_python.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e +source .venv/bin/activate +maturin develop -F python-binding --release +pytest "$@" diff --git a/tests/test_harmony.py b/tests/test_harmony.py index 2b00672..8392d7f 100644 --- a/tests/test_harmony.py +++ b/tests/test_harmony.py @@ -34,7 +34,6 @@ from openai_harmony import ( # noqa: E402 StreamableParser, SystemContent, ToolDescription, - ToolNamespaceConfig, load_harmony_encoding, ) from pydantic import ValidationError @@ -245,23 +244,18 @@ def test_tool_call_with_constrain_marker_adjacent(encoding_name): correctly and instead handle it as a separate content type. """ encoding = load_harmony_encoding(encoding_name) - text = ( "<|start|>assistant to=functions.get_weather<|channel|>commentary" '<|constrain|>json<|message|>{"location": "Tokyo"}<|end|>' ) - tokens = encoding.encode(text, allowed_special="all") - - parsed = encoding.parse_messages_from_completion_tokens(tokens, role=Role.ASSISTANT) - + parsed = encoding.parse_messages_from_completion_tokens(tokens, role=None) expected = [ Message.from_role_and_content(Role.ASSISTANT, '{"location": "Tokyo"}') .with_channel("commentary") .with_recipient("functions.get_weather") .with_content_type("<|constrain|>json"), ] - assert parsed == expected @@ -280,11 +274,8 @@ def test_tool_call_with_channel_before_recipient_and_constrain_adjacent( "<|start|>assistant<|channel|>commentary to=functions.get_weather" '<|constrain|>json<|message|>{"latitude":48.8566,"longitude":2.3522}<|call|>' ) - tokens = encoding.encode(text, allowed_special="all") - - parsed = encoding.parse_messages_from_completion_tokens(tokens, role=Role.ASSISTANT) - + parsed = encoding.parse_messages_from_completion_tokens(tokens, role=None) expected = [ Message.from_role_and_content( Role.ASSISTANT, '{"latitude":48.8566,"longitude":2.3522}' From d25cd545862e0ec76964690ba6725e2b311a77e4 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 09:55:31 -0700 Subject: [PATCH 04/44] clippy --- src/encoding.rs | 96 ++++++++++++++++++++++--------------------------- src/tests.rs | 6 ++-- 2 files changed, 46 insertions(+), 56 deletions(-) diff --git a/src/encoding.rs b/src/encoding.rs index f5d0378..39bc266 100644 --- a/src/encoding.rs +++ b/src/encoding.rs @@ -217,7 +217,7 @@ impl HarmonyEncoding { && first_final_idx.is_some_and(|first| *idx < first) && msg.channel.as_deref() == Some("analysis")) }) - .try_for_each(|(_, msg)| self.render_into(*msg, into)); + .try_for_each(|(_, msg)| self.render_into(msg, into)); self.conversation_has_function_tools .store(false, Ordering::Relaxed); result?; @@ -385,10 +385,10 @@ impl HarmonyEncoding { fn json_schema_to_typescript(schema: &serde_json::Value, indent: &str) -> String { // Helper to check if this schema is an enum fn is_enum(schema: &serde_json::Value) -> bool { - return schema + schema .get("enum") .and_then(|e| e.as_array()) - .map_or(false, |arr| !arr.is_empty()); + .is_some_and(|arr| !arr.is_empty()) } // Handle oneOf at the top level @@ -398,30 +398,28 @@ impl HarmonyEncoding { let mut first = true; for variant in arr { if !first { - out.push_str("\n"); - out.push_str(&format!("{} | ", indent)); + out.push('\n'); + out.push_str(&format!("{indent} | ")); } else { - out.push_str(&format!("\n{} | ", indent)); + out.push_str(&format!("\n{indent} | ")); first = false; } let type_str = - Self::json_schema_to_typescript(variant, &format!("{} ", indent)); + Self::json_schema_to_typescript(variant, &format!("{indent} ")); let mut type_str = type_str; if variant .get("nullable") .and_then(|n| n.as_bool()) .unwrap_or(false) - { - if !type_str.contains("null") { - type_str = format!("{} | null", type_str); + && !type_str.contains("null") { + type_str = format!("{type_str} | null"); } - } out.push_str(&type_str); // Add trailing comments (description, default) let mut trailing_comments = Vec::new(); if let Some(desc) = variant.get("description") { if let Some(desc_str) = desc.as_str() { - trailing_comments.push(format!("{}", desc_str)); + trailing_comments.push(desc_str.to_string()); } } if let Some(default) = variant.get("default") { @@ -429,7 +427,7 @@ impl HarmonyEncoding { trailing_comments .push(format!("default: \"{}\"", default.as_str().unwrap())); } else { - trailing_comments.push(format!("default: {}", default)); + trailing_comments.push(format!("default: {default}")); } } if !trailing_comments.is_empty() { @@ -463,7 +461,7 @@ impl HarmonyEncoding { // Render object-level description as comment if let Some(desc) = schema.get("description") { if let Some(desc_str) = desc.as_str() { - out.push_str(&format!("{}// {}\n", indent, desc_str)); + out.push_str(&format!("{indent}// {desc_str}\n")); } } out.push_str("{\n"); @@ -486,8 +484,7 @@ impl HarmonyEncoding { if let Some(title) = val.get("title") { if let Some(title_str) = title.as_str() { out.push_str(&format!( - "{0}// {1}\n{0}//\n", - indent, title_str + "{indent}// {title_str}\n{indent}//\n" )); } } @@ -495,19 +492,18 @@ impl HarmonyEncoding { if val.get("oneOf").is_none() { if let Some(desc) = val.get("description") { if let Some(desc_str) = desc.as_str() { - out.push_str(&format!("{}// {}\n", indent, desc_str)); + out.push_str(&format!("{indent}// {desc_str}\n")); } } } if let Some(examples) = val.get("examples") { if let Some(arr) = examples.as_array() { if !arr.is_empty() { - out.push_str(&format!("{}// Examples:\n", indent)); + out.push_str(&format!("{indent}// Examples:\n")); for ex in arr { if let Some(ex_str) = ex.as_str() { out.push_str(&format!( - "{}// - \"{}\"\n", - indent, ex_str + "{indent}// - \"{ex_str}\"\n" )); } } @@ -526,7 +522,7 @@ impl HarmonyEncoding { } let mut skip_property_desc = false; if let Some(desc_str) = property_desc { - if let Some(first_variant) = arr.get(0) { + if let Some(first_variant) = arr.first() { if let Some(variant_desc) = first_variant.get("description") { @@ -545,8 +541,7 @@ impl HarmonyEncoding { if !skip_property_desc { if let Some(desc_str) = property_desc { out.push_str(&format!( - "{}// {}\n", - indent, desc_str + "{indent}// {desc_str}\n" )); rendered_property_desc_above = true; } @@ -566,8 +561,7 @@ impl HarmonyEncoding { )); } else { out.push_str(&format!( - "{}// default: {}\n", - indent, default + "{indent}// default: {default}\n" )); } } @@ -584,10 +578,10 @@ impl HarmonyEncoding { )); // Render each variant for (i, variant) in arr.iter().enumerate() { - out.push_str(&format!("{} | ", indent)); + out.push_str(&format!("{indent} | ")); let type_str = Self::json_schema_to_typescript( variant, - &format!("{} ", indent), + &format!("{indent} "), ); // Handle nullable in variant let mut type_str = type_str; @@ -595,11 +589,9 @@ impl HarmonyEncoding { .get("nullable") .and_then(|n| n.as_bool()) .unwrap_or(false) - { - if !type_str.contains("null") { - type_str = format!("{} | null", type_str); + && !type_str.contains("null") { + type_str = format!("{type_str} | null"); } - } out.push_str(&type_str); // Add variant-level comments after the type let mut trailing_comments = Vec::new(); @@ -610,7 +602,7 @@ impl HarmonyEncoding { // Only render if not equal to property-level description if Some(desc_str) != property_desc { trailing_comments - .push(format!("{}", desc_str)); + .push(desc_str.to_string()); } } } @@ -627,7 +619,7 @@ impl HarmonyEncoding { )); } else { trailing_comments - .push(format!("default: {}", default)); + .push(format!("default: {default}")); } } if !trailing_comments.is_empty() { @@ -636,9 +628,9 @@ impl HarmonyEncoding { trailing_comments.join(" ") )); } - out.push_str("\n"); + out.push('\n'); } - out.push_str(&format!("{},\n", indent)); + out.push_str(&format!("{indent},\n")); continue; } } @@ -656,19 +648,17 @@ impl HarmonyEncoding { // Handle nullable let mut type_str = Self::json_schema_to_typescript( val, - &format!("{} ", indent), + &format!("{indent} "), ); if val .get("nullable") .and_then(|n| n.as_bool()) .unwrap_or(false) - { - if !type_str.contains("null") { - type_str = format!("{} | null", type_str); + && !type_str.contains("null") { + type_str = format!("{type_str} | null"); } - } out.push_str(&type_str); - out.push_str(","); + out.push(','); // Add default as comment if present (and not already handled) if val.get("oneOf").is_none() { if let Some(default) = val.get("default") { @@ -683,15 +673,15 @@ impl HarmonyEncoding { default.as_str().unwrap() )); } else { - out.push_str(&format!(" // default: {}", default)); + out.push_str(&format!(" // default: {default}")); } } } - out.push_str("\n"); + out.push('\n'); } } } - out.push_str(&format!("{}}}", indent)); + out.push_str(&format!("{indent}}}")); out } "string" => { @@ -699,7 +689,7 @@ impl HarmonyEncoding { if let Some(arr) = enum_vals.as_array() { let enums: Vec = arr .iter() - .filter_map(|v| v.as_str().map(|s| format!("\"{}\"", s))) + .filter_map(|v| v.as_str().map(|s| format!("\"{s}\""))) .collect(); if !enums.is_empty() { return enums.join(" | "); @@ -747,13 +737,13 @@ impl HarmonyEncoding { ) -> String { let mut tool_sections = Vec::::new(); tool_sections.push("# Tools".to_string()); - for (_namespace, ns_config) in tools { + for ns_config in tools.values() { let mut tool_section_content = Vec::::new(); tool_section_content.push(format!("## {}\n", ns_config.name)); if let Some(desc) = &ns_config.description { for line in desc.lines() { if !ns_config.tools.is_empty() { - tool_section_content.push(format!("// {}", line)); + tool_section_content.push(format!("// {line}")); } else { tool_section_content.push(line.to_string()); } @@ -763,7 +753,7 @@ impl HarmonyEncoding { tool_section_content.push(format!("namespace {} {{\n", ns_config.name)); for tool in &ns_config.tools { for line in tool.description.lines() { - tool_section_content.push(format!("// {}", line)); + tool_section_content.push(format!("// {line}")); } if let Some(params) = &tool.parameters { let param_type = Self::json_schema_to_typescript(params, ""); @@ -808,14 +798,14 @@ impl Render for HarmonyEncoding { // For users and assistants we put both the role, and optionally the user name. self.render_text_into(message.author.role.as_str(), into)?; if let Some(name) = &message.author.name { - self.render_text_into(format!(":{}", name), into)?; + self.render_text_into(format!(":{name}"), into)?; } }; // next render the header recipient, if there is one if let Some(recipient) = &message.recipient { if recipient != "all" { - self.render_text_into(format!(" to={}", recipient), into)?; + self.render_text_into(format!(" to={recipient}"), into)?; } } @@ -827,7 +817,7 @@ impl Render for HarmonyEncoding { // finally content type if let Some(content_type) = &message.content_type { - self.render_text_into(format!(" {}", content_type), into)?; + self.render_text_into(format!(" {content_type}"), into)?; } self.render_formatting_token_into(FormattingToken::Message, into)?; @@ -935,7 +925,7 @@ impl Render for HarmonyEncoding { channels_header.push_str(" Channel must be included for every message."); } if self.conversation_has_function_tools.load(Ordering::Relaxed) { - channels_header.push_str("\n"); + channels_header.push('\n'); channels_header.push_str( "Calls to these tools must go to the commentary channel: 'functions'.", ); @@ -1193,7 +1183,7 @@ impl StreamableParser { { if header_string.contains(constrain_marker) { header_string = header_string - .replace(constrain_marker, &format!(" {}", constrain_marker)) + .replace(constrain_marker, &format!(" {constrain_marker}")) .trim() .to_string(); } diff --git a/src/tests.rs b/src/tests.rs index 2f0e117..92dcb5f 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -501,11 +501,11 @@ fn test_preserve_cot() { fn test_reserved_token_decoding() { let encoding = load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss).unwrap(); assert_eq!( - encoding.tokenizer.decode_utf8(&[200014]).unwrap(), + encoding.tokenizer.decode_utf8([200014]).unwrap(), "<|reserved_200014|>" ); assert_eq!( - encoding.tokenizer.decode_utf8(&[201088]).unwrap(), + encoding.tokenizer.decode_utf8([201088]).unwrap(), "<|reserved_201088|>" ); } @@ -527,7 +527,7 @@ fn test_render_and_render_conversation_roundtrip() { #[test] fn test_decode_utf8_invalid_token() { let encoding = load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss).unwrap(); - let result = encoding.tokenizer.decode_utf8(&[99999999]); + let result = encoding.tokenizer.decode_utf8([99999999]); assert!(result.is_err(), "Expected error for invalid token"); } From cc89f2a3891592463873541f81a333d18abfc9e7 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 09:55:44 -0700 Subject: [PATCH 05/44] clippy --- src/encoding.rs | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/encoding.rs b/src/encoding.rs index 39bc266..c58e8b8 100644 --- a/src/encoding.rs +++ b/src/encoding.rs @@ -411,9 +411,10 @@ impl HarmonyEncoding { .get("nullable") .and_then(|n| n.as_bool()) .unwrap_or(false) - && !type_str.contains("null") { - type_str = format!("{type_str} | null"); - } + && !type_str.contains("null") + { + type_str = format!("{type_str} | null"); + } out.push_str(&type_str); // Add trailing comments (description, default) let mut trailing_comments = Vec::new(); @@ -540,9 +541,7 @@ impl HarmonyEncoding { let mut rendered_property_desc_above = false; if !skip_property_desc { if let Some(desc_str) = property_desc { - out.push_str(&format!( - "{indent}// {desc_str}\n" - )); + out.push_str(&format!("{indent}// {desc_str}\n")); rendered_property_desc_above = true; } } @@ -589,9 +588,10 @@ impl HarmonyEncoding { .get("nullable") .and_then(|n| n.as_bool()) .unwrap_or(false) - && !type_str.contains("null") { - type_str = format!("{type_str} | null"); - } + && !type_str.contains("null") + { + type_str = format!("{type_str} | null"); + } out.push_str(&type_str); // Add variant-level comments after the type let mut trailing_comments = Vec::new(); @@ -646,17 +646,16 @@ impl HarmonyEncoding { } )); // Handle nullable - let mut type_str = Self::json_schema_to_typescript( - val, - &format!("{indent} "), - ); + let mut type_str = + Self::json_schema_to_typescript(val, &format!("{indent} ")); if val .get("nullable") .and_then(|n| n.as_bool()) .unwrap_or(false) - && !type_str.contains("null") { - type_str = format!("{type_str} | null"); - } + && !type_str.contains("null") + { + type_str = format!("{type_str} | null"); + } out.push_str(&type_str); out.push(','); // Add default as comment if present (and not already handled) From 702031efe4373b1a32a967af2783dc40b8a911d0 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 10:05:54 -0700 Subject: [PATCH 06/44] readme and description --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index ccf3dfc..a89a2e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,8 @@ classifiers = [ ] dynamic = ["version"] dependencies = ["pydantic>=2.11.7"] +description = "OpenAI's response format for its open-weight model series gpt-oss" +readme = "README.md" [project.optional-dependencies] demo = ["uvicorn", "fastapi"] From 034caa61b6fec959fb60f96a40880bf73447eb37 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 10:06:15 -0700 Subject: [PATCH 07/44] version bump --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6fb919..6b774e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,7 +1317,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openai-harmony" -version = "0.0.1" +version = "0.0.2" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 9a876f4..53cd425 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openai-harmony" -version = "0.0.1" +version = "0.0.2" edition = "2021" license = "Apache-2.0" repository = "https://github.com/openai/harmony" From 111f0e60b3bf4faa3c25826fef661ee099360d32 Mon Sep 17 00:00:00 2001 From: Kohei YAMAMOTO / Asha <62386554+asha-ndf@users.noreply.github.com> Date: Wed, 6 Aug 2025 02:09:38 +0900 Subject: [PATCH 08/44] fix: correct syntax in conversation message formatting (#6) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a2428fd..1b9d4f4 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ convo = Conversation.from_messages([ Message.from_role_and_content( Role.DEVELOPER, DeveloperContent.new().with_instructions("Talk like a pirate!") - ) + ), Message.from_role_and_content(Role.USER, "Arrr, how be you?"), ]) tokens = enc.render_conversation_for_completion(convo, Role.ASSISTANT) From 47fb95f99cae467d7fb9819308526d72eefb49ff Mon Sep 17 00:00:00 2001 From: Yehor Smoliakov Date: Tue, 5 Aug 2025 20:13:38 +0300 Subject: [PATCH 09/44] Fix errors in wasm_module --- src/wasm_module.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/wasm_module.rs b/src/wasm_module.rs index f344661..9ffb8fd 100644 --- a/src/wasm_module.rs +++ b/src/wasm_module.rs @@ -235,7 +235,7 @@ impl JsStreamableParser { pub fn new(encoding: &JsHarmonyEncoding, role: &str) -> Result { let parsed_role = Role::try_from(role) .map_err(|_| JsValue::from_str(&format!("unknown role: {role}")))?; - let inner = StreamableParser::new(encoding.inner.clone(), parsed_role) + let inner = StreamableParser::new(encoding.inner.clone(), Some(parsed_role)) .map_err(|e| JsValue::from_str(&e.to_string()))?; Ok(Self { inner }) } @@ -323,9 +323,8 @@ pub async fn load_harmony_encoding( let parsed: HarmonyEncodingName = name .parse::() .map_err(|e| JsValue::from_str(&e.to_string()))?; - let encoding = inner_load_harmony_encoding(parsed) - .await - .map_err(|e| JsValue::from_str(&e.to_string()))?; + let encoding = + inner_load_harmony_encoding(parsed).map_err(|e| JsValue::from_str(&e.to_string()))?; Ok(JsHarmonyEncoding { inner: encoding }) } From a938605e0f4058ac882b2c96396aa6d6bb851f11 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 10:23:26 -0700 Subject: [PATCH 10/44] add back aarch --- .github/workflows/CI.yml | 8 ++++---- pyproject.toml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index ce9fd19..7832a69 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -28,8 +28,8 @@ jobs: target: x86_64 - runner: ubuntu-22.04 target: x86 - # - runner: ubuntu-22.04 - # target: aarch64 + - runner: ubuntu-22.04 + target: aarch64 - runner: ubuntu-22.04 target: armv7 # - runner: ubuntu-22.04 @@ -45,7 +45,7 @@ jobs: uses: PyO3/maturin-action@v1 with: target: ${{ matrix.platform.target }} - args: --release --out dist --find-interpreter + args: -F python-binding --release --out dist --find-interpreter sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} manylinux: auto - name: Upload wheels @@ -76,7 +76,7 @@ jobs: uses: PyO3/maturin-action@v1 with: target: ${{ matrix.platform.target }} - args: --release --out dist --find-interpreter + args: -F python-binding --release --out dist --find-interpreter sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} manylinux: musllinux_1_2 - name: Upload wheels diff --git a/pyproject.toml b/pyproject.toml index a89a2e6..5836c58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,10 +16,10 @@ description = "OpenAI's response format for its open-weight model series gpt-oss readme = "README.md" [project.optional-dependencies] -demo = ["uvicorn", "fastapi"] +demo = [] [tool.maturin] -features = ["pyo3/extension-module"] +features = ["python-binding", "pyo3/extension-module"] module-name = "openai_harmony" python-source = "python" From 57afd6f60b833603428a0ec708bbcf81c9b3940a Mon Sep 17 00:00:00 2001 From: Yehor Smoliakov Date: Tue, 5 Aug 2025 20:25:11 +0300 Subject: [PATCH 11/44] Fix Rust example in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1b9d4f4..5178b19 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ fn main() -> anyhow::Result<()> { let convo = Conversation::from_messages([ Message::from_role_and_content(Role::User, "Hello there!"), ]); - let tokens = enc.render_conversation_for_completion(&convo, Role::Assistant)?; + let tokens = enc.render_conversation_for_completion(&convo, Role::Assistant, None)?; println!("{:?}", tokens); Ok(()) } From 2d077f3745f4af8c7910911d0a442f6e3763ed4f Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 10:32:20 -0700 Subject: [PATCH 12/44] ring fixes --- .github/workflows/CI.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 7832a69..c5a0264 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -43,6 +43,9 @@ jobs: python-version: 3.x - name: Build wheels uses: PyO3/maturin-action@v1 + env: + # Ensure ring's ARM assembly sees an explicit architecture on aarch64 (glibc) + CFLAGS_aarch64_unknown_linux_gnu: -D__ARM_ARCH=8 with: target: ${{ matrix.platform.target }} args: -F python-binding --release --out dist --find-interpreter @@ -74,6 +77,9 @@ jobs: python-version: 3.x - name: Build wheels uses: PyO3/maturin-action@v1 + env: + # Ensure ring's ARM assembly sees an explicit architecture on aarch64 (musl) + CFLAGS_aarch64_unknown_linux_musl: -D__ARM_ARCH=8 with: target: ${{ matrix.platform.target }} args: -F python-binding --release --out dist --find-interpreter From b16552ff24c07ead0d7975004e1de9f4ab70b79f Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 10:36:49 -0700 Subject: [PATCH 13/44] cleanup flags and platforms --- .github/workflows/CI.yml | 12 ++++-------- README.md | 4 ++-- pyproject.toml | 2 +- test_python.sh | 2 +- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index c5a0264..29518e7 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -32,10 +32,6 @@ jobs: target: aarch64 - runner: ubuntu-22.04 target: armv7 - # - runner: ubuntu-22.04 - # target: s390x - # - runner: ubuntu-22.04 - # target: ppc64le steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 @@ -48,7 +44,7 @@ jobs: CFLAGS_aarch64_unknown_linux_gnu: -D__ARM_ARCH=8 with: target: ${{ matrix.platform.target }} - args: -F python-binding --release --out dist --find-interpreter + args: --release --out dist --find-interpreter sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} manylinux: auto - name: Upload wheels @@ -82,7 +78,7 @@ jobs: CFLAGS_aarch64_unknown_linux_musl: -D__ARM_ARCH=8 with: target: ${{ matrix.platform.target }} - args: -F python-binding --release --out dist --find-interpreter + args: --release --out dist --find-interpreter sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} manylinux: musllinux_1_2 - name: Upload wheels @@ -110,7 +106,7 @@ jobs: uses: PyO3/maturin-action@v1 with: target: ${{ matrix.platform.target }} - args: -F python-binding --release --out dist --find-interpreter + args: --release --out dist --find-interpreter sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} - name: Upload wheels uses: actions/upload-artifact@v4 @@ -136,7 +132,7 @@ jobs: uses: PyO3/maturin-action@v1 with: target: ${{ matrix.platform.target }} - args: -F python-binding --release --out dist --find-interpreter + args: --release --out dist --find-interpreter sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} - name: Upload wheels uses: actions/upload-artifact@v4 diff --git a/README.md b/README.md index 1b9d4f4..6ce4885 100644 --- a/README.md +++ b/README.md @@ -177,10 +177,10 @@ source .venv/bin/activate # Install maturin and test dependencies pip install maturin pytest mypy ruff # tailor to your workflow # Compile the Rust crate *and* install the Python package in editable mode -maturin develop -F python-binding --release +maturin develop --release ``` -`maturin develop -F python-binding` builds _harmony_ with Cargo, produces a native extension +`maturin develop` builds _harmony_ with Cargo, produces a native extension (`openai_harmony..so`) and places it in your virtualenv next to the pure- Python wrapper – similar to `pip install -e .` for pure Python projects. diff --git a/pyproject.toml b/pyproject.toml index 5836c58..4c8ad8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ description = "OpenAI's response format for its open-weight model series gpt-oss readme = "README.md" [project.optional-dependencies] -demo = [] +demo = ["uvicorn", "fastapi"] [tool.maturin] features = ["python-binding", "pyo3/extension-module"] diff --git a/test_python.sh b/test_python.sh index 09a24de..51176a7 100755 --- a/test_python.sh +++ b/test_python.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash set -e source .venv/bin/activate -maturin develop -F python-binding --release +maturin develop --release pytest "$@" From d2ba5c0daa81ac047997ef9c2b5354c28048b2d2 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 10:41:34 -0700 Subject: [PATCH 14/44] alpha bump --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6b774e8..6dae6d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,7 +1317,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openai-harmony" -version = "0.0.2" +version = "0.0.2-alpha.1" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 53cd425..ea43da9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openai-harmony" -version = "0.0.2" +version = "0.0.2-alpha.1" edition = "2021" license = "Apache-2.0" repository = "https://github.com/openai/harmony" From 658206a36eab91652c6354325f4ac7eaa901e7f2 Mon Sep 17 00:00:00 2001 From: michaelfeil Date: Tue, 5 Aug 2025 17:41:48 +0000 Subject: [PATCH 15/44] cross compile bindings --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 53cd425..da0d25b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,7 @@ reqwest = { version = "0.12.5", default-features = false, features = [ ] } futures = "0.3" clap = { version = "4", features = ["derive"] } -pyo3 = { version = "0.25.0", optional = true, features = ["extension-module"] } +pyo3 = { version = "0.25.0", optional = true, features = ["extension-module", "abi3-py38"] } wasm-bindgen = { version = "0.2.100", optional = true, features = [ "serde-serialize", ] } From 4cf923e7c48a47261c0b94942896538cd9c135c4 Mon Sep 17 00:00:00 2001 From: CharlesCNorton <135471798+CharlesCNorton@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:58:39 -0400 Subject: [PATCH 16/44] docs: fix typo in README.md (premables -> preambles) Fix typo in harmony format documentation Changed "premables" to "preambles" in the description of the harmony response format capabilities. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ce4885..73cc841 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The [gpt-oss models][gpt-oss] were trained on the [harmony response format][harmony-format] for defining conversation structures, generating reasoning output and structuring function calls. If you are not using gpt-oss directly but through an API or a provider like HuggingFace, Ollama, or vLLM, you will not have to be concerned about this as your inference solution will handle the formatting. If you are building your own inference solution, this guide will walk you through the prompt format. The format is designed to mimic the OpenAI Responses API, so if you have used that API before, this format should hopefully feel familiar to you. gpt-oss should not be used without using the harmony format as it will not work correctly. -The format enables the model to output to multiple different channels for chain of thought, and tool calling premables along with regular responses. It also enables specifying various tool namespaces, and structured outputs along with a clear instruction hierarchy. [Check out the guide][harmony-format] to learn more about the format itself. +The format enables the model to output to multiple different channels for chain of thought, and tool calling preambles along with regular responses. It also enables specifying various tool namespaces, and structured outputs along with a clear instruction hierarchy. [Check out the guide][harmony-format] to learn more about the format itself. ``` <|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI. From b1b6c5fe9a88db4166f3908d080a71bdba2ace56 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 11:18:34 -0700 Subject: [PATCH 17/44] fixed readme rust sample, added readme to lib.rs docs for doctest, bumped version --- Cargo.lock | 2 +- Cargo.toml | 7 +++++-- README.md | 14 +++++++------- src/lib.rs | 2 ++ 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6dae6d9..6b774e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,7 +1317,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openai-harmony" -version = "0.0.2-alpha.1" +version = "0.0.2" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index f57dd14..12ebc27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openai-harmony" -version = "0.0.2-alpha.1" +version = "0.0.2" edition = "2021" license = "Apache-2.0" repository = "https://github.com/openai/harmony" @@ -42,7 +42,10 @@ reqwest = { version = "0.12.5", default-features = false, features = [ ] } futures = "0.3" clap = { version = "4", features = ["derive"] } -pyo3 = { version = "0.25.0", optional = true, features = ["extension-module", "abi3-py38"] } +pyo3 = { version = "0.25.0", optional = true, features = [ + "extension-module", + "abi3-py38", +] } wasm-bindgen = { version = "0.2.100", optional = true, features = [ "serde-serialize", ] } diff --git a/README.md b/README.md index 73cc841..d9c5f13 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The [gpt-oss models][gpt-oss] were trained on the [harmony response format][harm The format enables the model to output to multiple different channels for chain of thought, and tool calling preambles along with regular responses. It also enables specifying various tool namespaces, and structured outputs along with a clear instruction hierarchy. [Check out the guide][harmony-format] to learn more about the format itself. -``` +```text <|start|>system<|message|>You are ChatGPT, a large language model trained by OpenAI. Knowledge cutoff: 2024-06 Current date: 2025-06-28 @@ -114,12 +114,12 @@ openai-harmony = { git = "https://github.com/openai/harmony" } ```rust use openai_harmony::chat::{Message, Role, Conversation}; use openai_harmony::{HarmonyEncodingName, load_harmony_encoding}; + fn main() -> anyhow::Result<()> { let enc = load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss)?; - let convo = Conversation::from_messages([ - Message::from_role_and_content(Role::User, "Hello there!"), - ]); - let tokens = enc.render_conversation_for_completion(&convo, Role::Assistant)?; + let convo = + Conversation::from_messages([Message::from_role_and_content(Role::User, "Hello there!")]); + let tokens = enc.render_conversation_for_completion(&convo, Role::Assistant, None)?; println!("{:?}", tokens); Ok(()) } @@ -130,7 +130,7 @@ fn main() -> anyhow::Result<()> { The majority of the rendering and parsing is built in Rust for performance and exposed to Python through thin [`pyo3`](https://pyo3.rs/) bindings. -``` +```text ┌──────────────────┐ ┌───────────────────────────┐ │ Python code │ │ Rust core (this repo) │ │ (dataclasses, │────► │ • chat / encoding logic │ @@ -140,7 +140,7 @@ through thin [`pyo3`](https://pyo3.rs/) bindings. ### Repository layout -``` +```text . ├── src/ # Rust crate │ ├── chat.rs # High-level data-structures (Role, Message, …) diff --git a/src/lib.rs b/src/lib.rs index 2535e1c..acd572a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#![doc = include_str!("../README.md")] + pub mod chat; mod encoding; mod registry; From 0c69fd960d0f87c27d9591fc293c442a5af171f7 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 11:41:19 -0700 Subject: [PATCH 18/44] ci --- .../actions/run-rust-python-tests/action.yml | 67 +++++++++++++++++++ .github/workflows/CI.yml | 14 ++++ 2 files changed, 81 insertions(+) create mode 100644 .github/actions/run-rust-python-tests/action.yml diff --git a/.github/actions/run-rust-python-tests/action.yml b/.github/actions/run-rust-python-tests/action.yml new file mode 100644 index 0000000..ed62480 --- /dev/null +++ b/.github/actions/run-rust-python-tests/action.yml @@ -0,0 +1,67 @@ +name: Run Rust and Python tests + +description: Format, clippy, Rust tests (incl. doctests), build Python extension and run pytest + +inputs: + python-version: + description: Python version to use + required: false + default: "3.11" + rust-toolchain: + description: Rust toolchain channel + required: false + default: stable + +runs: + using: composite + steps: + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ inputs.rust-toolchain }} + components: clippy,rustfmt + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Upgrade pip + shell: bash + run: | + python -m pip install --upgrade pip + + - name: Install build/test deps + shell: bash + run: | + python -m pip install maturin pytest + + - name: Check rustfmt + shell: bash + run: | + cargo fmt --all --check + + - name: Run clippy + shell: bash + run: | + cargo clippy --all-targets --all-features -- -D warnings + + - name: Run Rust tests (unit/integration) + shell: bash + run: | + cargo test --all-targets --all-features + + - name: Run Rust doctests + shell: bash + run: | + cargo test --doc + + - name: Build Python extension (maturin develop) + shell: bash + run: | + maturin develop --release + + - name: Run pytest + shell: bash + run: | + pytest -q diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 29518e7..38fcdb5 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -19,6 +19,20 @@ permissions: contents: read jobs: + tests: + name: Tests (fmt, clippy, cargo test, doctest, pytest) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-14, windows-latest] + steps: + - uses: actions/checkout@v4 + - name: Run composite test suite + uses: ./.github/actions/run-rust-python-tests + with: + python-version: "3.11" + rust-toolchain: stable linux: runs-on: ${{ matrix.platform.runner }} strategy: From 9418718e673c3ffda8ed39e327e813ef8956f80b Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 11:42:30 -0700 Subject: [PATCH 19/44] license update --- LICENSE | 315 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 158 insertions(+), 157 deletions(-) diff --git a/LICENSE b/LICENSE index 4ecba18..7a4a3ea 100644 --- a/LICENSE +++ b/LICENSE @@ -1,181 +1,182 @@ + Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION -1. Definitions. + 1. Definitions. - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. -END OF TERMS AND CONDITIONS + END OF TERMS AND CONDITIONS -APPENDIX: How to apply the Apache License to your work. + APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" @@ -186,16 +187,16 @@ APPENDIX: How to apply the Apache License to your work. same "printed page" as the copyright notice for easier identification within third-party archives. -Copyright 2025 OpenAI + Copyright [yyyy] [name of copyright owner] -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. \ No newline at end of file + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file From ff28a9ed8767acaa1bac03c614f9fb065d55b850 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 11:48:26 -0700 Subject: [PATCH 20/44] clippy --- src/py_module.rs | 8 +------- src/wasm_module.rs | 7 +------ 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/src/py_module.rs b/src/py_module.rs index fbb3129..e7bb9e5 100644 --- a/src/py_module.rs +++ b/src/py_module.rs @@ -12,9 +12,6 @@ //! A thin, typed, user-facing Python wrapper around these low-level bindings is //! provided in `harmony/__init__.py`. -// Only compile when the `python-binding` feature is enabled. -#![cfg(feature = "python-binding")] - use pyo3::prelude::*; // We need the `Python` type later on. @@ -34,8 +31,6 @@ use crate::{ load_harmony_encoding, HarmonyEncodingName, }; -use serde_json; - /// A thin PyO3 wrapper around the Rust `HarmonyEncoding` struct. #[pyclass] struct PyHarmonyEncoding { @@ -393,8 +388,7 @@ fn openai_harmony(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { "python" => ToolNamespaceConfig::python(), _ => { return Err(PyErr::new::(format!( - "Unknown tool namespace: {}", - tool + "Unknown tool namespace: {tool}" ))); } }; diff --git a/src/wasm_module.rs b/src/wasm_module.rs index 9ffb8fd..b1c4490 100644 --- a/src/wasm_module.rs +++ b/src/wasm_module.rs @@ -1,5 +1,3 @@ -#![cfg(feature = "wasm-binding")] - use wasm_bindgen::prelude::*; use crate::{ @@ -9,8 +7,6 @@ use crate::{ }; use serde::Deserialize; -use serde_json; -use serde_wasm_bindgen; #[wasm_bindgen] extern "C" { @@ -335,8 +331,7 @@ pub fn get_tool_namespace_config(tool: &str) -> Result { "python" => ToolNamespaceConfig::python(), _ => { return Err(JsValue::from_str(&format!( - "Unknown tool namespace: {}", - tool + "Unknown tool namespace: {tool}" ))) } }; From bcbabf7b931e9ff37c071f44f90ace6bd5755e3e Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 11:53:47 -0700 Subject: [PATCH 21/44] gate builds on tests --- .github/workflows/CI.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 38fcdb5..45b5516 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -33,7 +33,9 @@ jobs: with: python-version: "3.11" rust-toolchain: stable + linux: + needs: [tests] runs-on: ${{ matrix.platform.runner }} strategy: matrix: @@ -68,6 +70,7 @@ jobs: path: dist musllinux: + needs: [tests] runs-on: ${{ matrix.platform.runner }} strategy: matrix: @@ -102,6 +105,7 @@ jobs: path: dist windows: + needs: [tests] runs-on: ${{ matrix.platform.runner }} strategy: matrix: @@ -129,6 +133,7 @@ jobs: path: dist macos: + needs: [tests] runs-on: ${{ matrix.platform.runner }} strategy: matrix: @@ -155,6 +160,7 @@ jobs: path: dist sdist: + needs: [tests] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From 2fc90b29ef58380c7775fab2f34e41daa8516d6c Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 11:56:46 -0700 Subject: [PATCH 22/44] wip --- .github/actions/run-rust-python-tests/action.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/actions/run-rust-python-tests/action.yml b/.github/actions/run-rust-python-tests/action.yml index ed62480..6ff2650 100644 --- a/.github/actions/run-rust-python-tests/action.yml +++ b/.github/actions/run-rust-python-tests/action.yml @@ -31,10 +31,10 @@ runs: run: | python -m pip install --upgrade pip - - name: Install build/test deps + - name: Install test deps shell: bash run: | - python -m pip install maturin pytest + python -m pip install pytest - name: Check rustfmt shell: bash @@ -56,10 +56,10 @@ runs: run: | cargo test --doc - - name: Build Python extension (maturin develop) + - name: Build and install Python package shell: bash run: | - maturin develop --release + pip install . - name: Run pytest shell: bash From eaa1be0286975cd934b88fe61c0107aa6511d94f Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 12:05:06 -0700 Subject: [PATCH 23/44] fix tests on windows --- src/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tests.rs b/src/tests.rs index 92dcb5f..93751a9 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -83,6 +83,8 @@ fn test_simple_convo_with_effort() { for encoding_name in ENCODINGS { let encoding = load_harmony_encoding(encoding_name).unwrap(); for (effort, expected_text, use_instruction) in test_cases { + // on windows, we need to replace \r\n with \n + let expected_text = expected_text.replace("\r\n", "\n"); let expected_tokens = encoding .tokenizer .encode( From 69001b70642c324a28e249bcbb49cbc333897c64 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 12:11:49 -0700 Subject: [PATCH 24/44] window fix again --- src/tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/tests.rs b/src/tests.rs index 93751a9..9e19a0c 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -25,7 +25,9 @@ fn test_simple_convo() { let expected_tokens = encoding .tokenizer .encode( - include_str!("../test-data/test_simple_convo.txt").trim_end(), + include_str!("../test-data/test_simple_convo.txt") + .replace("\r\n", "\n") + .trim_end(), &encoding.tokenizer.special_tokens(), ) .0; From 155f53eca8f2b4c70385566822c06cacf9b9e8b5 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 12:55:20 -0700 Subject: [PATCH 25/44] windows fix again --- src/tests.rs | 78 ++++++++++++++++++++++++++++------------------------ 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/src/tests.rs b/src/tests.rs index 9e19a0c..d6b8504 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,3 +1,5 @@ +use std::path::Path; + use crate::{ chat::{ Author, Conversation, DeveloperContent, Message, ReasoningEffort, Role, SystemContent, @@ -10,12 +12,25 @@ use crate::{ use pretty_assertions::{assert_eq, Comparison}; use serde_json::json; -fn parse_tokens(text: &str) -> Vec { - text.split_whitespace() +fn parse_tokens(text: impl AsRef) -> Vec { + text.as_ref() + .split_whitespace() .map(|s| s.parse().unwrap()) .collect() } +fn load_test_data(path: impl AsRef) -> String { + // on windows, we need to replace \r\n with \n + let cargo_manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let src_dir = cargo_manifest_dir.join("src"); + let path = src_dir.join(path); + std::fs::read_to_string(path) + .unwrap() + .replace("\r\n", "\n") + .trim_end() + .to_string() +} + const ENCODINGS: [HarmonyEncodingName; 1] = [HarmonyEncodingName::HarmonyGptOss]; #[test] @@ -25,9 +40,7 @@ fn test_simple_convo() { let expected_tokens = encoding .tokenizer .encode( - include_str!("../test-data/test_simple_convo.txt") - .replace("\r\n", "\n") - .trim_end(), + load_test_data("../test-data/test_simple_convo.txt").as_str(), &encoding.tokenizer.special_tokens(), ) .0; @@ -52,47 +65,42 @@ fn test_simple_convo_with_effort() { let test_cases = [ ( ReasoningEffort::Low, - include_str!("../test-data/test_simple_convo_low_effort.txt"), + load_test_data("../test-data/test_simple_convo_low_effort.txt"), true, ), ( ReasoningEffort::Medium, - include_str!("../test-data/test_simple_convo_medium_effort.txt"), + load_test_data("../test-data/test_simple_convo_medium_effort.txt"), true, ), ( ReasoningEffort::High, - include_str!("../test-data/test_simple_convo_high_effort.txt"), + load_test_data("../test-data/test_simple_convo_high_effort.txt"), true, ), ( ReasoningEffort::Low, - include_str!("../test-data/test_simple_convo_low_effort_no_instruction.txt"), + load_test_data("../test-data/test_simple_convo_low_effort_no_instruction.txt"), false, ), ( ReasoningEffort::Medium, - include_str!("../test-data/test_simple_convo_medium_effort_no_instruction.txt"), + load_test_data("../test-data/test_simple_convo_medium_effort_no_instruction.txt"), false, ), ( ReasoningEffort::High, - include_str!("../test-data/test_simple_convo_high_effort_no_instruction.txt"), + load_test_data("../test-data/test_simple_convo_high_effort_no_instruction.txt"), false, ), ]; for encoding_name in ENCODINGS { let encoding = load_harmony_encoding(encoding_name).unwrap(); - for (effort, expected_text, use_instruction) in test_cases { - // on windows, we need to replace \r\n with \n - let expected_text = expected_text.replace("\r\n", "\n"); + for &(effort, ref expected_text, use_instruction) in &test_cases { let expected_tokens = encoding .tokenizer - .encode( - expected_text.trim_end(), - &encoding.tokenizer.special_tokens(), - ) + .encode(expected_text.as_str(), &encoding.tokenizer.special_tokens()) .0; let sys = SystemContent::new() .with_model_identity("You are ChatGPT, a large language model trained by OpenAI.") @@ -127,8 +135,8 @@ fn test_simple_convo_with_effort() { #[test] fn test_simple_reasoning_response() { - let expected_tokens = parse_tokens(include_str!( - "../test-data/test_simple_reasoning_response.txt" + let expected_tokens = parse_tokens(load_test_data( + "../test-data/test_simple_reasoning_response.txt", )); for encoding_name in ENCODINGS { let encoding = load_harmony_encoding(encoding_name).unwrap(); @@ -184,7 +192,7 @@ fn test_reasoning_system_message() { let expected = encoding .tokenizer .encode( - include_str!("../test-data/test_reasoning_system_message.txt").trim_end(), + load_test_data("../test-data/test_reasoning_system_message.txt").as_str(), &encoding.tokenizer.special_tokens(), ) .0; @@ -215,8 +223,8 @@ fn test_reasoning_system_message_no_instruction() { let expected = encoding .tokenizer .encode( - include_str!("../test-data/test_reasoning_system_message_no_instruction.txt") - .trim_end(), + load_test_data("../test-data/test_reasoning_system_message_no_instruction.txt") + .as_str(), &encoding.tokenizer.special_tokens(), ) .0; @@ -249,8 +257,8 @@ fn test_reasoning_system_message_with_dates() { let expected = encoding .tokenizer .encode( - include_str!("../test-data/test_reasoning_system_message_with_dates.txt") - .trim_end(), + load_test_data("../test-data/test_reasoning_system_message_with_dates.txt") + .as_str(), &encoding.tokenizer.special_tokens(), ) .0; @@ -279,8 +287,7 @@ fn test_reasoning_system_message_with_dates() { #[test] fn test_render_functions_with_parameters() { let encoding = load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss).unwrap(); - let expected_output = - include_str!("../test-data/test_render_functions_with_parameters.txt").trim_end(); + let expected_output = load_test_data("../test-data/test_render_functions_with_parameters.txt"); let sys = SystemContent::new() .with_reasoning_effort(ReasoningEffort::High) @@ -386,7 +393,7 @@ fn test_render_functions_with_parameters() { #[test] fn test_browser_and_python_tool() { let encoding = load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss).unwrap(); - let expected_output = include_str!("../test-data/test_browser_and_python_tool.txt").trim_end(); + let expected_output = load_test_data("../test-data/test_browser_and_python_tool.txt"); let convo = Conversation::from_messages([Message::from_role_and_content( Role::System, @@ -407,7 +414,7 @@ fn test_browser_and_python_tool() { #[test] fn test_dropping_cot_by_default() { let encoding = load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss).unwrap(); - let expected_output = include_str!("../test-data/test_dropping_cot_by_default.txt").trim_end(); + let expected_output = load_test_data("../test-data/test_dropping_cot_by_default.txt"); let convo = Conversation::from_messages([ Message::from_role_and_content(Role::User, "What is 2 + 2?"), @@ -437,8 +444,7 @@ fn test_dropping_cot_by_default() { #[test] fn test_does_not_drop_if_ongoing_analysis() { let encoding = load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss).unwrap(); - let expected_output = - include_str!("../test-data/test_does_not_drop_if_ongoing_analysis.txt").trim_end(); + let expected_output = load_test_data("../test-data/test_does_not_drop_if_ongoing_analysis.txt"); let convo = Conversation::from_messages([ Message::from_role_and_content(Role::User, "What is the weather in SF?"), @@ -474,7 +480,7 @@ fn test_does_not_drop_if_ongoing_analysis() { #[test] fn test_preserve_cot() { let encoding = load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss).unwrap(); - let expected_output = include_str!("../test-data/test_preserve_cot.txt").trim_end(); + let expected_output = load_test_data("../test-data/test_preserve_cot.txt"); let convo = Conversation::from_messages([ Message::from_role_and_content(Role::User, "What is 2 + 2?"), @@ -538,10 +544,10 @@ fn test_decode_utf8_invalid_token() { #[test] fn test_tool_response_parsing() { let encoding = load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss).unwrap(); - let text_tokens = include_str!("../test-data/test_tool_response_parsing.txt").trim_end(); + let text_tokens = load_test_data("../test-data/test_tool_response_parsing.txt"); let tokens = encoding .tokenizer - .encode(text_tokens, &encoding.tokenizer.special_tokens()) + .encode(&text_tokens, &encoding.tokenizer.special_tokens()) .0; let expected_message = Message::from_author_and_content( @@ -620,10 +626,10 @@ fn test_invalid_utf8_decoding() { #[test] fn test_streamable_parser() { let encoding = load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss).unwrap(); - let text = include_str!("../test-data/test_streamable_parser.txt").trim_end(); + let text = load_test_data("../test-data/test_streamable_parser.txt"); let tokens = encoding .tokenizer - .encode(text, &encoding.tokenizer.special_tokens()) + .encode(&text, &encoding.tokenizer.special_tokens()) .0; let mut parser = crate::encoding::StreamableParser::new(encoding.clone(), Some(Role::Assistant)).unwrap(); From 653bc4dffb65dff0d78fed6fd5e64fe758616b2e Mon Sep 17 00:00:00 2001 From: Tialo <65392801+Tialo@users.noreply.github.com> Date: Tue, 5 Aug 2025 23:27:32 +0300 Subject: [PATCH 26/44] use developer_message variable --- docs/format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/format.md b/docs/format.md index 0e1a779..a03aae8 100644 --- a/docs/format.md +++ b/docs/format.md @@ -92,7 +92,7 @@ developer_message = ( convo = Conversation.from_messages( [ Message.from_role_and_content(Role.SYSTEM, system_message), - Message.from_role_and_content(Role.DEVELOPER, system_message), + Message.from_role_and_content(Role.DEVELOPER, developer_message), Message.from_role_and_content(Role.USER, "What is the weather in Tokyo?"), Message.from_role_and_content( Role.ASSISTANT, From 3c06d6fbca22d2a64efbf6cf8e0c46b087ce2d97 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 13:33:36 -0700 Subject: [PATCH 27/44] windows again bro --- .../actions/run-rust-python-tests/action.yml | 2 + tests/test_harmony.py | 46 ++++++++++++------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.github/actions/run-rust-python-tests/action.yml b/.github/actions/run-rust-python-tests/action.yml index 6ff2650..b06aefb 100644 --- a/.github/actions/run-rust-python-tests/action.yml +++ b/.github/actions/run-rust-python-tests/action.yml @@ -63,5 +63,7 @@ runs: - name: Run pytest shell: bash + env: + PYTHONUTF8: "1" run: | pytest -q diff --git a/tests/test_harmony.py b/tests/test_harmony.py index 8392d7f..07d5562 100644 --- a/tests/test_harmony.py +++ b/tests/test_harmony.py @@ -59,7 +59,7 @@ def _assert_tokens_eq(encoding, expected: List[int], actual: List[int]): # type def read_expected_tokens(file_path: Path) -> List[int]: - with open(file_path, "r") as f: + with open(file_path, "r", encoding="utf-8") as f: return [int(x) for x in f.read().split()] @@ -78,7 +78,9 @@ def test_simple_convo(encoding_name): encoding = load_harmony_encoding(encoding_name) expected_text = ( - (ROOT_DIR / "test-data" / "test_simple_convo.txt").read_text().rstrip() + (ROOT_DIR / "test-data" / "test_simple_convo.txt") + .read_text(encoding="utf-8") + .rstrip() ) expected_tokens = encoding.encode(expected_text, allowed_special="all") @@ -143,7 +145,7 @@ def test_simple_convo_with_effort(encoding_name): ] for effort, tokens_path, use_instruction in test_cases: - expected_text = tokens_path.read_text().rstrip() + expected_text = tokens_path.read_text(encoding="utf-8").rstrip() expected_tokens = encoding.encode(expected_text, allowed_special="all") sys = ( SystemContent.new() @@ -299,7 +301,7 @@ def test_reasoning_system_message(encoding_name): expected_text = ( (ROOT_DIR / "test-data" / "test_reasoning_system_message.txt") - .read_text() + .read_text(encoding="utf-8") .rstrip() ) expected = encoding.encode(expected_text, allowed_special="all") @@ -336,7 +338,7 @@ def test_reasoning_system_message_no_instruction(encoding_name): expected_text = ( (ROOT_DIR / "test-data" / "test_reasoning_system_message_no_instruction.txt") - .read_text() + .read_text(encoding="utf-8") .rstrip() ) expected = encoding.encode(expected_text, allowed_special="all") @@ -376,7 +378,7 @@ def test_reasoning_system_message_with_dates(encoding_name): expected_text = ( (ROOT_DIR / "test-data" / "test_reasoning_system_message_with_dates.txt") - .read_text() + .read_text(encoding="utf-8") .rstrip() ) expected = encoding.encode(expected_text, allowed_special="all") @@ -409,7 +411,7 @@ def test_render_functions_with_parameters(): expected_output = ( (ROOT_DIR / "test-data" / "test_render_functions_with_parameters.txt") - .read_text() + .read_text(encoding="utf-8") .rstrip() ) @@ -526,7 +528,9 @@ def test_render_functions_with_parameters(): def test_no_tools(): encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS) expected_output = ( - (ROOT_DIR / "test-data" / "test_no_tools.txt").read_text().rstrip() + (ROOT_DIR / "test-data" / "test_no_tools.txt") + .read_text(encoding="utf-8") + .rstrip() ) convo = Conversation.from_messages( @@ -546,7 +550,9 @@ def test_no_tools(): def test_browser_tool_only(): encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS) expected_output = ( - (ROOT_DIR / "test-data" / "test_browser_tool_only.txt").read_text().rstrip() + (ROOT_DIR / "test-data" / "test_browser_tool_only.txt") + .read_text(encoding="utf-8") + .rstrip() ) convo = Conversation.from_messages( @@ -569,7 +575,7 @@ def test_browser_and_function_tool(): encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS) expected_output = ( (ROOT_DIR / "test-data" / "test_browser_and_function_tool.txt") - .read_text() + .read_text(encoding="utf-8") .rstrip() ) @@ -611,7 +617,7 @@ def test_browser_and_python_tool(): encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS) expected_output = ( (ROOT_DIR / "test-data" / "test_browser_and_python_tool.txt") - .read_text() + .read_text(encoding="utf-8") .rstrip() ) @@ -637,7 +643,7 @@ def test_dropping_cot_by_default(): expected_output = ( (ROOT_DIR / "test-data" / "test_dropping_cot_by_default.txt") - .read_text() + .read_text(encoding="utf-8") .rstrip() ) @@ -667,7 +673,7 @@ def test_does_not_drop_if_ongoing_analysis(): expected_output = ( (ROOT_DIR / "test-data" / "test_does_not_drop_if_ongoing_analysis.txt") - .read_text() + .read_text(encoding="utf-8") .rstrip() ) @@ -702,7 +708,9 @@ def test_preserve_cot(): encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS) expected_output = ( - (ROOT_DIR / "test-data" / "test_preserve_cot.txt").read_text().rstrip() + (ROOT_DIR / "test-data" / "test_preserve_cot.txt") + .read_text(encoding="utf-8") + .rstrip() ) convo = Conversation.from_messages( @@ -738,7 +746,7 @@ def test_keep_analysis_between_final_messages(): expected_output = ( (ROOT_DIR / "test-data" / "test_keep_analysis_between_finals.txt") - .read_text() + .read_text(encoding="utf-8") .rstrip() ) @@ -880,7 +888,9 @@ def test_tool_response_parsing(): encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS) text_tokens = ( - (ROOT_DIR / "test-data" / "test_tool_response_parsing.txt").read_text().rstrip() + (ROOT_DIR / "test-data" / "test_tool_response_parsing.txt") + .read_text(encoding="utf-8") + .rstrip() ) expected_message = ( @@ -904,7 +914,9 @@ def test_streamable_parser(): encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS) text_tokens = ( - (ROOT_DIR / "test-data" / "test_streamable_parser.txt").read_text().rstrip() + (ROOT_DIR / "test-data" / "test_streamable_parser.txt") + .read_text(encoding="utf-8") + .rstrip() ) tokens = encoding.encode(text_tokens, allowed_special="all") From c81ef707bcf5473128f1de9d21c166e64f9cacb2 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 13:38:25 -0700 Subject: [PATCH 28/44] remove Makefile --- Makefile | 12 ------------ 1 file changed, 12 deletions(-) delete mode 100644 Makefile diff --git a/Makefile b/Makefile deleted file mode 100644 index 14951ab..0000000 --- a/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -.PHONY: javascript -js: - # Browser ESM build - wasm-pack build --target web --out-dir javascript/dist/web --features wasm-binding --no-default-features - - # Node.js ESM/CJS-compatible build - wasm-pack build --target nodejs --out-dir javascript/dist/node --features wasm-binding --no-default-features - - -.PHONY: python-local -python-local: - maturin develop -F python-binding --release From d00ac3de4947868ecb00f8a0c7652ccdacb493d8 Mon Sep 17 00:00:00 2001 From: Jordan Wu <101218661+jordan-definitive@users.noreply.github.com> Date: Tue, 5 Aug 2025 14:52:12 -0700 Subject: [PATCH 29/44] fix: make `HarmonyEncoding` usable concurrently the `conversation_has_function_tools` atomic bool makes `HarmonyEncoding` stateful --- python/openai_harmony/__init__.py | 19 ++++++- src/encoding.rs | 91 ++++++++++++++++++++++--------- src/py_module.rs | 20 ++++++- src/registry.rs | 3 +- src/tests.rs | 2 +- src/wasm_module.rs | 29 +++++++++- 6 files changed, 130 insertions(+), 34 deletions(-) diff --git a/python/openai_harmony/__init__.py b/python/openai_harmony/__init__.py index 3485864..13b5fdd 100644 --- a/python/openai_harmony/__init__.py +++ b/python/openai_harmony/__init__.py @@ -425,6 +425,10 @@ class RenderConversationConfig(BaseModel): auto_drop_analysis: bool = True +class RenderOptions(BaseModel): + conversation_has_function_tools: bool = False + + class HarmonyEncoding: """High-level wrapper around the Rust ``PyHarmonyEncoding`` class.""" @@ -498,9 +502,20 @@ class HarmonyEncoding: config=config_dict, ) - def render(self, message: Message) -> List[int]: + def render( + self, message: Message, render_options: Optional[RenderOptions] = None + ) -> List[int]: """Render a single message into tokens.""" - return self._inner.render(message_json=message.to_json()) + if render_options is None: + render_options_dict = {"conversation_has_function_tools": False} + else: + render_options_dict = { + "conversation_has_function_tools": render_options.conversation_has_function_tools + } + + return self._inner.render( + message_json=message.to_json(), render_options=render_options_dict + ) # -- Parsing ------------------------------------------------------- diff --git a/src/encoding.rs b/src/encoding.rs index c58e8b8..afe1fce 100644 --- a/src/encoding.rs +++ b/src/encoding.rs @@ -5,10 +5,7 @@ use crate::{ use anyhow::Context as _; use std::{ collections::{HashMap, HashSet}, - sync::{ - atomic::{AtomicBool, Ordering}, - Arc, - }, + sync::Arc, vec, }; @@ -92,7 +89,6 @@ pub struct HarmonyEncoding { pub(crate) format_token_mapping: HashMap, pub(crate) stop_formatting_tokens: HashSet, pub(crate) stop_formatting_tokens_for_assistant_actions: HashSet, - pub(crate) conversation_has_function_tools: Arc, } impl std::fmt::Debug for HarmonyEncoding { @@ -191,8 +187,9 @@ impl HarmonyEncoding { } }) }); - self.conversation_has_function_tools - .store(has_function_tools, Ordering::Relaxed); + let render_options = RenderOptions { + conversation_has_function_tools: has_function_tools, + }; let last_assistant_is_final = messages .iter() .rev() @@ -217,9 +214,7 @@ impl HarmonyEncoding { && first_final_idx.is_some_and(|first| *idx < first) && msg.channel.as_deref() == Some("analysis")) }) - .try_for_each(|(_, msg)| self.render_into(msg, into)); - self.conversation_has_function_tools - .store(false, Ordering::Relaxed); + .try_for_each(|(_, msg)| self.render_into(msg, into, Some(&render_options))); result?; Ok(()) } @@ -305,18 +300,27 @@ impl HarmonyEncoding { } /// Render a single message into tokens. - pub fn render(&self, message: &Message) -> anyhow::Result> { + pub fn render( + &self, + message: &Message, + render_options: Option<&RenderOptions>, + ) -> anyhow::Result> { let mut out = vec![]; - Render::::render(self, message, &mut out)?; + Render::::render(self, message, &mut out, render_options)?; Ok(out) } /// Render a single message into the provided buffer. - pub fn render_into(&self, message: &Message, into: &mut B) -> anyhow::Result<()> + pub fn render_into( + &self, + message: &Message, + into: &mut B, + render_options: Option<&RenderOptions>, + ) -> anyhow::Result<()> where B: Extend, { - Render::::render(self, message, into) + Render::::render(self, message, into, render_options) } } @@ -772,14 +776,29 @@ impl HarmonyEncoding { } } +#[derive(Clone, Copy, Debug, Default)] +pub struct RenderOptions { + pub conversation_has_function_tools: bool, +} + trait Render { - fn render(&self, item: &T, into: &mut B) -> anyhow::Result<()> + fn render( + &self, + item: &T, + into: &mut B, + render_options: Option<&RenderOptions>, + ) -> anyhow::Result<()> where B: Extend; } impl Render for HarmonyEncoding { - fn render(&self, message: &Message, into: &mut B) -> anyhow::Result<()> + fn render( + &self, + message: &Message, + into: &mut B, + render_options: Option<&RenderOptions>, + ) -> anyhow::Result<()> where B: Extend, { @@ -836,7 +855,7 @@ impl Render for HarmonyEncoding { message.author.role ); } - Render::::render(self, content, into)?; + Render::::render(self, content, into, render_options)?; } // If there is a tool call we should render a tool call token @@ -851,15 +870,22 @@ impl Render for HarmonyEncoding { // Dispatch Content variants to their specific Render implementations impl Render for HarmonyEncoding { - fn render(&self, content: &Content, into: &mut B) -> anyhow::Result<()> + fn render( + &self, + content: &Content, + into: &mut B, + render_options: Option<&RenderOptions>, + ) -> anyhow::Result<()> where B: Extend, { match content { - Content::Text(text) => Render::::render(self, text, into), - Content::SystemContent(sys) => Render::::render(self, sys, into), + Content::Text(text) => Render::::render(self, text, into, render_options), + Content::SystemContent(sys) => { + Render::::render(self, sys, into, render_options) + } Content::DeveloperContent(dev) => { - Render::::render(self, dev, into) + Render::::render(self, dev, into, render_options) } } } @@ -867,7 +893,12 @@ impl Render for HarmonyEncoding { // Render plain text content impl Render for HarmonyEncoding { - fn render(&self, text: &TextContent, into: &mut B) -> anyhow::Result<()> + fn render( + &self, + text: &TextContent, + into: &mut B, + _render_options: Option<&RenderOptions>, + ) -> anyhow::Result<()> where B: Extend, { @@ -877,7 +908,12 @@ impl Render for HarmonyEncoding { // Render system-specific content (model identity, instructions, effort) impl Render for HarmonyEncoding { - fn render(&self, sys: &SystemContent, into: &mut B) -> anyhow::Result<()> + fn render( + &self, + sys: &SystemContent, + into: &mut B, + render_options: Option<&RenderOptions>, + ) -> anyhow::Result<()> where B: Extend, { @@ -923,7 +959,7 @@ impl Render for HarmonyEncoding { if channel_config.channel_required { channels_header.push_str(" Channel must be included for every message."); } - if self.conversation_has_function_tools.load(Ordering::Relaxed) { + if render_options.is_some_and(|o| o.conversation_has_function_tools) { channels_header.push('\n'); channels_header.push_str( "Calls to these tools must go to the commentary channel: 'functions'.", @@ -940,7 +976,12 @@ impl Render for HarmonyEncoding { // Render developer-specific content (instructions, tools) impl Render for HarmonyEncoding { - fn render(&self, dev: &crate::chat::DeveloperContent, into: &mut B) -> anyhow::Result<()> + fn render( + &self, + dev: &crate::chat::DeveloperContent, + into: &mut B, + _render_options: Option<&RenderOptions>, + ) -> anyhow::Result<()> where B: Extend, { diff --git a/src/py_module.rs b/src/py_module.rs index e7bb9e5..c5c7b0a 100644 --- a/src/py_module.rs +++ b/src/py_module.rs @@ -178,13 +178,29 @@ impl PyHarmonyEncoding { } /// Render a single message into tokens. - fn render(&self, message_json: &str) -> PyResult> { + fn render( + &self, + message_json: &str, + render_options: Option>, + ) -> PyResult> { let message: crate::chat::Message = serde_json::from_str(message_json).map_err(|e| { PyErr::new::(format!("invalid message JSON: {e}")) })?; + let rust_options = if let Some(options_dict) = render_options { + let conversation_has_function_tools = options_dict + .get_item("conversation_has_function_tools")? + .and_then(|v| v.extract().ok()) + .unwrap_or(false); + Some(crate::encoding::RenderOptions { + conversation_has_function_tools, + }) + } else { + None + }; + self.inner - .render(&message) + .render(&message, rust_options.as_ref()) .map_err(|e| PyErr::new::(e.to_string())) } diff --git a/src/registry.rs b/src/registry.rs index 6d8a98f..d1ffd2e 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -1,6 +1,6 @@ use std::{ collections::{HashMap, HashSet}, - sync::{atomic::AtomicBool, Arc}, + sync::Arc, }; use crate::{ @@ -76,7 +76,6 @@ pub fn load_harmony_encoding(name: HarmonyEncodingName) -> anyhow::Result Result, JsValue> { + pub fn render( + &self, + message: JsMessage, + render_options: JsRenderOptions, + ) -> Result, JsValue> { let message: JsValue = message.into(); let message: crate::chat::Message = serde_wasm_bindgen::from_value(message) .map_err(|e| JsValue::from_str(&format!("invalid message JSON: {e}")))?; + + #[derive(Deserialize)] + struct RenderOptions { + conversation_has_function_tools: Option, + } + let render_options: JsValue = render_options.into(); + let rust_options = if render_options.is_undefined() || render_options.is_null() { + None + } else { + let cfg: RenderOptions = serde_wasm_bindgen::from_value(render_options) + .map_err(|e| JsValue::from_str(&format!("invalid render options: {e}")))?; + Some(crate::encoding::RenderOptions { + conversation_has_function_tools: cfg + .conversation_has_function_tools + .unwrap_or(false), + }) + }; + self.inner - .render(&message) + .render(&message, rust_options.as_ref()) .map_err(|e| JsValue::from_str(&e.to_string())) } From 9528c7b4a00a3307fd9685fc1328aee11c3d9c90 Mon Sep 17 00:00:00 2001 From: Scott Lessans Date: Tue, 5 Aug 2025 17:13:26 -0700 Subject: [PATCH 30/44] version bump --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6b774e8..ce97b77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,7 +1317,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openai-harmony" -version = "0.0.2" +version = "0.0.3" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 12ebc27..25d070c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openai-harmony" -version = "0.0.2" +version = "0.0.3" edition = "2021" license = "Apache-2.0" repository = "https://github.com/openai/harmony" From 1eb285b8745c574c7ecc6f880f452dae1fc194c8 Mon Sep 17 00:00:00 2001 From: dkqjrm Date: Wed, 6 Aug 2025 09:31:41 +0900 Subject: [PATCH 31/44] fix: correct typo in docs/format.md Fix duplicate text in safety warning where 'You should We recommend' should be just 'We recommend'. --- docs/format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/format.md b/docs/format.md index a03aae8..7ec0113 100644 --- a/docs/format.md +++ b/docs/format.md @@ -305,7 +305,7 @@ And the actual answer is: ``` **Important:** -The model has not been trained to the same safety standards in the chain-of-thought as it has for final output. You should We recommend not to show the chain-of-thought to your users as they might contain harmful content. [Learn more in the model card](https://openai.com/index/gpt-oss-model-card/). +The model has not been trained to the same safety standards in the chain-of-thought as it has for final output. We recommend not to show the chain-of-thought to your users as they might contain harmful content. [Learn more in the model card](https://openai.com/index/gpt-oss-model-card/). #### Handling reasoning output in subsequent sampling From a01b5e66ffa471528f28f6133b5c5b804381202c Mon Sep 17 00:00:00 2001 From: axion66 Date: Tue, 5 Aug 2025 22:05:51 -0400 Subject: [PATCH 32/44] docs: improve repo layout and checks --- README.md | 2 +- run_checks.sh | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d9c5f13..5b460ca 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ through thin [`pyo3`](https://pyo3.rs/) bindings. │ ├── tests.rs # Canonical Rust test-suite │ └── py_module.rs # PyO3 bindings ⇒ compiled as openai_harmony.*.so │ -├── harmony/ # Pure-Python wrapper around the binding +├── python/openai_harmony/ # Pure-Python wrapper around the binding │ └── __init__.py # Dataclasses + helper API mirroring chat.rs │ ├── tests/ # Python test-suite (1-to-1 port of tests.rs) diff --git a/run_checks.sh b/run_checks.sh index 2b9d439..c945094 100755 --- a/run_checks.sh +++ b/run_checks.sh @@ -5,4 +5,5 @@ set -e cargo fmt --check cargo clippy --all-targets --all-features -- -D warnings cargo test --all-targets --all-features - +mypy python/openai_harmony +ruff check . From 85e43ea7f821a78ac56ed4e35955b25b2217fb69 Mon Sep 17 00:00:00 2001 From: axion66 Date: Tue, 5 Aug 2025 22:07:51 -0400 Subject: [PATCH 33/44] dep --- run_checks.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/run_checks.sh b/run_checks.sh index c945094..2b9d439 100755 --- a/run_checks.sh +++ b/run_checks.sh @@ -5,5 +5,4 @@ set -e cargo fmt --check cargo clippy --all-targets --all-features -- -D warnings cargo test --all-targets --all-features -mypy python/openai_harmony -ruff check . + From 0c9c61c35cc21962a10666bcfe941045fcc0b2e4 Mon Sep 17 00:00:00 2001 From: Yuan-Man <68322456+Yuan-ManX@users.noreply.github.com> Date: Wed, 6 Aug 2025 10:28:12 +0800 Subject: [PATCH 34/44] Update harmony format --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d9c5f13..d9672e2 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ Current date: 2025-06-28 Reasoning: high # Valid channels: analysis, commentary, final. Channel must be included for every message. -Calls to these tools must go to the commentary channel: 'functions'.<|end|><|start|>developer<|message|># Instructions +Calls to these tools must go to the commentary channel: 'functions'.<|end|> + +<|start|>developer<|message|># Instructions Always respond in riddles From c0f2f407e0f1693916b280b03413131bb61ac5ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20Gie=C3=9Fing?= Date: Wed, 6 Aug 2025 09:13:19 +0200 Subject: [PATCH 35/44] Enable PowerPC arch --- .github/workflows/CI.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 45b5516..4fcb76f 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -48,6 +48,8 @@ jobs: target: aarch64 - runner: ubuntu-22.04 target: armv7 + - runner: ubuntu-22.04 + target: ppc64le steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 From bd5ed13085bb82bd35c8f98ca5d6d12c4c5e99d7 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Fri, 8 Aug 2025 04:58:53 -0700 Subject: [PATCH 36/44] Add 'DeveloperContent' to __all__ For better import * --- python/openai_harmony/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/python/openai_harmony/__init__.py b/python/openai_harmony/__init__.py index 13b5fdd..33afbd7 100644 --- a/python/openai_harmony/__init__.py +++ b/python/openai_harmony/__init__.py @@ -708,6 +708,7 @@ __all__ = [ "Author", "Content", "TextContent", + "DeveloperContent", "ToolDescription", "SystemContent", "Message", From 8a4645f0f9d9c20a8ecd3381dbb88ce5f56084cc Mon Sep 17 00:00:00 2001 From: Dmytro Dzhulgakov Date: Fri, 8 Aug 2025 17:50:17 -0700 Subject: [PATCH 37/44] Fix tokenization of <|constrain|> content type in rendering (#47) --- src/encoding.rs | 17 ++++++++++++++++- tests/test_harmony.py | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/encoding.rs b/src/encoding.rs index afe1fce..d57f8ec 100644 --- a/src/encoding.rs +++ b/src/encoding.rs @@ -835,7 +835,22 @@ impl Render for HarmonyEncoding { // finally content type if let Some(content_type) = &message.content_type { - self.render_text_into(format!(" {content_type}"), into)?; + // <|constrain|> is a unique case which needs to be tokenized as a special token + if let Some(constrain_marker) = self.mapped_format_token(FormattingToken::ConstrainedFormat) { + if content_type.starts_with(constrain_marker) { + // Render the space, then the constrain marker as a special token, then the rest as text (if any) + self.render_text_into(" ", into)?; + self.render_formatting_token_into(FormattingToken::ConstrainedFormat, into)?; + let rest = &content_type[constrain_marker.len()..]; + if !rest.is_empty() { + self.render_text_into(rest, into)?; + } + } else { + self.render_text_into(format!(" {content_type}"), into)?; + } + } else { + self.render_text_into(format!(" {content_type}"), into)?; + } } self.render_formatting_token_into(FormattingToken::Message, into)?; diff --git a/tests/test_harmony.py b/tests/test_harmony.py index 07d5562..dd34e81 100644 --- a/tests/test_harmony.py +++ b/tests/test_harmony.py @@ -233,6 +233,36 @@ def test_simple_tool_call(encoding_name): assert parsed == expected +@pytest.mark.parametrize( + "encoding_name", + [ + HarmonyEncodingName.HARMONY_GPT_OSS, + ], +) +def test_tool_call_with_constrain_tokenized_correctly(encoding_name): + """ + Despite passing <|constrain|> as a string in "content_type" it has to be kept as a special token. + """ + encoding = load_harmony_encoding(encoding_name) + text = ( + "<|start|>assistant to=functions.get_weather<|channel|>commentary" + ' <|constrain|>json<|message|>{"location": "Tokyo"}<|call|>' + ) + tokens = encoding.encode(text, allowed_special="all") + parsed = encoding.parse_messages_from_completion_tokens(tokens, role=None) + expected = [ + Message.from_role_and_content(Role.ASSISTANT, '{"location": "Tokyo"}') + .with_channel("commentary") + .with_recipient("functions.get_weather") + .with_content_type("<|constrain|>json"), + ] + assert parsed == expected + + rendered = encoding.render_conversation(Conversation.from_messages(expected)) + assert text == encoding.decode_utf8(tokens) + assert rendered == tokens + + @pytest.mark.parametrize( "encoding_name", [ @@ -248,7 +278,7 @@ def test_tool_call_with_constrain_marker_adjacent(encoding_name): encoding = load_harmony_encoding(encoding_name) text = ( "<|start|>assistant to=functions.get_weather<|channel|>commentary" - '<|constrain|>json<|message|>{"location": "Tokyo"}<|end|>' + '<|constrain|>json<|message|>{"location": "Tokyo"}<|call|>' ) tokens = encoding.encode(text, allowed_special="all") parsed = encoding.parse_messages_from_completion_tokens(tokens, role=None) @@ -702,6 +732,8 @@ def test_does_not_drop_if_ongoing_analysis(): ) assert encoding.decode_utf8(tokens) == expected_output + # ensure that <|constrain|>json part is tokenized correctly as special tokens + assert encoding.encode(expected_output, allowed_special="all") == tokens def test_preserve_cot(): From 72079ca4971f326dddcd5ff62bfb7e2fff37a07a Mon Sep 17 00:00:00 2001 From: Dominik Kundel Date: Fri, 8 Aug 2025 18:27:00 -0700 Subject: [PATCH 38/44] Fix formatting (#51) * Fix formatting * fix format * fix clippy error * bump version --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/encoding.rs | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce97b77..e0b0a71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1317,7 +1317,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "openai-harmony" -version = "0.0.3" +version = "0.0.4" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 25d070c..23fa1ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openai-harmony" -version = "0.0.3" +version = "0.0.4" edition = "2021" license = "Apache-2.0" repository = "https://github.com/openai/harmony" diff --git a/src/encoding.rs b/src/encoding.rs index d57f8ec..6a9305b 100644 --- a/src/encoding.rs +++ b/src/encoding.rs @@ -836,12 +836,13 @@ impl Render for HarmonyEncoding { // finally content type if let Some(content_type) = &message.content_type { // <|constrain|> is a unique case which needs to be tokenized as a special token - if let Some(constrain_marker) = self.mapped_format_token(FormattingToken::ConstrainedFormat) { - if content_type.starts_with(constrain_marker) { + if let Some(constrain_marker) = + self.mapped_format_token(FormattingToken::ConstrainedFormat) + { + if let Some(rest) = content_type.strip_prefix(constrain_marker) { // Render the space, then the constrain marker as a special token, then the rest as text (if any) self.render_text_into(" ", into)?; self.render_formatting_token_into(FormattingToken::ConstrainedFormat, into)?; - let rest = &content_type[constrain_marker.len()..]; if !rest.is_empty() { self.render_text_into(rest, into)?; } From 52176bfbec8f1d5e876b9793626a4153c71b3d4a Mon Sep 17 00:00:00 2001 From: nk <66731869+nicognaW@users.noreply.github.com> Date: Sat, 9 Aug 2025 09:28:08 +0800 Subject: [PATCH 39/44] Fix shadcn utils file in js demo (#20) --- .gitignore | 3 +++ demo/harmony-demo/src/lib/utils.ts | 6 ++++++ 2 files changed, 9 insertions(+) create mode 100644 demo/harmony-demo/src/lib/utils.ts diff --git a/.gitignore b/.gitignore index c8f0442..bab5ca1 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,6 @@ docs/_build/ # Pyenv .python-version + +# Avoid ignoring shadcn utils +!demo/harmony-demo/src/lib diff --git a/demo/harmony-demo/src/lib/utils.ts b/demo/harmony-demo/src/lib/utils.ts new file mode 100644 index 0000000..bd0c391 --- /dev/null +++ b/demo/harmony-demo/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} From 3fb0342894e87bbcdc8b6691cac40c7e9a757268 Mon Sep 17 00:00:00 2001 From: Amirhossein Ghanipour Date: Sat, 16 Aug 2025 02:24:12 +0330 Subject: [PATCH 40/44] docs: clairfy use of <|return|> vs <|end|> in conversation history (#59) --- docs/format.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/format.md b/docs/format.md index 7ec0113..49cc1ec 100644 --- a/docs/format.md +++ b/docs/format.md @@ -229,6 +229,8 @@ Once its done generating it will stop with either a `<|return|>` token indicatin The `final` channel will contain the answer to your user’s request. Check out the [reasoning section](#reasoning) for more details on the chain-of-thought. +**Implementation note:** `<|return|>` is a decode-time stop token only. When you add the assistant’s generated reply to conversation history for the next turn, replace the trailing `<|return|>` with `<|end|>` so that stored messages are fully formed as `<|start|>{header}<|message|>{content}<|end|>`. Prior messages in prompts should therefore end with `<|end|>`. For supervised targets/training examples, ending with `<|return|>` is appropriate; for persisted history, normalize to `<|end|>`. + ### System message format The system message is used to provide general information to the system. This is different to what might be considered the “system prompt” in other prompt formats. For that, check out the [developer message format](#developer-message-format). From 535ab26a46a4281b330ede2eb37b90fdca662ccb Mon Sep 17 00:00:00 2001 From: googlefan256 Date: Sat, 16 Aug 2025 07:54:31 +0900 Subject: [PATCH 41/44] docs: update format.md (#45) --- docs/format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/format.md b/docs/format.md index 49cc1ec..a0d2708 100644 --- a/docs/format.md +++ b/docs/format.md @@ -64,7 +64,7 @@ system_message = ( developer_message = ( DeveloperContent.new() .with_instructions("Always respond in riddles") - .with_tools( + .with_function_tools( [ ToolDescription.new( "get_current_weather", From 088321b872064cdabe0f86f09c7c61a87bb063ec Mon Sep 17 00:00:00 2001 From: Dominik Kundel Date: Fri, 15 Aug 2025 16:25:58 -0700 Subject: [PATCH 42/44] docs: update example From e21d67af61e8717663ac84e470865c10d60d925b Mon Sep 17 00:00:00 2001 From: Dominik Kundel Date: Fri, 15 Aug 2025 16:27:04 -0700 Subject: [PATCH 43/44] docs: update code example --- docs/format.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/docs/format.md b/docs/format.md index a0d2708..1e6e529 100644 --- a/docs/format.md +++ b/docs/format.md @@ -52,13 +52,8 @@ encoding = load_harmony_encoding(HarmonyEncodingName.HARMONY_GPT_OSS) system_message = ( SystemContent.new() - .with_model_identity( - "You are ChatGPT, a large language model trained by OpenAI." - ) .with_reasoning_effort(ReasoningEffort.HIGH) .with_conversation_start_date("2025-06-28") - .with_knowledge_cutoff("2024-06") - .with_required_channels(["analysis", "commentary", "final"]) ) developer_message = ( @@ -101,11 +96,11 @@ convo = Conversation.from_messages( Message.from_role_and_content(Role.ASSISTANT, '{"location": "Tokyo"}') .with_channel("commentary") .with_recipient("functions.get_weather") - .with_content_type("json"), + .with_content_type("<|constrain|> json"), Message.from_author_and_content( Author.new(Role.TOOL, "functions.lookup_weather"), '{ "temperature": 20, "sunny": true }', - ), + ).with_channel("commentary"), ] ) From 508cbaa7f6b0277bd37c9bdf6d4dc8a4d51aada5 Mon Sep 17 00:00:00 2001 From: Dominik Kundel Date: Fri, 15 Aug 2025 16:28:10 -0700 Subject: [PATCH 44/44] docs: fix return to end token --- docs/format.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/format.md b/docs/format.md index 1e6e529..2402406 100644 --- a/docs/format.md +++ b/docs/format.md @@ -324,7 +324,7 @@ Then the input for the next sampling should be ``` <|start|>user<|message|>What is 2 + 2?<|end|> -<|start|>assistant<|channel|>final<|message|>2 + 2 = 4.<|return|> +<|start|>assistant<|channel|>final<|message|>2 + 2 = 4.<|end|> <|start|>user<|message|>What about 9 / 2?<|end|> <|start|>assistant ```