diff --git a/renderers/base.py b/renderers/base.py index 3c2eb6af..f9e0c927 100644 --- a/renderers/base.py +++ b/renderers/base.py @@ -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) diff --git a/renderers/gpt_oss.py b/renderers/gpt_oss.py index 00f41f21..05d8427f 100644 --- a/renderers/gpt_oss.py +++ b/renderers/gpt_oss.py @@ -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 @@ -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. diff --git a/renderers/parsing.py b/renderers/parsing.py index 9f7d5db9..6439ee6c 100644 --- a/renderers/parsing.py +++ b/renderers/parsing.py @@ -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: @@ -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, ) @@ -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, ) diff --git a/tests/test_gpt_oss.py b/tests/test_gpt_oss.py index 0f05eb9f..90b7c56e 100644 --- a/tests/test_gpt_oss.py +++ b/tests/test_gpt_oss.py @@ -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