Compare commits

...

62 commits
0.0.1 ... main

Author SHA1 Message Date
Dominik Kundel
508cbaa7f6
docs: fix return to end token
Some checks failed
CI / Tests (fmt, clippy, cargo test, doctest, pytest) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:aarch64]) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:armv7]) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:ppc64le]) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:x86]) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:x86_64]) (push) Has been cancelled
CI / musllinux (map[runner:ubuntu-22.04 target:aarch64]) (push) Has been cancelled
CI / musllinux (map[runner:ubuntu-22.04 target:armv7]) (push) Has been cancelled
CI / musllinux (map[runner:ubuntu-22.04 target:x86]) (push) Has been cancelled
CI / musllinux (map[runner:ubuntu-22.04 target:x86_64]) (push) Has been cancelled
CI / windows (map[runner:windows-latest target:x64]) (push) Has been cancelled
CI / windows (map[runner:windows-latest target:x86]) (push) Has been cancelled
CI / macos (map[runner:macos-13 target:x86_64]) (push) Has been cancelled
CI / macos (map[runner:macos-14 target:aarch64]) (push) Has been cancelled
CI / sdist (push) Has been cancelled
CI / Release (push) Has been cancelled
2025-08-15 16:28:10 -07:00
Dominik Kundel
e21d67af61
docs: update code example 2025-08-15 16:27:04 -07:00
Dominik Kundel
088321b872
docs: update example 2025-08-15 16:25:58 -07:00
googlefan256
535ab26a46
docs: update format.md (#45) 2025-08-15 15:54:31 -07:00
Amirhossein Ghanipour
3fb0342894
docs: clairfy use of <|return|> vs <|end|> in conversation history (#59) 2025-08-15 15:54:12 -07:00
nk
52176bfbec
Fix shadcn utils file in js demo (#20)
Some checks failed
CI / Tests (fmt, clippy, cargo test, doctest, pytest) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:aarch64]) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:armv7]) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:ppc64le]) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:x86]) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:x86_64]) (push) Has been cancelled
CI / musllinux (map[runner:ubuntu-22.04 target:aarch64]) (push) Has been cancelled
CI / musllinux (map[runner:ubuntu-22.04 target:armv7]) (push) Has been cancelled
CI / musllinux (map[runner:ubuntu-22.04 target:x86]) (push) Has been cancelled
CI / musllinux (map[runner:ubuntu-22.04 target:x86_64]) (push) Has been cancelled
CI / windows (map[runner:windows-latest target:x64]) (push) Has been cancelled
CI / windows (map[runner:windows-latest target:x86]) (push) Has been cancelled
CI / macos (map[runner:macos-13 target:x86_64]) (push) Has been cancelled
CI / macos (map[runner:macos-14 target:aarch64]) (push) Has been cancelled
CI / sdist (push) Has been cancelled
CI / Release (push) Has been cancelled
2025-08-08 18:28:08 -07:00
Dominik Kundel
72079ca497
Fix formatting (#51)
* Fix formatting

* fix format

* fix clippy error

* bump version
2025-08-08 18:27:00 -07:00
Dmytro Dzhulgakov
8a4645f0f9
Fix tokenization of <|constrain|> content type in rendering (#47) 2025-08-08 17:50:17 -07:00
Scott Lessans
2387e4ae4f
Merge pull request #36 from mgiessing/main
Some checks are pending
CI / Tests (fmt, clippy, cargo test, doctest, pytest) (push) Waiting to run
CI / linux (map[runner:ubuntu-22.04 target:aarch64]) (push) Blocked by required conditions
CI / linux (map[runner:ubuntu-22.04 target:armv7]) (push) Blocked by required conditions
CI / linux (map[runner:ubuntu-22.04 target:ppc64le]) (push) Blocked by required conditions
CI / linux (map[runner:ubuntu-22.04 target:x86]) (push) Blocked by required conditions
CI / linux (map[runner:ubuntu-22.04 target:x86_64]) (push) Blocked by required conditions
CI / musllinux (map[runner:ubuntu-22.04 target:aarch64]) (push) Blocked by required conditions
CI / musllinux (map[runner:ubuntu-22.04 target:armv7]) (push) Blocked by required conditions
CI / musllinux (map[runner:ubuntu-22.04 target:x86]) (push) Blocked by required conditions
CI / musllinux (map[runner:ubuntu-22.04 target:x86_64]) (push) Blocked by required conditions
CI / windows (map[runner:windows-latest target:x64]) (push) Blocked by required conditions
CI / windows (map[runner:windows-latest target:x86]) (push) Blocked by required conditions
CI / macos (map[runner:macos-13 target:x86_64]) (push) Blocked by required conditions
CI / macos (map[runner:macos-14 target:aarch64]) (push) Blocked by required conditions
CI / sdist (push) Blocked by required conditions
CI / Release (push) Blocked by required conditions
Enable PowerPC arch
2025-08-08 14:10:17 -07:00
Scott Lessans
4bab5180af
Merge pull request #50 from simonw/patch-1
Add 'DeveloperContent' to __all__
2025-08-08 14:09:47 -07:00
Simon Willison
bd5ed13085
Add 'DeveloperContent' to __all__
For better import *
2025-08-08 04:58:53 -07:00
Scott Lessans
3efbf74253
Merge pull request #29 from axion66/improve-readme-and-checks
Some checks failed
CI / Tests (fmt, clippy, cargo test, doctest, pytest) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:aarch64]) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:armv7]) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:x86]) (push) Has been cancelled
CI / linux (map[runner:ubuntu-22.04 target:x86_64]) (push) Has been cancelled
CI / musllinux (map[runner:ubuntu-22.04 target:aarch64]) (push) Has been cancelled
CI / musllinux (map[runner:ubuntu-22.04 target:armv7]) (push) Has been cancelled
CI / musllinux (map[runner:ubuntu-22.04 target:x86]) (push) Has been cancelled
CI / musllinux (map[runner:ubuntu-22.04 target:x86_64]) (push) Has been cancelled
CI / windows (map[runner:windows-latest target:x64]) (push) Has been cancelled
CI / windows (map[runner:windows-latest target:x86]) (push) Has been cancelled
CI / macos (map[runner:macos-13 target:x86_64]) (push) Has been cancelled
CI / macos (map[runner:macos-14 target:aarch64]) (push) Has been cancelled
CI / sdist (push) Has been cancelled
CI / Release (push) Has been cancelled
docs: fix typo in README.md ( - harmony -> - python/openai_harmony)
2025-08-06 16:21:08 -07:00
Scott Lessans
9d653a4c73
Merge pull request #30 from Yuan-ManX/harmony-format
Update harmony format
2025-08-06 16:20:40 -07:00
Scott Lessans
161e5fe2a5
Merge pull request #28 from dkqjrm/fix-typo-format-md
Fix a typo in the safety warning section
2025-08-06 16:15:37 -07:00
Marvin Gießing
c0f2f407e0
Enable PowerPC arch 2025-08-06 09:13:19 +02:00
Yuan-Man
0c9c61c35c
Update harmony format 2025-08-06 10:28:12 +08:00
axion66
85e43ea7f8 dep 2025-08-05 22:09:16 -04:00
axion66
a01b5e66ff docs: improve repo layout and checks 2025-08-05 22:05:51 -04:00
dkqjrm
1eb285b874 fix: correct typo in docs/format.md
Fix duplicate text in safety warning where 'You should We recommend' should be just 'We recommend'.
2025-08-06 09:31:41 +09:00
Scott Lessans
9528c7b4a0 version bump 2025-08-05 17:13:26 -07:00
Scott Lessans
82b3afb9eb
Merge pull request #26 from jordan-wu-97/jordan/fix-function-call-atomic-bool
fix: make `HarmonyEncoding` usable concurrently
2025-08-05 17:12:50 -07:00
Jordan Wu
d00ac3de49 fix: make HarmonyEncoding usable concurrently
the `conversation_has_function_tools` atomic bool makes `HarmonyEncoding` stateful
2025-08-05 16:55:03 -07:00
Scott Lessans
b255cbeb62
Merge pull request #18 from openai/dev/scl/better-ci
better ci
2025-08-05 13:44:48 -07:00
Scott Lessans
058ef3257c
Merge pull request #21 from Tialo/main
fix format.md
2025-08-05 13:40:06 -07:00
Scott Lessans
c81ef707bc remove Makefile 2025-08-05 13:38:25 -07:00
Scott Lessans
3c06d6fbca windows again bro 2025-08-05 13:33:36 -07:00
Tialo
653bc4dffb
use developer_message variable 2025-08-05 23:27:32 +03:00
Scott Lessans
155f53eca8 windows fix again 2025-08-05 12:55:20 -07:00
Scott Lessans
69001b7064 window fix again 2025-08-05 12:11:49 -07:00
Scott Lessans
eaa1be0286 fix tests on windows 2025-08-05 12:05:06 -07:00
Scott Lessans
2fc90b29ef wip 2025-08-05 11:56:46 -07:00
Scott Lessans
bcbabf7b93 gate builds on tests 2025-08-05 11:53:47 -07:00
Scott Lessans
ff28a9ed87 clippy 2025-08-05 11:48:26 -07:00
Scott Lessans
6375a15ea1 Merge branch 'main' into dev/scl/better-ci 2025-08-05 11:45:04 -07:00
Scott Lessans
f6179119ca
Merge pull request #8 from RustedBytes/main
Fix errors in wasm_module
2025-08-05 11:43:57 -07:00
Scott Lessans
9418718e67 license update 2025-08-05 11:42:30 -07:00
Scott Lessans
0c69fd960d ci 2025-08-05 11:41:19 -07:00
Yehor Smoliakov
eb86106b69
Merge branch 'main' into main 2025-08-05 21:31:34 +03:00
Scott Lessans
64bca4cf32
Merge pull request #17 from openai/dev/scl/add-docs-to-cargo
Fix readme rust sample, version bump
2025-08-05 11:23:50 -07:00
Scott Lessans
b1b6c5fe9a fixed readme rust sample, added readme to lib.rs docs for doctest, bumped version 2025-08-05 11:18:34 -07:00
Scott Lessans
eeece1861c
Merge pull request #15 from CharlesCNorton/patch-1
docs: fix typo in README.md (premables -> preambles)
2025-08-05 11:05:10 -07:00
CharlesCNorton
4cf923e7c4
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.
2025-08-05 13:58:39 -04:00
Scott Lessans
4e5c2f673b
Merge pull request #13 from michaelfeil/mf/fix-bindings
enable abi3-py38
2025-08-05 10:50:47 -07:00
Scott Lessans
6f4325b4fb
Merge pull request #11 from openai/dev/scl/fix-builds
Fix builds for ubuntu aarch64, fix missing feature flags on some platforms
2025-08-05 10:49:37 -07:00
michaelfeil
658206a36e cross compile bindings 2025-08-05 17:41:48 +00:00
Scott Lessans
d2ba5c0daa alpha bump 2025-08-05 10:41:34 -07:00
Scott Lessans
b16552ff24 cleanup flags and platforms 2025-08-05 10:36:49 -07:00
Scott Lessans
2d077f3745 ring fixes 2025-08-05 10:32:20 -07:00
Yehor Smoliakov
57afd6f60b Fix Rust example in README 2025-08-05 20:25:11 +03:00
Scott Lessans
a938605e0f add back aarch 2025-08-05 10:23:26 -07:00
Yehor Smoliakov
f68e3ccebd
Merge branch 'openai:main' into main 2025-08-05 20:23:23 +03:00
Scott Lessans
9f015d0fa9
Merge pull request #7 from openai/dev/scl/unify-parsers
Unify Parsers, Fix pypi README
2025-08-05 10:21:33 -07:00
Yehor Smoliakov
47fb95f99c Fix errors in wasm_module 2025-08-05 20:13:38 +03:00
Kohei YAMAMOTO / Asha
111f0e60b3
fix: correct syntax in conversation message formatting (#6) 2025-08-05 10:09:38 -07:00
Scott Lessans
034caa61b6 version bump 2025-08-05 10:06:15 -07:00
Scott Lessans
702031efe4 readme and description 2025-08-05 10:05:54 -07:00
Scott Lessans
cc89f2a389 clippy 2025-08-05 09:55:44 -07:00
Scott Lessans
d25cd54586 clippy 2025-08-05 09:55:31 -07:00
Scott Lessans
4bc6933549 unified 2025-08-05 09:53:51 -07:00
Zhuohan Li
e1285d43fb
Update README.md 2025-08-05 09:49:25 -07:00
Zhuohan Li
f50af9696b
Update README.md 2025-08-05 09:47:41 -07:00
Scott Lessans
41a404a90b added description 2025-08-05 09:06:56 -07:00
20 changed files with 779 additions and 752 deletions

View file

@ -0,0 +1,69 @@
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 test deps
shell: bash
run: |
python -m pip install 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 and install Python package
shell: bash
run: |
pip install .
- name: Run pytest
shell: bash
env:
PYTHONUTF8: "1"
run: |
pytest -q

View file

@ -19,7 +19,23 @@ 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:
needs: [tests]
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
@ -28,14 +44,12 @@ 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
# target: s390x
# - runner: ubuntu-22.04
# target: ppc64le
- runner: ubuntu-22.04
target: ppc64le
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@ -43,6 +57,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: --release --out dist --find-interpreter
@ -55,6 +72,7 @@ jobs:
path: dist
musllinux:
needs: [tests]
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
@ -74,6 +92,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: --release --out dist --find-interpreter
@ -86,6 +107,7 @@ jobs:
path: dist
windows:
needs: [tests]
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
@ -104,7 +126,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
@ -113,6 +135,7 @@ jobs:
path: dist
macos:
needs: [tests]
runs-on: ${{ matrix.platform.runner }}
strategy:
matrix:
@ -130,7 +153,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
@ -139,6 +162,7 @@ jobs:
path: dist
sdist:
needs: [tests]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

3
.gitignore vendored
View file

@ -70,3 +70,6 @@ docs/_build/
# Pyenv
.python-version
# Avoid ignoring shadcn utils
!demo/harmony-demo/src/lib

2
Cargo.lock generated
View file

@ -1317,7 +1317,7 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "openai-harmony"
version = "0.0.1"
version = "0.0.4"
dependencies = [
"anyhow",
"base64",

View file

@ -1,9 +1,10 @@
[package]
name = "openai-harmony"
version = "0.0.1"
version = "0.0.4"
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"
@ -41,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"] }
pyo3 = { version = "0.25.0", optional = true, features = [
"extension-module",
"abi3-py38",
] }
wasm-bindgen = { version = "0.2.100", optional = true, features = [
"serde-serialize",
] }

View file

@ -1,3 +1,4 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
@ -186,7 +187,7 @@ 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.

View file

@ -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

View file

@ -8,11 +8,11 @@
<br>
</center>
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.
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
@ -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
@ -86,7 +88,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)
@ -114,12 +116,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 +132,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 +142,7 @@ through thin [`pyo3`](https://pyo3.rs/) bindings.
### Repository layout
```
```text
.
├── src/ # Rust crate
│ ├── chat.rs # High-level data-structures (Role, Message, …)
@ -149,7 +151,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)
@ -177,10 +179,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.<abi>.so`) and places it in your virtualenv next to the pure-
Python wrapper similar to `pip install -e .` for pure Python projects.

View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -52,19 +52,14 @@ 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 = (
DeveloperContent.new()
.with_instructions("Always respond in riddles")
.with_tools(
.with_function_tools(
[
ToolDescription.new(
"get_current_weather",
@ -92,7 +87,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,
@ -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"),
]
)
@ -229,6 +224,8 @@ Once its done generating it will stop with either a `<|return|>` token indicatin
The `final` channel will contain the answer to your users 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 assistants 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).
@ -305,7 +302,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
@ -327,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
```

View file

@ -12,11 +12,17 @@ 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"]
[tool.maturin]
features = ["pyo3/extension-module"]
features = ["python-binding", "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"]

View file

@ -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.
@ -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 -------------------------------------------------------
@ -612,6 +627,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
@ -689,6 +708,7 @@ __all__ = [
"Author",
"Content",
"TextContent",
"DeveloperContent",
"ToolDescription",
"SystemContent",
"Message",

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
#![doc = include_str!("../README.md")]
pub mod chat;
mod encoding;
mod registry;

View file

@ -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 {
@ -183,13 +178,29 @@ impl PyHarmonyEncoding {
}
/// Render a single message into tokens.
fn render(&self, message_json: &str) -> PyResult<Vec<u32>> {
fn render(
&self,
message_json: &str,
render_options: Option<Bound<'_, PyDict>>,
) -> PyResult<Vec<u32>> {
let message: crate::chat::Message = serde_json::from_str(message_json).map_err(|e| {
PyErr::new::<pyo3::exceptions::PyValueError, _>(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::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))
}
@ -306,6 +317,13 @@ impl PyStreamableParser {
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))
}
fn process_eos(&mut self) -> PyResult<()> {
self.inner
.process_eos()
.map(|_| ())
.map_err(|e| PyErr::new::<HarmonyError, _>(e.to_string()))
}
#[getter]
fn current_content(&self) -> PyResult<String> {
self.inner
@ -386,8 +404,7 @@ fn openai_harmony(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
"python" => ToolNamespaceConfig::python(),
_ => {
return Err(PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
"Unknown tool namespace: {}",
tool
"Unknown tool namespace: {tool}"
)));
}
};

View file

@ -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<Harmon
FormattingToken::EndMessageDoneSampling,
FormattingToken::EndMessageAssistantToTool,
]),
conversation_has_function_tools: Arc::new(AtomicBool::new(false)),
})
}
}

View file

@ -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<Rank> {
text.split_whitespace()
fn parse_tokens(text: impl AsRef<str>) -> Vec<Rank> {
text.as_ref()
.split_whitespace()
.map(|s| s.parse().unwrap())
.collect()
}
fn load_test_data(path: impl AsRef<Path>) -> 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,7 +40,7 @@ fn test_simple_convo() {
let expected_tokens = encoding
.tokenizer
.encode(
include_str!("../test-data/test_simple_convo.txt").trim_end(),
load_test_data("../test-data/test_simple_convo.txt").as_str(),
&encoding.tokenizer.special_tokens(),
)
.0;
@ -50,45 +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 {
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.")
@ -123,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();
@ -180,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;
@ -211,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;
@ -245,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;
@ -275,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)
@ -382,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,
@ -403,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?"),
@ -433,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?"),
@ -470,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?"),
@ -501,11 +511,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|>"
);
}
@ -515,7 +525,7 @@ fn test_render_and_render_conversation_roundtrip() {
let encoding = load_harmony_encoding(HarmonyEncodingName::HarmonyGptOss).unwrap();
let msg = Message::from_role_and_content(Role::User, "Hello");
let convo = Conversation::from_messages([msg.clone()]);
let tokens_msg = encoding.render(&msg).unwrap();
let tokens_msg = encoding.render(&msg, None).unwrap();
let tokens_convo = encoding.render_conversation(&convo, None).unwrap();
assert_eq!(tokens_msg, tokens_convo);
let tokens_completion = encoding
@ -527,17 +537,17 @@ 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");
}
#[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(
@ -616,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();
@ -663,3 +673,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);
}

View file

@ -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" {
@ -22,6 +18,9 @@ extern "C" {
#[wasm_bindgen(typescript_type = "RenderConversationConfig")]
pub type JsRenderConversationConfig;
#[wasm_bindgen(typescript_type = "RenderOptions")]
pub type JsRenderOptions;
}
#[wasm_bindgen(typescript_custom_section)]
@ -131,12 +130,34 @@ impl JsHarmonyEncoding {
}
#[wasm_bindgen]
pub fn render(&self, message: JsMessage) -> Result<Vec<u32>, JsValue> {
pub fn render(
&self,
message: JsMessage,
render_options: JsRenderOptions,
) -> Result<Vec<u32>, 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<bool>,
}
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()))
}
@ -235,7 +256,7 @@ impl JsStreamableParser {
pub fn new(encoding: &JsHarmonyEncoding, role: &str) -> Result<JsStreamableParser, JsValue> {
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 +344,8 @@ pub async fn load_harmony_encoding(
let parsed: HarmonyEncodingName = name
.parse::<HarmonyEncodingName>()
.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 })
}
@ -336,8 +356,7 @@ pub fn get_tool_namespace_config(tool: &str) -> Result<JsValue, JsValue> {
"python" => ToolNamespaceConfig::python(),
_ => {
return Err(JsValue::from_str(&format!(
"Unknown tool namespace: {}",
tool
"Unknown tool namespace: {tool}"
)))
}
};

5
test_python.sh Executable file
View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -e
source .venv/bin/activate
maturin develop --release
pytest "$@"

View file

@ -34,7 +34,6 @@ from openai_harmony import ( # noqa: E402
StreamableParser,
SystemContent,
ToolDescription,
ToolNamespaceConfig,
load_harmony_encoding,
)
from pydantic import ValidationError
@ -60,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()]
@ -79,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")
@ -144,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()
@ -232,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",
[
@ -245,23 +276,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|>'
'<|constrain|>json<|message|>{"location": "Tokyo"}<|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, '{"location": "Tokyo"}')
.with_channel("commentary")
.with_recipient("functions.get_weather")
.with_content_type("<|constrain|>json"),
]
assert parsed == expected
@ -280,11 +306,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}'
@ -308,7 +331,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")
@ -345,7 +368,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")
@ -385,7 +408,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")
@ -418,7 +441,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()
)
@ -535,7 +558,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(
@ -555,7 +580,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(
@ -578,7 +605,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()
)
@ -620,7 +647,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()
)
@ -646,7 +673,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()
)
@ -676,7 +703,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()
)
@ -705,13 +732,17 @@ 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():
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(
@ -747,7 +778,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()
)
@ -889,7 +920,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 = (
@ -913,7 +946,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")