diff --git a/litellm/litellm_core_utils/token_counter.py b/litellm/litellm_core_utils/token_counter.py index e56dd83a277..74f456ca0b2 100644 --- a/litellm/litellm_core_utils/token_counter.py +++ b/litellm/litellm_core_utils/token_counter.py @@ -1812,7 +1812,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", + } ) @@ -1892,7 +1898,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 378899b667d..c09b08f81af 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -882,6 +882,11 @@ class ChatCompletionFileObject(TypedDict): file: ChatCompletionFileObjectFile +class ChatCompletionRefusalObject(TypedDict): + type: ReadOnly[Literal["refusal"]] + refusal: ReadOnly[str] + + OpenAIMessageContentListBlock = ( ChatCompletionTextObject | ChatCompletionImageObject @@ -889,6 +894,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 d0b837646b4..9bc1fd1723d 100644 --- a/tests/unit/litellm_core_utils/test_token_counter.py +++ b/tests/unit/litellm_core_utils/test_token_counter.py @@ -3625,3 +3625,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 + )