Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ CUDA_VISIBLE_DEVICES=1 uv run --script examples/sglang/multiturn_generate_sglang
```

The SGLang script uses `input_ids`, so SGLang does not apply a chat template.
It leaves `openai-harmony` at SGLang's pinned version for dependency resolution.
SGLang pins `openai-harmony`, which installs the same `openai_harmony` module
as the `oss-harmony` package renderers depends on, so whichever installs last
wins. The gpt-oss renderer produces the same tokens on either build.

### SGLang HTTP Recipe (online)

Expand Down
1 change: 0 additions & 1 deletion examples/sglang/multiturn_generate_sglang.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
# "sglang==0.5.10.post1",
# "flash-attn-4>=4.0.0b4",
# "transformers>=5.3.0",
# "openai-harmony==0.0.4",
# "openai>=1.108.1",
# "tiktoken",
# "jinja2",
Expand Down
1 change: 0 additions & 1 deletion examples/sglang/online_multiturn_sglang.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
# "renderers>=0.1.6",
# "transformers>=5.3.0",
# "httpx>=0.27",
# "openai-harmony==0.0.4",
# "tiktoken",
# "jinja2",
# "numpy",
Expand Down
1 change: 0 additions & 1 deletion examples/tinker/multiturn_generate_tinker.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
# "renderers>=0.1.6",
# "tinker>=0.9.0",
# "transformers>=4.57.6,<=5.5.3",
# "openai-harmony>=0.0.8",
# "openai>=1.108.1",
# "tiktoken",
# "jinja2",
Expand Down
1 change: 0 additions & 1 deletion examples/transformers/multiturn_generate_transformers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
# "accelerate",
# "torch",
# "kernels>=0.12.0",
# "openai-harmony>=0.0.8",
# "openai>=1.108.1",
# "tiktoken",
# "jinja2",
Expand Down
1 change: 0 additions & 1 deletion examples/vllm/multiturn_generate_vllm.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
# "renderers>=0.1.6",
# "vllm>=0.20",
# "transformers>=4.50.0",
# "openai-harmony>=0.0.8",
# "openai>=1.108.1",
# "tiktoken",
# "jinja2",
Expand Down
17 changes: 6 additions & 11 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,12 @@ dependencies = [
"openai>=1.108.1",
"tiktoken",
"jinja2",
# Used by GptOssRenderer to render and parse harmony tokens. Vendoring
# OpenAI's reference implementation keeps us byte-identical with vLLM
# (which also uses it) and saves us mirroring a 330-line Jinja template.
#
# Floor is ``>=0.0.4`` (not the latest 0.0.8) on purpose: every SGLang
# release through 0.5.12.post1 hard-pins ``openai-harmony==0.0.4``, so a
# higher floor makes ``renderers`` uninstallable alongside SGLang. The
# GptOssRenderer renders byte-identically on 0.0.4 (verified token-for-token
# against 0.0.8) and the Harmony cells in ``tests/test_parity.py`` pass,
# so the older harmony is safe.
"openai-harmony>=0.0.4",
# Used by GptOssRenderer to render harmony tokens. ``oss-harmony`` is the
# maintained fork of OpenAI's ``openai-harmony`` that vLLM depends on.
# Both packages install the ``openai_harmony`` module and overwrite each
# other's files, so ``renderers`` can't share an environment with anything
# that requires ``openai-harmony`` (SGLang pins ``openai-harmony==0.0.4``).
"oss-harmony>=0.0.11",
# ``BaseRendererConfig`` inherits from ``pydantic_config.BaseConfig`` so
# the typed-config surface stays uniform with prime-rl / verifiers config
# bases. Transitively brings pydantic, which ``renderers.configs`` also
Expand Down
43 changes: 34 additions & 9 deletions renderers/gpt_oss.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
"""GptOssRenderer — adapter over OpenAI's harmony reference encoder.
"""GptOssRenderer — adapter over the harmony encoder.

Wire format: harmony (channel-based, no BOS). The official renderer ships
in the ``openai-harmony`` package — vLLM uses it directly, so matching
its output guarantees byte-identical tokens with vLLM's serving path
(and with what the model was trained on).
Wire format: harmony (channel-based, no BOS). The encoder ships in the
``oss-harmony`` package, the fork of OpenAI's ``openai-harmony`` that vLLM
depends on.

This renderer is a thin adapter that:

Expand All @@ -18,7 +17,8 @@
- renders one harmony message at a time via ``enc.render(m)`` so each
token can be attributed to its source caller index. ``enc.render``
is byte-identical to ``enc.render_conversation`` when concatenated;
verified empirically.
verified empirically. Tool calls and tool results keep the
recipient-first header (``to=functions.x<|channel|>commentary``).

Special tokens
--------------
Expand Down Expand Up @@ -180,6 +180,29 @@ def _encode(self, text: str) -> list[int]:
return []
return self._tokenizer.encode(text, add_special_tokens=False)

def _render_message(self, hm: HarmonyMessage) -> list[int]:
"""Render one harmony message with its recipient ahead of its channel.

oss-harmony renders commentary headers channel-first
(``<|channel|>commentary to=functions.x``). openai-harmony and the
gpt-oss chat template put the recipient first, and which order gpt-oss
handles best in conversation history is unsettled, so tool calls and
tool results keep the recipient-first header.
"""
ids = self._enc.render(hm)
if hm.channel != "commentary" or hm.recipient is None:
return ids
author = hm.author.name if hm.author.role == Role.TOOL else hm.author.role.value
header = [
self._start,
*self._enc.encode(author, disallowed_special=()),
*self._enc.encode(f" to={hm.recipient}", disallowed_special=()),
self._channel,
*self._enc.encode(hm.channel, disallowed_special=()),
self._message,
]
return header + ids[ids.index(self._message) + 1 :]

def _prefix_content_mask(
self,
prefix_tokens: list[int],
Expand Down Expand Up @@ -436,7 +459,9 @@ def emit_harmony_message(
for hm in self._to_harmony_messages(
msg, preserve_thinking=preserve_thinking
):
emit_harmony_message(self._enc.render(hm), i, is_assistant=is_assistant)
emit_harmony_message(
self._render_message(hm), i, is_assistant=is_assistant
)

# When the conversation ends on an assistant final-channel turn,
# ``apply_chat_template`` (and ``render_conversation_for_training``)
Expand Down Expand Up @@ -547,7 +572,7 @@ def bridge_to_next_turn(
) -> RenderedTokens | None:
"""Per-message harmony bridge.

Each new message is rendered in isolation via ``enc.render(m)`` —
Each new message is rendered in isolation via ``_render_message`` —
same primitive ``render()`` uses — so the bridge can never drift
from the full re-render.
"""
Expand Down Expand Up @@ -619,7 +644,7 @@ def bridge_to_next_turn(
if role not in ("tool", "user", "system", "developer"):
return None
for hm in self._to_harmony_messages(msg):
ids = self._enc.render(hm)
ids = self._render_message(hm)
try:
msg_marker = ids.index(self._message)
except ValueError:
Expand Down
15 changes: 11 additions & 4 deletions tests/reference_continuations.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
import json
import re

from reference_rendering import _harmony_messages, render_reference
from reference_rendering import (
_harmony_messages,
harmony_recipient_first,
render_reference,
)

REASONING = "Check the weather carefully."
ANSWER = "It is sunny in Tokyo."
Expand Down Expand Up @@ -96,9 +100,12 @@ def render(conversation, generation=False, **overrides):
),
Message.from_role_and_content(Role.ASSISTANT, ANSWER).with_channel("final"),
]
full = encoder.render_conversation_for_training(
Conversation.from_messages(history),
RenderConversationConfig(auto_drop_analysis=False),
full = harmony_recipient_first(
encoder,
encoder.render_conversation_for_training(
Conversation.from_messages(history),
RenderConversationConfig(auto_drop_analysis=False),
),
)
elif case.resolved_renderer == "deepseek-r1" and prompt_text.endswith("<think>\n"):
# R1's generation prompt opens thinking, but its history branch removes
Expand Down
42 changes: 40 additions & 2 deletions tests/reference_rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,42 @@ def _harmony_messages(
return out


def harmony_recipient_first(encoder: Any, ids: list[int]) -> list[int]:
"""Move each header's `` to=recipient`` ahead of its ``<|channel|>``.

The gpt-oss renderer keeps openai-harmony's recipient-first header for tool
calls and tool results; oss-harmony renders commentary headers channel-first.
"""
start, channel, message = (
encoder.encode(token, allowed_special="all")[0]
for token in ("<|start|>", "<|channel|>", "<|message|>")
)
out: list[int] = []
i = 0
while i < len(ids):
if ids[i] != start or message not in ids[i:]:
out.append(ids[i])
i += 1
continue
end = ids.index(message, i)
header = ids[i + 1 : end]
if channel in header:
pos = header.index(channel)
channel_name, to, recipient = encoder.decode(header[pos + 1 :]).partition(
" to="
)
if to:
header = [
*header[:pos],
*encoder.encode(f" to={recipient}"),
channel,
*encoder.encode(channel_name),
]
out += [start, *header, message]
i = end + 1
return out


def _render_harmony(
tokenizer: Any,
messages: list[dict[str, Any]],
Expand Down Expand Up @@ -596,10 +632,12 @@ def _render_harmony(
prompt = encoder.render_conversation_for_completion(
conversation, next_turn_role=Role.ASSISTANT
)
return prompt + encoder.encode(
return harmony_recipient_first(encoder, prompt) + encoder.encode(
"<|channel|>analysis<|message|>", allowed_special="all"
)
return encoder.render_conversation_for_training(conversation)
return harmony_recipient_first(
encoder, encoder.render_conversation_for_training(conversation)
)


REFERENCE_ORACLES: Mapping[str, ReferenceOracle] = MappingProxyType(
Expand Down
61 changes: 61 additions & 0 deletions tests/test_gpt_oss.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""GPT-OSS header coverage that the Harmony parity oracle can't provide.

The parity oracle is harmony itself, so it moves with whichever harmony build
is installed. openai-harmony renders tool-call and tool-result headers
recipient-first; oss-harmony renders them channel-first. The renderer keeps
the recipient-first header on either build, and this test pins it.
"""

from __future__ import annotations

from renderers import create_renderer
from renderers.base import load_tokenizer

_TOOLS = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
"required": ["location"],
},
},
}
]


def test_tool_call_and_result_headers_put_recipient_first():
tok = load_tokenizer("openai/gpt-oss-20b")
renderer = create_renderer(tok)
messages = [
{"role": "user", "content": "Weather in SF?"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {
"name": "get_weather",
"arguments": '{"location": "SF"}',
},
}
],
},
{"role": "tool", "tool_call_id": "c1", "name": "get_weather", "content": "20C"},
]

ids = renderer.render_ids(messages, tools=_TOOLS)
expected = tok.encode(
"<|start|>assistant to=functions.get_weather<|channel|>commentary"
'<|message|>{"location": "SF"}<|call|>'
"<|start|>functions.get_weather to=assistant<|channel|>commentary"
"<|message|>20C<|end|>",
add_special_tokens=False,
)

assert ids[-len(expected) :] == expected
Loading
Loading