From ba4f91b5d149a7d3c4bf20ffa4402be7189b4dad Mon Sep 17 00:00:00 2001 From: dengzi <2294612869@qq.com> Date: Tue, 3 Nov 2026 10:50:12 +0800 Subject: [PATCH] fix(token_counter): count assistant refusal content parts token_counter raised UnsupportedContentTypeError on the refusal part that the OpenAI SDK documents for assistant messages, so POST /utils/token_counter answered 500 for any history containing one. A refusal now counts as text like the text part it replaces, and the content block union carries it. --- litellm/litellm_core_utils/token_counter.py | 10 ++++++++-- litellm/types/llms/openai.py | 6 ++++++ .../litellm_core_utils/test_token_counter.py | 17 +++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index df8038e756c..9dc7e95719a 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -1691,7 +1691,13 @@ def _count_anthropic_content( _TEXT_FIELD_BY_BLOCK_TYPE: Final = MappingProxyType( - {"text": "text", "thinking": "thinking", "redacted_thinking": "thinking", "compaction": "content"} + { + "text": "text", + "thinking": "thinking", + "redacted_thinking": "thinking", + "compaction": "content", + "refusal": "refusal", + } ) @@ -1771,7 +1777,7 @@ def _count_content_list( f"Invalid content item type: {content_type}. " f"Expected str or dict with 'type' field " f"(text, image_url, image, document, file, tool_use, tool_result, thinking, redacted_thinking, " - f"compaction, tool_reference, search_result)." + f"compaction, tool_reference, search_result, refusal)." ) return num_tokens except Exception as e: diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 1d1628035ec..569730b7977 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -881,6 +881,11 @@ class ChatCompletionFileObject(TypedDict): file: ChatCompletionFileObjectFile +class ChatCompletionRefusalObject(TypedDict): + type: ReadOnly[Literal["refusal"]] + refusal: ReadOnly[str] + + OpenAIMessageContentListBlock = ( ChatCompletionTextObject | ChatCompletionImageObject @@ -888,6 +893,7 @@ OpenAIMessageContentListBlock = ( | ChatCompletionDocumentObject | ChatCompletionVideoObject | ChatCompletionFileObject + | ChatCompletionRefusalObject ) OpenAIMessageContent = str | Iterable[OpenAIMessageContentListBlock] diff --git a/tests/unit/litellm_core_utils/test_token_counter.py b/tests/unit/litellm_core_utils/test_token_counter.py index 8ef118154d1..875dd4c8718 100644 --- a/tests/unit/litellm_core_utils/test_token_counter.py +++ b/tests/unit/litellm_core_utils/test_token_counter.py @@ -3626,3 +3626,20 @@ def test_openai_model_check_holds_while_openai_models_are_registered_concurrentl registering.result() finally: litellm.open_ai_chat_completion_models.difference_update(registered) + + +def test_refusal_content_part_counts_as_the_text_it_replaces() -> None: + """An assistant refusal part is a documented OpenAI content block, so it must count instead of raising""" + with_text = [ + {"role": "user", "content": "A request"}, + {"role": "assistant", "content": [{"type": "text", "text": "I cannot help with that request."}]}, + {"role": "user", "content": "Please suggest a safe alternative."}, + ] + with_refusal = [ + {"role": "user", "content": "A request"}, + {"role": "assistant", "content": [{"type": "refusal", "refusal": "I cannot help with that request."}]}, + {"role": "user", "content": "Please suggest a safe alternative."}, + ] + assert token_counter_old(model="gpt-4o", messages=with_refusal) == token_counter_old( + model="gpt-4o", messages=with_text + )