Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/my-website/docs/completion/json_mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Works for:
- Google AI Studio - Gemini models
- Vertex AI models (Gemini + Anthropic)
- Bedrock Models
- Anthropic API Models

<Tabs>
<TabItem value="sdk" label="SDK">
Expand Down
91 changes: 83 additions & 8 deletions litellm/llms/anthropic/chat/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@
ChatCompletionToolCallFunctionChunk,
ChatCompletionUsageBlock,
)
from litellm.types.utils import GenericStreamingChunk, PromptTokensDetailsWrapper
from litellm.types.utils import GenericStreamingChunk
from litellm.types.utils import Message as LitellmMessage
from litellm.types.utils import PromptTokensDetailsWrapper
from litellm.utils import CustomStreamWrapper, ModelResponse, Usage

from ...base import BaseLLM
Expand Down Expand Up @@ -94,6 +96,7 @@ async def make_call(
messages: list,
logging_obj,
timeout: Optional[Union[float, httpx.Timeout]],
json_mode: bool,
) -> Tuple[Any, httpx.Headers]:
if client is None:
client = litellm.module_level_aclient
Expand All @@ -119,7 +122,9 @@ async def make_call(
raise AnthropicError(status_code=500, message=str(e))

completion_stream = ModelResponseIterator(
streaming_response=response.aiter_lines(), sync_stream=False
streaming_response=response.aiter_lines(),
sync_stream=False,
json_mode=json_mode,
)

# LOGGING
Expand All @@ -142,6 +147,7 @@ def make_sync_call(
messages: list,
logging_obj,
timeout: Optional[Union[float, httpx.Timeout]],
json_mode: bool,
) -> Tuple[Any, httpx.Headers]:
if client is None:
client = litellm.module_level_client # re-use a module level client
Expand Down Expand Up @@ -175,7 +181,7 @@ def make_sync_call(
)

completion_stream = ModelResponseIterator(
streaming_response=response.iter_lines(), sync_stream=True
streaming_response=response.iter_lines(), sync_stream=True, json_mode=json_mode
)

# LOGGING
Expand Down Expand Up @@ -270,11 +276,12 @@ def _process_response(
"arguments"
)
if json_mode_content_str is not None:
args = json.loads(json_mode_content_str)
values: Optional[dict] = args.get("values")
if values is not None:
_message = litellm.Message(content=json.dumps(values))
_converted_message = self._convert_tool_response_to_message(
tool_calls=tool_calls,
)
if _converted_message is not None:
completion_response["stop_reason"] = "stop"
_message = _converted_message
model_response.choices[0].message = _message # type: ignore
model_response._hidden_params["original_response"] = completion_response[
"content"
Expand Down Expand Up @@ -318,6 +325,37 @@ def _process_response(
model_response._hidden_params = _hidden_params
return model_response

@staticmethod
def _convert_tool_response_to_message(

@ghost ghost Nov 19, 2024

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ishaan-jaff this code should not be in the handler function as it's about llm translation.

it should be in the transformation.py or prompt_factory file


acknowledging prior code was in handler - it seems like that was missed during a refactor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey I think a lot of the previous code for this was written by you @krrishdholakia

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the PR I looked at - had no idea why this logic was in the handler to begin with but assumed there must have been a reason you had f2401d6#diff-4b817e67059fdef6b8fabcd1e356d6597ffdde6ff251a1233a01d41b8805207b

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed here @krrishdholakia #6834

tool_calls: List[ChatCompletionToolCallChunk],
) -> Optional[LitellmMessage]:
"""
In JSON mode, Anthropic API returns JSON schema as a tool call, we need to convert it to a message to follow the OpenAI format

"""
## HANDLE JSON MODE - anthropic returns single function call
json_mode_content_str: Optional[str] = tool_calls[0]["function"].get(
"arguments"
)
try:
if json_mode_content_str is not None:
args = json.loads(json_mode_content_str)
if (
isinstance(args, dict)
and (values := args.get("values")) is not None
):
_message = litellm.Message(content=json.dumps(values))
return _message
else:
# a lot of the times the `values` key is not present in the tool response
# relevant issue: https://github.com/BerriAI/litellm/issues/6741
_message = litellm.Message(content=json.dumps(args))
return _message
except json.JSONDecodeError:
# json decode error does occur, return the original tool response str
return litellm.Message(content=json_mode_content_str)
return None

async def acompletion_stream_function(
self,
model: str,
Expand All @@ -334,6 +372,7 @@ async def acompletion_stream_function(
stream,
_is_function_call,
data: dict,
json_mode: bool,
optional_params=None,
litellm_params=None,
logger_fn=None,
Expand All @@ -350,6 +389,7 @@ async def acompletion_stream_function(
messages=messages,
logging_obj=logging_obj,
timeout=timeout,
json_mode=json_mode,
)
streamwrapper = CustomStreamWrapper(
completion_stream=completion_stream,
Expand Down Expand Up @@ -500,6 +540,7 @@ def completion(
optional_params=optional_params,
stream=stream,
_is_function_call=_is_function_call,
json_mode=json_mode,
litellm_params=litellm_params,
logger_fn=logger_fn,
headers=headers,
Expand Down Expand Up @@ -547,6 +588,7 @@ def completion(
messages=messages,
logging_obj=logging_obj,
timeout=timeout,
json_mode=json_mode,
)
return CustomStreamWrapper(
completion_stream=completion_stream,
Expand Down Expand Up @@ -605,11 +647,14 @@ def embedding(self):


class ModelResponseIterator:
def __init__(self, streaming_response, sync_stream: bool):
def __init__(
self, streaming_response, sync_stream: bool, json_mode: Optional[bool] = False
):
self.streaming_response = streaming_response
self.response_iterator = self.streaming_response
self.content_blocks: List[ContentBlockDelta] = []
self.tool_index = -1
self.json_mode = json_mode

def check_empty_tool_call_args(self) -> bool:
"""
Expand Down Expand Up @@ -771,6 +816,8 @@ def chunk_parser(self, chunk: dict) -> GenericStreamingChunk:
status_code=500, # it looks like Anthropic API does not return a status code in the chunk error - default to 500
)

text, tool_use = self._handle_json_mode_chunk(text=text, tool_use=tool_use)

returned_chunk = GenericStreamingChunk(
text=text,
tool_use=tool_use,
Expand All @@ -785,6 +832,34 @@ def chunk_parser(self, chunk: dict) -> GenericStreamingChunk:
except json.JSONDecodeError:
raise ValueError(f"Failed to decode JSON from chunk: {chunk}")

def _handle_json_mode_chunk(
self, text: str, tool_use: Optional[ChatCompletionToolCallChunk]
) -> Tuple[str, Optional[ChatCompletionToolCallChunk]]:
"""
If JSON mode is enabled, convert the tool call to a message.

Anthropic returns the JSON schema as part of the tool call
OpenAI returns the JSON schema as part of the content, this handles placing it in the content

Args:
text: str
tool_use: Optional[ChatCompletionToolCallChunk]
Returns:
Tuple[str, Optional[ChatCompletionToolCallChunk]]

text: The text to use in the content
tool_use: The ChatCompletionToolCallChunk to use in the chunk response
"""
if self.json_mode is True and tool_use is not None:
message = AnthropicChatCompletion._convert_tool_response_to_message(
tool_calls=[tool_use]
)
if message is not None:
text = message.content or ""
tool_use = None

return text, tool_use

# Sync iterator
def __iter__(self):
return self
Expand Down
46 changes: 46 additions & 0 deletions tests/llm_translation/base_llm_unit_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ def test_content_list_handling(self):
)
assert response is not None

# for OpenAI the content contains the JSON schema, so we need to assert that the content is not None
assert response.choices[0].message.content is not None

def test_message_with_name(self):
base_completion_call_args = self.get_base_completion_call_args()
messages = [
Expand Down Expand Up @@ -79,6 +82,49 @@ def test_json_response_format(self):

print(response)

# OpenAI guarantees that the JSON schema is returned in the content
# relevant issue: https://github.com/BerriAI/litellm/issues/6741
assert response.choices[0].message.content is not None

def test_json_response_format_stream(self):
"""
Test that the JSON response format with streaming is supported by the LLM API
"""
base_completion_call_args = self.get_base_completion_call_args()
litellm.set_verbose = True

messages = [
{
"role": "system",
"content": "Your output should be a JSON object with no additional properties. ",
},
{
"role": "user",
"content": "Respond with this in json. city=San Francisco, state=CA, weather=sunny, temp=60",
},
]

response = litellm.completion(
**base_completion_call_args,
messages=messages,
response_format={"type": "json_object"},
stream=True,
)

print(response)

Check failure

Code scanning / CodeQL

Clear-text logging of sensitive information

This expression logs [sensitive data (secret)](1) as clear text. This expression logs [sensitive data (secret)](2) as clear text. This expression logs [sensitive data (secret)](3) as clear text. This expression logs [sensitive data (secret)](4) as clear text. This expression logs [sensitive data (secret)](5) as clear text. This expression logs [sensitive data (secret)](6) as clear text. This expression logs [sensitive data (secret)](7) as clear text. This expression logs [sensitive data (secret)](8) as clear text. This expression logs [sensitive data (secret)](9) as clear text. This expression logs [sensitive data (secret)](10) as clear text. This expression logs [sensitive data (secret)](11) as clear text. This expression logs [sensitive data (secret)](12) as clear text. This expression logs [sensitive data (secret)](13) as clear text. This expression logs [sensitive data (secret)](14) as clear text. This expression logs [sensitive data (secret)](15) as clear text. This expression logs [sensitive data (secret)](16) as clear text. This expression logs [sensitive data (secret)](17) as clear text. This expression logs [sensitive data (secret)](18) as clear text. This expression logs [sensitive data (secret)](19) as clear text. This expression logs [sensitive data (secret)](20) as clear text. This expression logs [sensitive data (secret)](21) as clear text. This expression logs [sensitive data (secret)](22) as clear text. This expression logs [sensitive data (secret)](23) as clear text. This expression logs [sensitive data (secret)](24) as clear text. This expression logs [sensitive data (secret)](25) as clear text. This expression logs [sensitive data (secret)](26) as clear text. This expression logs [sensitive data (secret)](27) as clear text. This expression logs [sensitive data (secret)](28) as clear text. This expression logs [sensitive data (secret)](29) as clear text. This expression logs [sensitive data (secret)](30) as clear text. This expression logs [sensitive data (secret)](31) as clear text. This expression logs [sensitive data (secret)](32) as clear text. This expression logs [sensitive data (secret)](33) as clear text. This expression logs [sensitive data (secret)](34) as clear text. This expression logs [sensitive data (secret)](35) as clear text. This expression logs [sensitive data (secret)](36) as clear text. This expression logs [sensitive data (secret)](37) as clear text. This expression logs [sensitive data (secret)](38) as clear text. This expression logs [sensitive data (secret)](39) as clear text. This expression logs [sensitive data (secret)](40) as clear text. This expression logs [sensitive data (secret)](41) as clear text. This expression logs [sensitive data (secret)](42) as clear text. This expression logs [sensitive data (secret)](43) as clear text. This expression logs [sensitive data (secret)](44) as clear text. This expression logs [sensitive data (secret)](45) as clear text. This expression logs [sensitive data (secret)](46) as clear text. This expression logs [sensitive data (secret)](47) as clear text. This expression logs [sensitive data (secret)](48) as clear text. This expression logs [sensitive data (secret)](49) as clear text. This expression logs [sensitive data (secret)](50) as clear text. This expression logs [sensitive data (secret)](51) as clear text. This expression logs [sensitive data (secret)](52) as clear text. This expression logs [sensitive data (secret)](53) as clear text. This expression logs [sensitive data (secret)](54) as clear text. This expression logs [sensitive data (secret)](55) as clear text. This expression logs [sensitive data (secret)](56) as clear text. This expression logs [sensitive data (secret)](57) as clear text. This expression logs [sensitive data (secret)](58) as clear text. This expression logs [sensitive data (secret)](59) as clear text. This expression logs [sensitive data (secret)](60) as clear text. This expression logs [sensitive data (secret)](61) as clear text. This expression logs [sensitive data (secret)](62) as clear text. This expressi

Copilot Autofix

AI almost 2 years ago

To fix the problem, we should avoid logging the entire response object directly. Instead, we can log only the non-sensitive parts of the response or use a logging mechanism that redacts sensitive information. In this case, we will remove the print(response) statement and ensure that only necessary information is logged.

Suggested changeset 1
tests/llm_translation/base_llm_unit_tests.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/tests/llm_translation/base_llm_unit_tests.py b/tests/llm_translation/base_llm_unit_tests.py
--- a/tests/llm_translation/base_llm_unit_tests.py
+++ b/tests/llm_translation/base_llm_unit_tests.py
@@ -113,4 +113,2 @@
 
-        print(response)
-
         content = ""
EOF
@@ -113,4 +113,2 @@

print(response)

content = ""
Copilot is powered by AI and may make mistakes. Always verify output.

content = ""
for chunk in response:
content += chunk.choices[0].delta.content or ""

print("content=", content)

# OpenAI guarantees that the JSON schema is returned in the content
# relevant issue: https://github.com/BerriAI/litellm/issues/6741
# we need to assert that the JSON schema was returned in the content, (for Anthropic we were returning it as part of the tool call)
assert content is not None
assert len(content) > 0

@pytest.fixture
def pdf_messages(self):
import base64
Expand Down
92 changes: 91 additions & 1 deletion tests/llm_translation/test_anthropic_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@
)
from litellm.adapters.anthropic_adapter import anthropic_adapter
from litellm.types.llms.anthropic import AnthropicResponse

from litellm.types.utils import GenericStreamingChunk, ChatCompletionToolCallChunk
from litellm.types.llms.openai import ChatCompletionToolCallFunctionChunk
from litellm.llms.anthropic.common_utils import process_anthropic_headers
from litellm.llms.anthropic.chat.handler import AnthropicChatCompletion
from httpx import Headers
from base_llm_unit_tests import BaseLLMChatTest

Expand Down Expand Up @@ -694,3 +696,91 @@ def test_pdf_handling(self, pdf_messages):
assert _document_validation["type"] == "document"
assert _document_validation["source"]["media_type"] == "application/pdf"
assert _document_validation["source"]["type"] == "base64"


def test_convert_tool_response_to_message_with_values():
"""Test converting a tool response with 'values' key to a message"""
tool_calls = [
ChatCompletionToolCallChunk(
id="test_id",
type="function",
function=ChatCompletionToolCallFunctionChunk(
name="json_tool_call",
arguments='{"values": {"name": "John", "age": 30}}',
),
index=0,
)
]

message = AnthropicChatCompletion._convert_tool_response_to_message(
tool_calls=tool_calls
)

assert message is not None
assert message.content == '{"name": "John", "age": 30}'


def test_convert_tool_response_to_message_without_values():
"""
Test converting a tool response without 'values' key to a message

Anthropic API returns the JSON schema in the tool call, OpenAI Spec expects it in the message. This test ensures that the tool call is converted to a message correctly.

Relevant issue: https://github.com/BerriAI/litellm/issues/6741
"""
tool_calls = [
ChatCompletionToolCallChunk(
id="test_id",
type="function",
function=ChatCompletionToolCallFunctionChunk(
name="json_tool_call", arguments='{"name": "John", "age": 30}'
),
index=0,
)
]

message = AnthropicChatCompletion._convert_tool_response_to_message(
tool_calls=tool_calls
)

assert message is not None
assert message.content == '{"name": "John", "age": 30}'


def test_convert_tool_response_to_message_invalid_json():
"""Test converting a tool response with invalid JSON"""
tool_calls = [
ChatCompletionToolCallChunk(
id="test_id",
type="function",
function=ChatCompletionToolCallFunctionChunk(
name="json_tool_call", arguments="invalid json"
),
index=0,
)
]

message = AnthropicChatCompletion._convert_tool_response_to_message(
tool_calls=tool_calls
)

assert message is not None
assert message.content == "invalid json"


def test_convert_tool_response_to_message_no_arguments():
"""Test converting a tool response with no arguments"""
tool_calls = [
ChatCompletionToolCallChunk(
id="test_id",
type="function",
function=ChatCompletionToolCallFunctionChunk(name="json_tool_call"),
index=0,
)
]

message = AnthropicChatCompletion._convert_tool_response_to_message(
tool_calls=tool_calls
)

assert message is None