Merge pull request #41892 from BerriAI/litellm_gemini_contentless_candidate_finish_reason
Some checks failed
CI Coverage / assert-ci-coverage (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Helm unit test / unit-test (push) Waiting to run
Publish basedpyright base counts / publish (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Code Quality Checks / code-quality (push) Waiting to run
Code Quality Checks / python-310-import-smoke (push) Waiting to run
UI Unit Tests / ui-unit-tests (push) Waiting to run
Postgres Tests / proxy-security (push) Waiting to run
Postgres Tests / schema-migration (push) Waiting to run
Postgres Tests / proxy-behavior (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Unit Tests: Documentation Validation / documentation (push) Waiting to run
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Waiting to run
Unit Tests: Proxy DB Operations / auth-checks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / budgets (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / custom-logging (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / db-and-spend (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-server-core (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Blocked by required conditions
Unit Tests / misc (push) Waiting to run
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / key-generation (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / logging-misc (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-runtime (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-utils (push) Blocked by required conditions
Unit Tests / caching-local (push) Waiting to run
Unit Tests / core-utils (push) Waiting to run
Unit Tests / enterprise-package (push) Waiting to run
Unit Tests / enterprise-routing (push) Waiting to run
Unit Tests / integrations (push) Waiting to run
Unit Tests / All Other Providers (push) Waiting to run
Unit Tests / Vertex AI (push) Waiting to run
Unit Tests / proxy-auth (push) Waiting to run
Unit Tests / proxy-endpoints (push) Waiting to run
Unit Tests / proxy-extras (push) Waiting to run
Unit Tests / proxy-infra (push) Waiting to run
Unit Tests / proxy-server (push) Waiting to run
Unit Tests / responses-caching-types (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
Issue label sync / sync-issue-labels-tests (push) Has been cancelled
Issue label sync / sync-issue-labels (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled
VS Code Extension / vscode-extension (push) Has been cancelled

fix(gemini): preserve candidates with finishReason and no content (#40477)
This commit is contained in:
Mateo Wang 2026-09-18 16:34:46 -07:00 committed by GitHub
commit ff7dc86947
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 408 additions and 54 deletions

View file

@ -224,6 +224,12 @@ _FINISH_REASON_MAP: Final[dict[str, OpenAIChatCompletionFinishReason]] = {
"IMAGE_PROHIBITED_CONTENT": "content_filter",
"TOO_MANY_TOOL_CALLS": "stop",
"MALFORMED_RESPONSE": "stop",
"NO_IMAGE": "content_filter",
"IMAGE_RECITATION": "content_filter",
"IMAGE_OTHER": "content_filter",
"ESCALATION": "content_filter",
"UNEXPECTED_TOOL_CALL": "stop",
"MISSING_THOUGHT_SIGNATURE": "stop",
# Zhipu GLM
"network_error": "stop",
"sensitive": "content_filter",

View file

@ -1410,6 +1410,8 @@ class LiteLLMAnthropicMessagesAdapter:
return "max_tokens"
elif openai_finish_reason == "tool_calls":
return "tool_use"
elif openai_finish_reason in ["content_filter", "refusal"]:
return "refusal"
return "end_turn"
@staticmethod

View file

@ -6,7 +6,7 @@ import time
from collections.abc import Callable, Mapping, Sequence
from copy import deepcopy
from functools import partial
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Union, cast
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Union, cast, get_args
import httpx
@ -57,6 +57,7 @@ from litellm.types.llms.vertex_ai import (
ContentType,
FunctionCallingConfig,
FunctionDeclaration,
GeminiFinishReason,
GeminiThinkingConfig,
GenerateContentResponseBody,
HttpxPartType,
@ -1330,25 +1331,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
"IMAGE_PROHIBITED_CONTENT": "The token generation was stopped as the response was flagged for prohibited image content.",
}
_GEMINI_FINISH_REASON_KEYS = frozenset(
{
"STOP",
"MAX_TOKENS",
"SAFETY",
"RECITATION",
"FINISH_REASON_UNSPECIFIED",
"MALFORMED_FUNCTION_CALL",
"LANGUAGE",
"OTHER",
"BLOCKLIST",
"PROHIBITED_CONTENT",
"SPII",
"IMAGE_SAFETY",
"IMAGE_PROHIBITED_CONTENT",
"TOO_MANY_TOOL_CALLS",
"MALFORMED_RESPONSE",
}
)
_GEMINI_FINISH_REASON_KEYS: Final[frozenset[str]] = frozenset(get_args(GeminiFinishReason))
@staticmethod
def get_finish_reason_mapping() -> dict[str, OpenAIChatCompletionFinishReason]:
@ -2232,22 +2215,23 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
grounding_metadata: Final[list[dict]] = []
url_context_metadata: Final[list[dict]] = []
image_response: list[ImageURLListItem] | None = None
safety_ratings: Final[list] = []
citation_metadata: Final[list] = []
chat_completion_message: Final[ChatCompletionResponseMessage] = {"role": "assistant"}
chat_completion_logprobs: ChoiceLogprobs | None = None
tools: list[ChatCompletionToolCallChunk] | None = []
functions: ChatCompletionToolCallFunctionChunk | None = None
thinking_blocks: list[ChatCompletionThinkingBlock] | None = None
reasoning_content: str | None = None
thought_signatures: Sequence[str] | None = None
server_side_tool_invocations: list[dict[str, object]] | None = None
for idx, candidate in enumerate(_candidates):
if "content" not in candidate:
if "content" not in candidate and "finishReason" not in candidate:
continue
image_response: list[ImageURLListItem] | None = None
chat_completion_message: ChatCompletionResponseMessage = {"role": "assistant"}
chat_completion_logprobs: ChoiceLogprobs | None = None
tools: list[ChatCompletionToolCallChunk] | None = None
functions: ChatCompletionToolCallFunctionChunk | None = None
thinking_blocks: list[ChatCompletionThinkingBlock] | None = None
reasoning_content: str | None = None
thought_signatures: Sequence[str] | None = None
server_side_tool_invocations: list[dict[str, object]] | None = None
# Extract metadata using helper function
(
candidate_grounding_metadata,
@ -2261,7 +2245,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
safety_ratings.extend(candidate_safety_ratings)
citation_metadata.extend(candidate_citation_metadata)
if "parts" in candidate["content"]:
if "content" in candidate and candidate["content"] and "parts" in candidate["content"]:
(
content,
reasoning_content,
@ -2368,14 +2352,18 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
)
model_response.choices.append(choice)
elif isinstance(model_response, ModelResponse):
native_finish_reason = candidate.get("finishReason")
choice = litellm.Choices(
finish_reason=VertexGeminiConfig._check_finish_reason(
chat_completion_message, candidate.get("finishReason")
chat_completion_message, native_finish_reason
),
index=candidate.get("index", idx),
message=chat_completion_message,
logprobs=chat_completion_logprobs,
enhancements=None,
provider_specific_fields=(
{"native_finish_reason": native_finish_reason} if native_finish_reason is not None else None
),
)
model_response.choices.append(choice)
@ -3173,12 +3161,10 @@ class ModelResponseIterator:
self.has_seen_tool_calls = True
break
# _process_candidates skips candidates without a "content" part, so a
# content-less chunk leaves choices empty and the downstream streaming
# handler hits IndexError on choices[0]. This covers the final chunk
# (finishReason, no content) and mid-stream metadata-only chunks
# (grounding/web-search/thought, no content and no finishReason — seen
# with web_search + reasoning) by emitting an empty-delta choice.
# _process_candidates skips candidates with neither "content" nor
# "finishReason", so a metadata-only chunk (grounding/web-search/thought,
# seen with web_search + reasoning) leaves choices empty and the downstream
# streaming handler hits IndexError on choices[0]. Emit an empty-delta choice.
if not model_response.choices and _candidates:
from litellm.types.utils import Delta, StreamingChoices

View file

@ -61,6 +61,7 @@ from litellm.types.llms.openai import (
ChatCompletionToolParamFunctionChunk,
ChatCompletionUserMessage,
GenericChatCompletionMessage,
IncompleteDetails,
InputTokensDetails,
OpenAIChatCompletionTextObject,
OpenAIMcpServerTool,
@ -111,6 +112,9 @@ ResponseTools: TypeAlias = Sequence[Mapping[str, object]] | None
ChatToolParam: TypeAlias = ChatCompletionToolParam | OpenAIMcpServerTool
NAMESPACE_DESCRIPTION_SEPARATOR: Final = "\n\n"
NAMESPACE_MEMBER_TYPES_WITH_CHAT_TOOLS: Final = frozenset({"function", "custom"})
_INCOMPLETE_REASON_BY_FINISH_REASON: Final[Mapping[str, Literal["max_output_tokens", "content_filter"]]] = (
MappingProxyType({"length": "max_output_tokens", "content_filter": "content_filter", "refusal": "content_filter"})
)
@dataclass(frozen=True, slots=True)
@ -2299,6 +2303,18 @@ class LiteLLMCompletionResponsesConfig:
# Default to completed for unknown finish reasons
return "completed"
@staticmethod
def _incomplete_details_for_finish_reason(
finish_reason: str | None,
existing: IncompleteDetails | None,
) -> IncompleteDetails | None:
if existing is not None:
return existing
if finish_reason is None:
return None
reason: Final = _INCOMPLETE_REASON_BY_FINISH_REASON.get(finish_reason)
return IncompleteDetails(reason=reason) if reason is not None else None
@staticmethod
def _tool_call_id_from_responses_item(item_id: str | None, call_id: str | None) -> str:
"""Bedrock Mantle returns a non-unique, index-based ``call_id`` (``call_0``,
@ -2415,13 +2431,18 @@ class LiteLLMCompletionResponsesConfig:
if choices and len(choices) > 0:
finish_reason = choices[0].finish_reason
incomplete_details: Final = LiteLLMCompletionResponsesConfig._incomplete_details_for_finish_reason(
finish_reason=finish_reason,
existing=getattr(chat_completion_response, "incomplete_details", None),
)
responses_api_response: Final[ResponsesAPIResponse] = ResponsesAPIResponse(
id=chat_completion_response.id,
created_at=chat_completion_response.created,
model=chat_completion_response.model,
object="response",
error=getattr(chat_completion_response, "error", None),
incomplete_details=getattr(chat_completion_response, "incomplete_details", None),
incomplete_details=incomplete_details,
instructions=getattr(chat_completion_response, "instructions", None),
metadata=getattr(chat_completion_response, "metadata", {}),
output=LiteLLMCompletionResponsesConfig._transform_chat_completion_choices_to_responses_output(

View file

@ -425,22 +425,35 @@ class UrlContextMetadata(TypedDict, total=False):
urlMetadata: list[UrlMetadata]
GeminiFinishReason = Literal[
"FINISH_REASON_UNSPECIFIED",
"STOP",
"MAX_TOKENS",
"SAFETY",
"RECITATION",
"LANGUAGE",
"OTHER",
"BLOCKLIST",
"PROHIBITED_CONTENT",
"SPII",
"MALFORMED_FUNCTION_CALL",
"IMAGE_SAFETY",
"IMAGE_PROHIBITED_CONTENT",
"TOO_MANY_TOOL_CALLS",
"MALFORMED_RESPONSE",
"NO_IMAGE",
"IMAGE_RECITATION",
"IMAGE_OTHER",
"ESCALATION",
"UNEXPECTED_TOOL_CALL",
"MISSING_THOUGHT_SIGNATURE",
]
class Candidates(TypedDict, total=False):
index: int
content: HttpxContentType
finishReason: Literal[
"FINISH_REASON_UNSPECIFIED",
"STOP",
"MAX_TOKENS",
"SAFETY",
"RECITATION",
"OTHER",
"BLOCKLIST",
"PROHIBITED_CONTENT",
"SPII",
"MALFORMED_FUNCTION_CALL",
"IMAGE_SAFETY",
]
finishReason: GeminiFinishReason
safetyRatings: list[SafetyRatings]
citationMetadata: CitationMetadata
groundingMetadata: GroundingMetadata

View file

@ -151,6 +151,12 @@ class TestMapFinishReasonGemini:
("IMAGE_PROHIBITED_CONTENT", "content_filter"),
("TOO_MANY_TOOL_CALLS", "stop"),
("MALFORMED_RESPONSE", "stop"),
("NO_IMAGE", "content_filter"),
("IMAGE_RECITATION", "content_filter"),
("IMAGE_OTHER", "content_filter"),
("ESCALATION", "content_filter"),
("UNEXPECTED_TOOL_CALL", "stop"),
("MISSING_THOUGHT_SIGNATURE", "stop"),
],
)
def test_gemini_finish_reasons(self, gemini_reason, expected):

View file

@ -105,6 +105,46 @@ def test_translate_chat_length_takes_precedence_over_refusal():
assert result.get("stop_details") is None
def test_translate_chat_content_filter_to_anthropic_response():
response = ModelResponse(
id="chatcmpl-content-filter",
model="openai-model",
choices=[
Choices(
index=0,
finish_reason="content_filter",
message=Message(content=None, role="assistant"),
)
],
usage=Usage(prompt_tokens=1, completion_tokens=0, total_tokens=1),
)
result = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(response)
assert result["content"] == []
assert result["stop_reason"] == "refusal"
def test_translate_chat_refusal_finish_reason_to_anthropic_response():
response = ModelResponse(
id="chatcmpl-refusal-reason",
model="openai-model",
choices=[
Choices(
index=0,
finish_reason="refusal",
message=Message(content=None, role="assistant"),
)
],
usage=Usage(prompt_tokens=1, completion_tokens=0, total_tokens=1),
)
result = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(response)
assert result["content"] == []
assert result["stop_reason"] == "refusal"
def test_translate_streaming_openai_chunk_to_anthropic_content_block():
choices = [
StreamingChoices(

View file

@ -2,7 +2,7 @@ import asyncio
import json
import re
from copy import deepcopy
from typing import Final, List, cast
from typing import Final, List, cast, get_args
from unittest.mock import MagicMock, patch
import httpx
@ -18,7 +18,7 @@ from litellm.llms.vertex_ai.common_utils import VertexAIError
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
VertexGeminiConfig,
)
from litellm.types.llms.vertex_ai import UsageMetadata
from litellm.types.llms.vertex_ai import GeminiFinishReason, UsageMetadata
from litellm.types.utils import ChoiceLogprobs, Usage
from litellm.utils import CustomStreamWrapper
@ -940,6 +940,11 @@ def test_check_finish_reason():
)
def test_every_documented_gemini_finish_reason_has_an_explicit_mapping():
documented: Final = frozenset(get_args(GeminiFinishReason))
assert set(VertexGeminiConfig.get_finish_reason_mapping()) == documented
def test_finish_reason_unspecified_and_malformed_function_call():
"""
Test that FINISH_REASON_UNSPECIFIED and MALFORMED_FUNCTION_CALL
@ -968,6 +973,12 @@ def test_finish_reason_unspecified_and_malformed_function_call():
# Test new Gemini finish reasons
assert finish_reason_mappings["TOO_MANY_TOOL_CALLS"] == "stop"
assert finish_reason_mappings["MALFORMED_RESPONSE"] == "stop"
assert finish_reason_mappings["NO_IMAGE"] == "content_filter"
assert finish_reason_mappings["IMAGE_RECITATION"] == "content_filter"
assert finish_reason_mappings["IMAGE_OTHER"] == "content_filter"
assert finish_reason_mappings["ESCALATION"] == "content_filter"
assert finish_reason_mappings["UNEXPECTED_TOOL_CALL"] == "stop"
assert finish_reason_mappings["MISSING_THOUGHT_SIGNATURE"] == "stop"
def test_vertex_ai_usage_metadata_response_token_count():
@ -6074,3 +6085,210 @@ def test_prompt_blocked_chunk_keeps_served_model_version():
assert streaming_chunk.model == "gemini-3.8-flash-001"
assert streaming_chunk.choices[0].finish_reason == "content_filter"
def test_gemini_candidate_with_finish_reason_no_content_chat_completion():
config = VertexGeminiConfig()
completion_response = {
"candidates": [
{
"finishReason": "NO_IMAGE",
"index": 0,
}
],
"usageMetadata": {
"promptTokenCount": 19,
"candidatesTokenCount": 0,
"totalTokenCount": 19,
},
}
model_response = ModelResponse()
logging_obj = MagicMock()
raw_response = MagicMock()
raw_response.headers = {}
resp = config._transform_google_generate_content_to_openai_model_response(
completion_response=completion_response,
model_response=model_response,
model="gemini-2.5-flash-image",
logging_obj=logging_obj,
raw_response=raw_response,
)
assert len(resp.choices) == 1
assert resp.choices[0].finish_reason == "content_filter"
assert resp.choices[0].message.content is None
assert resp.choices[0].provider_specific_fields["native_finish_reason"] == "NO_IMAGE"
def test_gemini_candidate_with_finish_reason_no_content_anthropic_messages():
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
LiteLLMAnthropicMessagesAdapter,
)
config = VertexGeminiConfig()
completion_response = {
"candidates": [
{
"finishReason": "NO_IMAGE",
"index": 0,
}
],
"usageMetadata": {
"promptTokenCount": 19,
"candidatesTokenCount": 0,
"totalTokenCount": 19,
},
}
resp = config._transform_google_generate_content_to_openai_model_response(
completion_response=completion_response,
model_response=ModelResponse(),
model="gemini-2.5-flash-image",
logging_obj=MagicMock(),
raw_response=MagicMock(headers={}),
)
adapter = LiteLLMAnthropicMessagesAdapter()
anthropic_resp = adapter.translate_openai_response_to_anthropic(
response=resp,
tool_name_mapping={},
)
assert anthropic_resp["stop_reason"] == "refusal"
assert anthropic_resp["content"] == []
def test_gemini_candidate_with_finish_reason_no_content_responses_api():
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
config = VertexGeminiConfig()
completion_response = {
"candidates": [
{
"finishReason": "NO_IMAGE",
"index": 0,
}
],
"usageMetadata": {
"promptTokenCount": 19,
"candidatesTokenCount": 0,
"totalTokenCount": 19,
},
}
resp = config._transform_google_generate_content_to_openai_model_response(
completion_response=completion_response,
model_response=ModelResponse(),
model="gemini-2.5-flash-image",
logging_obj=MagicMock(),
raw_response=MagicMock(headers={}),
)
responses_resp = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="Generate picture",
responses_api_request={},
chat_completion_response=resp,
)
assert responses_resp.status == "incomplete"
assert responses_resp.incomplete_details is not None
assert responses_resp.incomplete_details.reason == "content_filter"
def test_gemini_candidate_other_finish_reasons_no_content():
from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import (
LiteLLMAnthropicMessagesAdapter,
)
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
config = VertexGeminiConfig()
max_tokens_response = {
"candidates": [{"finishReason": "MAX_TOKENS", "index": 0}],
"usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 50, "totalTokenCount": 60},
}
resp_length = config._transform_google_generate_content_to_openai_model_response(
completion_response=max_tokens_response,
model_response=ModelResponse(),
model="gemini-2.5-flash",
logging_obj=MagicMock(),
raw_response=MagicMock(headers={}),
)
assert len(resp_length.choices) == 1
assert resp_length.choices[0].finish_reason == "length"
assert resp_length.choices[0].provider_specific_fields["native_finish_reason"] == "MAX_TOKENS"
anthropic_length = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(
response=resp_length,
tool_name_mapping={},
)
assert anthropic_length["stop_reason"] == "max_tokens"
responses_length = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="thinking request",
responses_api_request={},
chat_completion_response=resp_length,
)
assert responses_length.status == "incomplete"
assert responses_length.incomplete_details.reason == "max_output_tokens"
def test_gemini_candidate_with_finish_reason_no_content_streaming_chunk():
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
ModelResponseIterator,
)
chunk: Final = {
"candidates": [{"finishReason": "NO_IMAGE", "index": 0}],
"usageMetadata": {"promptTokenCount": 19, "candidatesTokenCount": 0, "totalTokenCount": 19},
}
iterator: Final = ModelResponseIterator(streaming_response=[], sync_stream=True, logging_obj=MagicMock())
streaming_chunk: Final = iterator.chunk_parser(chunk)
assert len(streaming_chunk.choices) == 1
assert streaming_chunk.choices[0].finish_reason == "content_filter"
assert streaming_chunk.choices[0].delta.content is None
assert streaming_chunk.choices[0].delta.tool_calls is None
def test_gemini_multi_candidate_messages_do_not_share_state():
config: Final = VertexGeminiConfig()
completion_response: Final = {
"candidates": [
{
"content": {
"role": "model",
"parts": [
{"text": "Let me check the weather.", "thought": True},
{"functionCall": {"name": "get_weather", "args": {"city": "Paris"}}},
],
},
"finishReason": "STOP",
"index": 0,
},
{
"content": {"role": "model", "parts": [{"text": "It is sunny in Paris."}]},
"finishReason": "STOP",
"index": 1,
},
],
"usageMetadata": {"promptTokenCount": 10, "candidatesTokenCount": 20, "totalTokenCount": 30},
}
resp: Final = config._transform_google_generate_content_to_openai_model_response(
completion_response=completion_response,
model_response=ModelResponse(),
model="gemini-2.5-flash",
logging_obj=MagicMock(),
raw_response=MagicMock(headers={}),
)
assert len(resp.choices) == 2
assert resp.choices[0].finish_reason == "tool_calls"
assert resp.choices[0].message.tool_calls[0].function.name == "get_weather"
assert resp.choices[0].message.reasoning_content == "Let me check the weather."
assert resp.choices[1].finish_reason == "stop"
assert resp.choices[1].message.content == "It is sunny in Paris."
assert resp.choices[1].message.tool_calls is None
assert getattr(resp.choices[1].message, "reasoning_content", None) is None
assert resp.choices[1].provider_specific_fields["native_finish_reason"] == "STOP"

View file

@ -4938,3 +4938,65 @@ class TestStreamingSnapshotItemIds:
reasoning_items = _bridged_output_items(completed_event.response, "reasoning")
assert len(reasoning_items) == 1
assert reasoning_items[0].id == streamed_event.item_id
def test_transform_chat_completion_response_incomplete_details():
from litellm.types.llms.openai import IncompleteDetails
resp_length = ModelResponse(
id="resp-length",
choices=[Choices(index=0, finish_reason="length", message=Message(content="cutoff", role="assistant"))],
model="gpt-4o",
)
result_length = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="test prompt",
responses_api_request={},
chat_completion_response=resp_length,
)
assert result_length.status == "incomplete"
assert result_length.incomplete_details is not None
assert result_length.incomplete_details.reason == "max_output_tokens"
resp_filter = ModelResponse(
id="resp-filter",
choices=[Choices(index=0, finish_reason="content_filter", message=Message(content=None, role="assistant"))],
model="gpt-4o",
)
result_filter = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="test prompt",
responses_api_request={},
chat_completion_response=resp_filter,
)
assert result_filter.status == "incomplete"
assert result_filter.incomplete_details is not None
assert result_filter.incomplete_details.reason == "content_filter"
resp_refusal = ModelResponse(
id="resp-refusal",
choices=[Choices(index=0, finish_reason="refusal", message=Message(content=None, role="assistant"))],
model="gpt-4o",
)
result_refusal = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="test prompt",
responses_api_request={},
chat_completion_response=resp_refusal,
)
assert result_refusal.status == "incomplete"
assert result_refusal.incomplete_details is not None
assert result_refusal.incomplete_details.reason == "content_filter"
existing_details = IncompleteDetails(reason="content_filter")
resp_existing = ModelResponse(
id="resp-existing",
choices=[Choices(index=0, finish_reason="length", message=Message(content="cutoff", role="assistant"))],
model="gpt-4o",
)
resp_existing.incomplete_details = existing_details
result_existing = LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
request_input="test prompt",
responses_api_request={},
chat_completion_response=resp_existing,
)
assert result_existing.status == "incomplete"
assert result_existing.incomplete_details == existing_details