Skip to content
Draft
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
10 changes: 10 additions & 0 deletions renderers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,12 +619,22 @@ class ParsedToolCall:
it back as the call's ``arguments`` re-renders the call with the
model's own spacing, which ``json.dumps(arguments)`` generally
doesn't. It's ``None`` for other formats.

``content_type`` is the call's content type as the model sampled it in the
call's header, for formats whose header carries one (gpt-oss: the word after
``<|constrain|>``, else the header's bare content type such as ``code``;
``None`` when the header has none). The model doesn't always pick the type
its arguments would suggest (gpt-oss samples `` code`` for JSON objects too),
so passing it back as the tool call's ``content_type`` (on the tool-call dict
or its ``function`` dict) re-renders the header as sampled; the gpt-oss
renderer honours ``json`` and ``code``. It's ``None`` for other formats.
"""

raw: str
name: str | None = None
arguments: dict[str, Any] | str | None = None
arguments_text: str | None = None
content_type: str | None = None
token_span: tuple[int, int] | None = None
status: ToolCallParseStatus = ToolCallParseStatus.OK
id: str | None = None # native tool-call id when the format carries one (Kimi K2)
Expand Down
12 changes: 10 additions & 2 deletions renderers/gpt_oss.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
verified empirically. Tool calls render as gpt-oss samples them,
channel-first with a content type
(``<|channel|>commentary to=functions.x <|constrain|>json``, or ``code``
for arguments that aren't a JSON object); tool results keep the
for arguments that aren't a JSON object, unless the call carries the
``content_type`` it was sampled with); tool results keep the
recipient-first header (``to=assistant<|channel|>commentary``).

Special tokens
Expand Down Expand Up @@ -850,7 +851,14 @@ def _assistant_to_harmony(
recipient = name if name.startswith("functions.") else f"functions.{name}"
m = HarmonyMessage.from_role_and_content(Role.ASSISTANT, args)
m = m.with_channel("commentary").with_recipient(recipient)
out.append(m.with_content_type("json" if _is_json_object(args) else "code"))
# The header as sampled when the call carries it; else the type its arguments suggest.
sampled = tc.get("content_type") or fn.get("content_type")
content_type = (
sampled
if sampled in ("json", "code")
else ("json" if _is_json_object(args) else "code")
)
out.append(m.with_content_type(content_type))

# Empty assistant (no text and no tool_calls) — emit an empty
# final-channel message so message_indices stays non-empty.
Expand Down
15 changes: 14 additions & 1 deletion renderers/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -1745,8 +1745,19 @@ def parse_gpt_oss(
recipient_match = re.search(r"to=([^\s<]+)", header_text)
recipient = recipient_match.group(1) if recipient_match else None

if recipient and recipient.startswith("functions."):
if recipient_match and recipient and recipient.startswith("functions."):
tool_name = recipient[len("functions.") :]
if constrain_id in header_ids:
after = _decode(
tokenizer, header_ids[header_ids.index(constrain_id) + 1 :]
).split()
else:
# The header's words after the recipient, without its channel (recipient-first headers
# put ``<|channel|>commentary`` there).
after = re.sub(
r"<\|channel\|>\s*\S+", " ", header_text[recipient_match.end() :]
).split()
content_type = after[0] if after else None
block_end = body_end + 1 if body_closed else body_end
span = (block_start, block_end)
try:
Expand All @@ -1758,6 +1769,7 @@ def parse_gpt_oss(
name=tool_name or None,
arguments=body_text,
arguments_text=body_text,
content_type=content_type,
token_span=span,
status=ToolCallParseStatus.INVALID_JSON,
)
Expand All @@ -1775,6 +1787,7 @@ def parse_gpt_oss(
name=tool_name or None,
arguments=arguments,
arguments_text=body_text,
content_type=content_type,
token_span=span,
status=status,
)
Expand Down
77 changes: 77 additions & 0 deletions tests/test_gpt_oss.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,80 @@ def test_rerendered_tool_calls_equal_sampled_tokens():
if header in text
}
assert content_types == {"json", "code"}


def test_a_code_header_on_json_arguments_round_trips():
"""gpt-oss sometimes samples `` code`` for a call whose arguments are a JSON
object. The parsed call carries the sampled ``content_type``, and a re-render
given it reproduces the sampled tokens; without it the header follows the
arguments."""
tok = load_tokenizer("openai/gpt-oss-20b")
renderer = create_renderer(tok, GptOssRendererConfig(reasoning_effort="low"))
args = '{"command":"ls -R"}'
user = {"role": "user", "content": "List the files."}
prompt = renderer.render_ids([user], add_generation_prompt=True)
sampled = tok.encode(
"List files.<|end|>"
"<|start|>assistant<|channel|>commentary to=functions.bash code"
f"<|message|>{args}<|call|>",
add_special_tokens=False,
)
(call,) = renderer.parse_response(sampled, prompt_ids=prompt).tool_calls
assert (call.arguments_text, call.content_type) == (args, "code")

def assistant_tokens(**extra):
message = {
"role": "assistant",
"content": None,
"reasoning_content": "List files.",
"tool_calls": [
{
"type": "function",
"id": "call_0",
"function": {"name": "bash", "arguments": args},
**extra,
}
],
}
rendered = renderer.render([user, message])
return [
t for t, i in zip(rendered.token_ids, rendered.message_indices) if i == 1
]

# The prompt's generation header plus the sample are the assistant turn's tokens.
turn = prompt[len(renderer.render_ids([user])) :] + sampled
assert assistant_tokens(content_type=call.content_type) == turn
assert assistant_tokens() != turn
assert "<|constrain|>json" in tok.decode(assistant_tokens())


def test_a_tool_call_header_content_type_is_parsed_as_sampled():
tok = load_tokenizer("openai/gpt-oss-20b")
renderer = create_renderer(tok, GptOssRendererConfig(reasoning_effort="low"))
prompt = renderer.render_ids(
[{"role": "user", "content": "q"}], add_generation_prompt=True
)

def content_type(header):
sampled = tok.encode(
f"Go.<|end|><|start|>assistant{header}<|message|>{{}}<|call|>",
add_special_tokens=False,
)
return (
renderer.parse_response(sampled, prompt_ids=prompt)
.tool_calls[0]
.content_type
)

assert (
content_type("<|channel|>commentary to=functions.bash <|constrain|>json")
== "json"
)
assert content_type("<|channel|>commentary to=functions.bash code") == "code"
assert (
content_type(" to=functions.bash<|channel|>commentary <|constrain|>json")
== "json"
)
# A header without a content type has none.
assert content_type("<|channel|>commentary to=functions.bash") is None
assert content_type(" to=functions.bash<|channel|>commentary") is None
Loading