diff --git a/tests/entrypoints/openai/parser/test_harmony_utils.py b/tests/entrypoints/openai/parser/test_harmony_utils.py index 21b53dff1507..47c53032c8fa 100644 --- a/tests/entrypoints/openai/parser/test_harmony_utils.py +++ b/tests/entrypoints/openai/parser/test_harmony_utils.py @@ -8,10 +8,13 @@ from vllm.entrypoints.openai.parser.harmony_utils import ( auto_drop_analysis_messages, get_encoding, + get_streamable_parser_for_assistant, get_system_message, has_custom_tools, parse_chat_input_to_harmony_message, parse_chat_output, + sanitize_harmony_name, + sanitize_harmony_recipient, ) from vllm.entrypoints.openai.responses.harmony import ( response_input_to_harmony, @@ -928,3 +931,282 @@ def test_reasoning_with_empty_content_returns_none(self): msg = response_input_to_harmony(item, prev_responses=[]) assert msg is None + + +class TestSanitizeHarmonyName: + """Tests for sanitize_harmony_name().""" + + def test_clean_name_unchanged(self) -> None: + assert sanitize_harmony_name("get_weather") == "get_weather" + + def test_strip_channel_token(self) -> None: + assert ( + sanitize_harmony_name("manage_cart<|channel|>commentary") == "manage_cart" + ) + + def test_strip_constrain_token(self) -> None: + assert sanitize_harmony_name("<|constrain|>json") == "" + + def test_pure_control_token_returns_empty(self) -> None: + assert sanitize_harmony_name("<|start|>") == "" + + def test_multiple_tokens_earliest_wins(self) -> None: + assert sanitize_harmony_name("foo<|channel|>bar<|constrain|>baz") == "foo" + + def test_empty_string(self) -> None: + assert sanitize_harmony_name("") == "" + + def test_trailing_whitespace_stripped(self) -> None: + assert sanitize_harmony_name("tool_name <|end|>") == "tool_name" + + +class TestSanitizeHarmonyRecipient: + """Tests for sanitize_harmony_recipient().""" + + def test_clean_dotted_name_unchanged(self) -> None: + assert sanitize_harmony_recipient("browser.search") == "browser.search" + + def test_clean_simple_name_unchanged(self) -> None: + assert sanitize_harmony_recipient("python") == "python" + + def test_contaminated_first_part_preserved_structure(self) -> None: + """browser<|channel|>.search → browser.search""" + assert ( + sanitize_harmony_recipient("browser<|channel|>.search") == "browser.search" + ) + + def test_contaminated_second_part(self) -> None: + """browser.search<|end|>garbage → browser.search""" + assert ( + sanitize_harmony_recipient("browser.search<|end|>garbage") + == "browser.search" + ) + + def test_pure_control_token_returns_empty(self) -> None: + assert sanitize_harmony_recipient("<|constrain|>json") == "" + + def test_functions_dotted_contaminated(self) -> None: + """functions.get_weather<|channel|>commentary → functions.get_weather""" + assert ( + sanitize_harmony_recipient("functions.get_weather<|channel|>commentary") + == "functions.get_weather" + ) + + def test_empty_string(self) -> None: + assert sanitize_harmony_recipient("") == "" + + def test_container_dotted_contaminated(self) -> None: + """container<|channel|>.exec → container.exec""" + assert ( + sanitize_harmony_recipient("container<|channel|>.exec") == "container.exec" + ) + + def test_full_component_contamination_returns_empty(self) -> None: + """functions.<|constrain|>json → "" (not "functions")""" + assert sanitize_harmony_recipient("functions.<|constrain|>json") == "" + + def test_container_full_component_contamination_returns_empty(self) -> None: + """container.<|channel|>commentary → "" (not "container")""" + assert sanitize_harmony_recipient("container.<|channel|>commentary") == "" + + +class TestResilientStreamableParser: + """Tests for ResilientStreamableParser error recovery.""" + + def test_normal_sequence_unchanged(self) -> None: + """Normal token sequence should produce same results as raw parser.""" + encoding = get_encoding() + harmony_str = "<|channel|>final<|message|>Hello world<|end|>" + token_ids = encoding.encode(harmony_str, allowed_special="all") + + parser = get_streamable_parser_for_assistant() + for tok in token_ids: + parser.process(tok) + + assert len(parser.messages) == 1 + assert parser.messages[0].content[0].text == "Hello world" + assert parser.messages[0].channel == "final" + + def test_missing_start_recovery(self) -> None: + """Parser should recover when <|start|> is missing between messages.""" + encoding = get_encoding() + # First message completes normally, second is missing <|start|> + first_msg = "<|channel|>final<|message|>First.<|end|>" + second_msg = "<|channel|>final<|message|>Second.<|end|>" + first_tokens = encoding.encode(first_msg, allowed_special="all") + second_tokens = encoding.encode(second_msg, allowed_special="all") + + parser = get_streamable_parser_for_assistant() + for tok in first_tokens: + parser.process(tok) + # Feed second message tokens directly (missing <|start|>assistant) + for tok in second_tokens: + parser.process(tok) + + assert len(parser.messages) == 2 + assert parser.messages[0].content[0].text == "First." + assert parser.messages[1].content[0].text == "Second." + + def test_constrain_in_header_skipped(self) -> None: + """<|constrain|> in HEADER state should be skipped gracefully.""" + encoding = get_encoding() + # First, complete a normal message so parser goes to EXPECT_START + first_msg = "<|channel|>final<|message|>First.<|end|>" + first_tokens = encoding.encode(first_msg, allowed_special="all") + + # Build a second message where <|constrain|> appears in the header + # after <|start|>assistant, before <|channel|> + start_tok = encoding.encode("<|start|>", allowed_special="all") + role_toks = encoding.encode("assistant", allowed_special="all") + constrain_tok = encoding.encode("<|constrain|>", allowed_special="all") + # Garbage tokens that should be skipped + json_toks = encoding.encode("json", allowed_special="all") + message_tok = encoding.encode("<|message|>", allowed_special="all") + text_toks = encoding.encode("Second.", allowed_special="all") + end_tok = encoding.encode("<|end|>", allowed_special="all") + + parser = get_streamable_parser_for_assistant() + # Complete first message + for tok in first_tokens: + parser.process(tok) + assert len(parser.messages) == 1 + + # Feed: <|start|>assistant → puts parser in HEADER state + for tok in start_tok: + parser.process(tok) + for tok in role_toks: + parser.process(tok) + # Feed: <|constrain|> → should enter skip mode + for tok in constrain_tok: + parser.process(tok) + # Feed: json tokens → should be discarded in skip mode + for tok in json_toks: + parser.process(tok) + # Feed: <|message|> → should exit skip mode and resume + for tok in message_tok: + parser.process(tok) + # Feed: text + <|end|> + for tok in text_toks: + parser.process(tok) + for tok in end_tok: + parser.process(tok) + + # Should have produced two messages despite the malformed sequence + assert len(parser.messages) == 2 + assert parser.messages[0].content[0].text == "First." + assert parser.messages[1].content[0].text == "Second." + + def test_messages_recipients_sanitized(self) -> None: + """Messages returned by .messages should have sanitized recipients, + preventing contaminated history in multi-turn interactions.""" + encoding = get_encoding() + # Build a tool call message with a contaminated recipient + harmony_str = ( + "<|channel|>commentary" + "<|message|>Let me search.<|end|>" + "<|start|>assistant to=functions.get_weather<|channel|>commentary" + '<|constrain|>json<|message|>{"loc": "SF"}<|end|>' + ) + token_ids = encoding.encode(harmony_str, allowed_special="all") + + parser = get_streamable_parser_for_assistant() + for tok in token_ids: + parser.process(tok) + + msgs = parser.messages + # All recipients should be clean (no control tokens) + for msg in msgs: + if msg.recipient is not None: + for tok_str in ( + "<|channel|>", + "<|constrain|>", + "<|start|>", + "<|end|>", + "<|message|>", + ): + assert tok_str not in msg.recipient, ( + f"Leaked control token {tok_str!r} " + f"in message recipient: {msg.recipient!r}" + ) + + def test_last_consumed_token_tracks_normal_processing(self) -> None: + """Normal tokens forwarded to inner parser update last_consumed_token.""" + encoding = get_encoding() + harmony_str = "<|channel|>final<|message|>Hello world<|end|>" + token_ids = encoding.encode(harmony_str, allowed_special="all") + + parser = get_streamable_parser_for_assistant() + assert parser.last_consumed_token is None + + for tok in token_ids: + parser.process(tok) + + # After processing, last_consumed_token should be the last token + assert parser.last_consumed_token == token_ids[-1] + + def test_pattern3_discarded_tokens_not_in_last_consumed(self) -> None: + """Free-text tokens in EXPECT_START don't update last_consumed_token.""" + encoding = get_encoding() + # Complete a message to reach EXPECT_START state + first_msg = "<|channel|>final<|message|>First.<|end|>" + first_tokens = encoding.encode(first_msg, allowed_special="all") + + parser = get_streamable_parser_for_assistant() + for tok in first_tokens: + parser.process(tok) + + last_consumed_after_first = parser.last_consumed_token + assert last_consumed_after_first is not None + + # Now feed free-text tokens (not <|start|>) — these should be discarded + garbage_tokens = encoding.encode("some free text", allowed_special="all") + for tok in garbage_tokens: + parser.process(tok) + + # last_consumed_token should NOT have changed + assert parser.last_consumed_token == last_consumed_after_first + + def test_pattern2_skip_mode_discarded_tokens_not_in_last_consumed(self) -> None: + """Tokens skipped during Pattern 2 don't update last_consumed_token.""" + encoding = get_encoding() + # Complete a first message + first_msg = "<|channel|>final<|message|>First.<|end|>" + first_tokens = encoding.encode(first_msg, allowed_special="all") + + # Build second message with <|constrain|> in header + start_tok = encoding.encode("<|start|>", allowed_special="all") + role_toks = encoding.encode("assistant", allowed_special="all") + constrain_tok = encoding.encode("<|constrain|>", allowed_special="all") + json_toks = encoding.encode("json", allowed_special="all") + message_tok = encoding.encode("<|message|>", allowed_special="all") + + parser = get_streamable_parser_for_assistant() + for tok in first_tokens: + parser.process(tok) + + last_consumed_after_first = parser.last_consumed_token + + # Feed <|start|>assistant to enter HEADER state + for tok in start_tok: + parser.process(tok) + for tok in role_toks: + parser.process(tok) + + last_consumed_after_header = parser.last_consumed_token + + # Feed <|constrain|> to enter skip mode + for tok in constrain_tok: + parser.process(tok) + + # last_consumed should not change (constrain triggers skip, not forwarded) + assert parser.last_consumed_token == last_consumed_after_header + + # Feed garbage tokens in skip mode — should not update + for tok in json_toks: + parser.process(tok) + assert parser.last_consumed_token == last_consumed_after_header + + # Feed <|message|> to exit skip mode — this IS forwarded + for tok in message_tok: + parser.process(tok) + assert parser.last_consumed_token != last_consumed_after_first diff --git a/tests/entrypoints/openai/responses/test_harmony_utils.py b/tests/entrypoints/openai/responses/test_harmony_utils.py index e51538298ff9..91100fcee76a 100644 --- a/tests/entrypoints/openai/responses/test_harmony_utils.py +++ b/tests/entrypoints/openai/responses/test_harmony_utils.py @@ -461,3 +461,34 @@ def test_parser_state_to_response_output_analysis_channel() -> None: assert len(builtin_items) == 1 assert not isinstance(builtin_items[0], McpCall) assert builtin_items[0].type == "reasoning" + + +class TestHarmonyOutputSanitization: + """Tests that leaked Harmony control tokens are sanitized in output.""" + + def test_constrain_recipient_treated_as_no_recipient(self): + """<|constrain|>json as recipient should be sanitized to empty, + falling through to _parse_message_no_recipient (produces message).""" + message = Message.from_role_and_content(Role.ASSISTANT, "Some output text") + message = message.with_channel("commentary") + message = message.with_recipient("<|constrain|>json") + + output_items = harmony_to_response_output(message) + + # Should produce a message (preamble), not an MCP call + assert len(output_items) == 1 + assert isinstance(output_items[0], ResponseOutputMessage) + assert output_items[0].type == "message" + + def test_contaminated_tool_name_cleaned_in_function_call(self): + """Function name with leaked <|channel|> should be sanitized.""" + message = Message.from_role_and_content(Role.ASSISTANT, '{"location": "SF"}') + message = message.with_channel("commentary") + message = message.with_recipient("functions.get_weather<|channel|>commentary") + + output_items = harmony_to_response_output(message) + + assert len(output_items) == 1 + assert isinstance(output_items[0], ResponseFunctionToolCall) + assert output_items[0].name == "get_weather" + assert "<|" not in output_items[0].name diff --git a/vllm/entrypoints/openai/chat_completion/stream_harmony.py b/vllm/entrypoints/openai/chat_completion/stream_harmony.py index 87f2f9b92275..a44b83baa902 100644 --- a/vllm/entrypoints/openai/chat_completion/stream_harmony.py +++ b/vllm/entrypoints/openai/chat_completion/stream_harmony.py @@ -17,6 +17,7 @@ DeltaMessage, DeltaToolCall, ) +from vllm.entrypoints.openai.parser.harmony_utils import sanitize_harmony_name class TokenState(NamedTuple): @@ -109,7 +110,9 @@ def extract_harmony_streaming_delta( opened_new_call = False if prev_recipient != group.recipient: # New tool call - emit the opening message - tool_name = group.recipient.split("functions.", 1)[1] + tool_name = sanitize_harmony_name( + group.recipient.split("functions.", 1)[1] + ) tool_messages.append( DeltaToolCall( id=make_tool_call_id(), diff --git a/vllm/entrypoints/openai/parser/harmony_utils.py b/vllm/entrypoints/openai/parser/harmony_utils.py index 9b4264456c51..17de269d0bfc 100644 --- a/vllm/entrypoints/openai/parser/harmony_utils.py +++ b/vllm/entrypoints/openai/parser/harmony_utils.py @@ -15,6 +15,7 @@ ReasoningEffort, Role, StreamableParser, + StreamState, SystemContent, TextContent, ToolDescription, @@ -27,6 +28,176 @@ logger = init_logger(__name__) +# Harmony special token strings that may leak into tool names or recipients +# during generation by GPT-OSS models. +_HARMONY_SPECIAL_TOKEN_STRS = ( + "<|channel|>", + "<|constrain|>", + "<|start|>", + "<|end|>", + "<|message|>", + "<|call|>", +) + +# Harmony special token IDs (GPT-OSS encoding) +_TOK_CONSTRAIN = 200003 +_TOK_CHANNEL = 200005 +_TOK_START = 200006 +_TOK_END = 200007 +_TOK_MESSAGE = 200008 + + +def sanitize_harmony_name(name: str) -> str: + """Strip leaked Harmony control tokens from a tool/recipient name. + + Finds the earliest Harmony control token string in *name* and returns + only the text before it. Returns the original string unchanged when + no control tokens are present. + """ + earliest = len(name) + for tok in _HARMONY_SPECIAL_TOKEN_STRS: + idx = name.find(tok) + if idx != -1 and idx < earliest: + earliest = idx + return name[:earliest].rstrip() + + +def sanitize_harmony_recipient(recipient: str) -> str: + """Sanitize a structured recipient name (e.g. ``browser.search``). + + Splits on ``'.'``, sanitizes each part individually with + :func:`sanitize_harmony_name`, and rejoins. If any component is + entirely consumed by control tokens (sanitizes to empty), the whole + recipient is considered corrupt and an empty string is returned so + that callers fall back to the safe no-recipient path. + + Example: ``browser<|channel|>.search`` → ``browser.search`` + Example: ``functions.<|constrain|>json`` → ``""`` + """ + parts = recipient.split(".") + sanitized_parts = [sanitize_harmony_name(part) for part in parts] + if any(not p for p in sanitized_parts): + return "" + return ".".join(sanitized_parts) + + +class ResilientStreamableParser: + """Wrapper around ``StreamableParser`` that recovers from two common + malformed-output patterns produced by GPT-OSS models: + + 1. **Missing ``<|start|>`` recovery** – When the parser expects a + ``<|start|>`` token but receives ``<|channel|>`` instead, inject the + missing ``<|start|>`` + assistant role token before forwarding. + + 2. **Malformed ``<|constrain|>`` in headers** – When the parser is in + ``HEADER`` state and receives ``<|constrain|>``, enter skip mode and + discard tokens until ``<|message|>`` or ``<|end|>`` is seen. + + All public properties are delegated to the underlying parser. The + ``current_recipient`` getter additionally sanitizes leaked tokens. + """ + + def __init__(self, inner: StreamableParser, encoding): + self._inner = inner + self._encoding = encoding + self._skip_until_message_or_end = False + self._last_known_role: str | None = None + self._last_consumed_token: int | None = None + + # --- error-recovering process() ----------------------------------- + + def process(self, token_id: int) -> None: + # Track role from inner parser while available + if self._inner.current_role is not None: + self._last_known_role = self._inner.current_role.value + + # Pattern 2: skip mode – discard until <|message|> or <|end|> + if self._skip_until_message_or_end: + if token_id in (_TOK_MESSAGE, _TOK_END): + self._skip_until_message_or_end = False + self._inner.process(token_id) + self._last_consumed_token = token_id + # else: silently discard the token + return + + state = self._inner.state + + # Pattern 1: missing <|start|> before <|channel|> + if state == StreamState.EXPECT_START and token_id == _TOK_CHANNEL: + # Inject <|start|> + assistant role token + self._inner.process(_TOK_START) + assert self._last_known_role is not None, ( + "Pattern 1 recovery requires a prior message to establish role" + ) + role_tokens = self._encoding.encode( + self._last_known_role, allowed_special="all" + ) + for rt in role_tokens: + self._inner.process(rt) + self._inner.process(token_id) + self._last_consumed_token = token_id + return + + # Pattern 3: free text between harmony messages (e.g. model outputs plain + # text after a <|end|> before starting the next channel message). + # The triggered_tags grammar allows free tokens in the sub-dispatch loop, + # so the model may generate trailing text that isn't part of any channel. + # Silently discard these tokens rather than crashing with HarmonyError. + if state == StreamState.EXPECT_START and token_id != _TOK_START: + return + + # Pattern 2: <|constrain|> during HEADER → enter skip mode + if state == StreamState.HEADER and token_id == _TOK_CONSTRAIN: + self._skip_until_message_or_end = True + return + + self._inner.process(token_id) + self._last_consumed_token = token_id + + # --- delegated properties ----------------------------------------- + + @property + def messages(self): + msgs = self._inner.messages + for msg in msgs: + if msg.recipient is not None: + sanitized = sanitize_harmony_recipient(msg.recipient) + msg.recipient = sanitized if sanitized else None + return msgs + + @property + def current_content(self): + return self._inner.current_content + + @property + def current_channel(self): + return self._inner.current_channel + + @property + def current_recipient(self): + raw = self._inner.current_recipient + if raw is not None: + sanitized = sanitize_harmony_recipient(raw) + return sanitized if sanitized else None + return raw + + @property + def current_role(self): + return self._inner.current_role + + @property + def state(self): + return self._inner.state + + @property + def last_content_delta(self): + return self._inner.last_content_delta + + @property + def last_consumed_token(self) -> int | None: + return self._last_consumed_token + + REASONING_EFFORT = { "high": ReasoningEffort.HIGH, "medium": ReasoningEffort.MEDIUM, @@ -256,7 +427,7 @@ def parse_chat_input_to_harmony_message( for call in tool_calls: func = call.get("function", {}) - name = func.get("name", "") + name = sanitize_harmony_name(func.get("name", "")) arguments = func.get("arguments", "") or "" msg = Message.from_role_and_content(Role.ASSISTANT, arguments) msg = msg.with_channel("commentary") @@ -328,8 +499,10 @@ def get_stop_tokens_for_assistant_actions() -> list[int]: return get_encoding().stop_tokens_for_assistant_actions() -def get_streamable_parser_for_assistant() -> StreamableParser: - return StreamableParser(get_encoding(), role=Role.ASSISTANT) +def get_streamable_parser_for_assistant() -> ResilientStreamableParser: + encoding = get_encoding() + inner = StreamableParser(encoding, role=Role.ASSISTANT) + return ResilientStreamableParser(inner, encoding) def parse_output_into_messages(token_ids: Iterable[int]) -> StreamableParser: diff --git a/vllm/entrypoints/openai/responses/context.py b/vllm/entrypoints/openai/responses/context.py index bab59e0aa1ec..49b85fdc01b8 100644 --- a/vllm/entrypoints/openai/responses/context.py +++ b/vllm/entrypoints/openai/responses/context.py @@ -31,6 +31,8 @@ get_encoding, get_streamable_parser_for_assistant, render_for_completion, + sanitize_harmony_name, + sanitize_harmony_recipient, ) from vllm.entrypoints.openai.parser.responses_parser import ( get_responses_parser_for_simple_context, @@ -669,7 +671,9 @@ def messages(self) -> list: def need_builtin_tool_call(self) -> bool: last_msg = self.messages[-1] recipient = last_msg.recipient - if recipient is None: + if recipient is not None: + recipient = sanitize_harmony_recipient(recipient) + if not recipient: return False if recipient.startswith("browser."): return "browser" in self.available_tools @@ -685,6 +689,8 @@ async def call_tool(self) -> list[Message]: last_msg = self.messages[-1] recipient = last_msg.recipient if recipient is not None: + recipient = sanitize_harmony_recipient(recipient) + if recipient: if recipient.startswith("browser."): return await self.call_search_tool( self._tool_sessions["browser"], last_msg @@ -708,7 +714,7 @@ async def call_search_tool( self.called_tools.add("browser") if isinstance(tool_session, Tool): return await tool_session.get_result(self) - tool_name = last_msg.recipient.split(".")[1] + tool_name = sanitize_harmony_name(last_msg.recipient.split(".")[1]) if envs.VLLM_TOOL_JSON_ERROR_AUTOMATIC_RETRY: try: args = json.loads(last_msg.content[0].text) @@ -795,7 +801,9 @@ async def call_container_tool( self.called_tools.add("container") if isinstance(tool_session, Tool): return await tool_session.get_result(self) - tool_name = last_msg.recipient.split(".")[1].split(" ")[0] + tool_name = sanitize_harmony_name( + last_msg.recipient.split(".")[1].split(" ")[0] + ) if envs.VLLM_TOOL_JSON_ERROR_AUTOMATIC_RETRY: try: args = json.loads(last_msg.content[0].text) @@ -875,7 +883,9 @@ def append_output(self, output: RequestOutput) -> None: self.current_turn_metrics.reset() # Check if the current token is part of reasoning content self._update_num_reasoning_tokens() - self.last_tok = tok + consumed = self.parser.last_consumed_token + if consumed is not None: + self.last_tok = consumed if len(self._messages) - self.num_init_messages < len(self.parser.messages): self._messages.extend( self.parser.messages[len(self._messages) - self.num_init_messages :] @@ -909,7 +919,10 @@ def render_for_completion(self) -> list[int]: last_n = -1 to_process = [] - while rendered_tokens[last_n] != self.last_tok: + while ( + abs(last_n) <= len(rendered_tokens) + and rendered_tokens[last_n] != self.last_tok + ): to_process.append(rendered_tokens[last_n]) last_n -= 1 for tok in reversed(to_process): diff --git a/vllm/entrypoints/openai/responses/harmony.py b/vllm/entrypoints/openai/responses/harmony.py index faab2f7f4cc7..f2502d5721de 100644 --- a/vllm/entrypoints/openai/responses/harmony.py +++ b/vllm/entrypoints/openai/responses/harmony.py @@ -32,6 +32,8 @@ from vllm.entrypoints.openai.parser.harmony_utils import ( BUILTIN_TOOL_TO_MCP_SERVER_LABEL, flatten_chat_text_content, + sanitize_harmony_name, + sanitize_harmony_recipient, ) from vllm.entrypoints.openai.responses.protocol import ( ResponseInputOutputItem, @@ -93,7 +95,7 @@ def _parse_chat_format_message(chat_msg: dict) -> list[Message]: msgs: list[Message] = [] for call in tool_calls: func = call.get("function", {}) - name = func.get("name", "") + name = sanitize_harmony_name(func.get("name", "")) arguments = func.get("arguments", "") or "" msg = Message.from_role_and_content(Role.ASSISTANT, arguments) msg = msg.with_channel("commentary") @@ -186,7 +188,8 @@ def response_input_to_harmony( elif response_msg["type"] == "function_call": msg = Message.from_role_and_content(Role.ASSISTANT, response_msg["arguments"]) msg = msg.with_channel("commentary") - msg = msg.with_recipient(f"functions.{response_msg['name']}") + sanitized_name = sanitize_harmony_name(response_msg["name"]) + msg = msg.with_recipient(f"functions.{sanitized_name}") msg = msg.with_content_type("json") else: raise ValueError(f"Unknown input type: {response_msg['type']}") @@ -292,7 +295,7 @@ def _parse_browser_tool_call(message: Message, recipient: str) -> ResponseOutput def _parse_function_call(message: Message, recipient: str) -> list[ResponseOutputItem]: """Parse function calls into function tool call items.""" - function_name = recipient.split(".")[-1] + function_name = sanitize_harmony_name(recipient.split(".")[-1]) output_items = [] for content in message.content: random_id = random_uuid() @@ -421,6 +424,10 @@ def harmony_to_response_output(message: Message) -> list[ResponseOutputItem]: output_items: list[ResponseOutputItem] = [] recipient = message.recipient + if recipient is not None: + recipient = sanitize_harmony_recipient(recipient) + if not recipient: + recipient = None if recipient is not None: # Browser tool calls (browser.search, browser.open, browser.find)