mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
fix(anthropic): decode deeply nested JSON mode answers without slow recursive validation
Backport of #50470 to lts/1.108.x.
Cherry-picked from 9c98b9892c (litellm_fix_anthropic_stream_fragment_json).
This commit is contained in:
parent
4925b39df9
commit
6b2f831c9d
3 changed files with 54 additions and 19 deletions
|
|
@ -12,11 +12,11 @@ import secrets
|
|||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final, TypeAlias
|
||||
from typing import Any, Final, TypeAlias
|
||||
|
||||
from openai.lib import _parsing, _pydantic # pyright: ignore[reportPrivateUsage] # SDK parser internals
|
||||
from pydantic import BaseModel, InstanceOf, JsonValue, TypeAdapter, ValidationError
|
||||
from typing_extensions import TypeAliasType
|
||||
from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError
|
||||
from typing_extensions import TypeIs # noqa: TID251 # json.loads with the number hooks yields only answer values
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping
|
||||
|
|
@ -201,22 +201,11 @@ class SourceNumber:
|
|||
|
||||
|
||||
_JsonScalar: TypeAlias = str | bool | int | float | None
|
||||
if TYPE_CHECKING:
|
||||
AnswerValue: TypeAlias = (
|
||||
InstanceOf[SourceNumber] | _JsonScalar | Sequence["AnswerValue"] | Mapping[str, "AnswerValue"]
|
||||
)
|
||||
else:
|
||||
AnswerValue = TypeAliasType(
|
||||
"AnswerValue", InstanceOf[SourceNumber] | _JsonScalar | Sequence["AnswerValue"] | Mapping[str, "AnswerValue"]
|
||||
)
|
||||
_ANSWER_OBJECT: Final = TypeAdapter(Mapping[str, AnswerValue])
|
||||
AnswerValue: TypeAlias = SourceNumber | _JsonScalar | Sequence["AnswerValue"] | Mapping[str, "AnswerValue"]
|
||||
|
||||
|
||||
def as_answer_object(value: object) -> Mapping[str, AnswerValue] | None:
|
||||
try:
|
||||
return _ANSWER_OBJECT.validate_python(value)
|
||||
except ValidationError:
|
||||
return None
|
||||
def as_answer_object(value: AnswerValue) -> Mapping[str, AnswerValue] | None:
|
||||
return value if isinstance(value, Mapping) else None
|
||||
|
||||
|
||||
def _source_float(text: str) -> float | SourceNumber:
|
||||
|
|
@ -232,11 +221,18 @@ def _source_int(text: str) -> int | SourceNumber:
|
|||
return SourceNumber(text)
|
||||
|
||||
|
||||
def _is_decoded_json_object(
|
||||
decoded: object,
|
||||
) -> TypeIs[Mapping[str, AnswerValue]]: # guard-ok: json.loads with the source-number hooks yields only answer values
|
||||
return isinstance(decoded, dict)
|
||||
|
||||
|
||||
def decoded_answer_object(text: str) -> Mapping[str, AnswerValue] | None:
|
||||
try:
|
||||
return _ANSWER_OBJECT.validate_python(json.loads(text, parse_float=_source_float, parse_int=_source_int))
|
||||
except (ValidationError, json.JSONDecodeError, RecursionError):
|
||||
decoded: Final[object] = json.loads(text, parse_float=_source_float, parse_int=_source_int)
|
||||
except (json.JSONDecodeError, RecursionError):
|
||||
return None
|
||||
return decoded if _is_decoded_json_object(decoded) else None
|
||||
|
||||
|
||||
def _marked_source_number(marker: str, number: SourceNumber) -> str:
|
||||
|
|
|
|||
|
|
@ -2958,6 +2958,11 @@ _DIGITS_PAST_THE_INT_LIMIT: Final = "1" * (sys.get_int_max_str_digits() + 1)
|
|||
(('{"n": 1e5}',), '{"n": 1e5}'),
|
||||
(('{"values": false}',), "false"),
|
||||
(('{"values": null}',), '{"values": null}'),
|
||||
pytest.param(
|
||||
('{"values": ' + "[" * 300 + "]" * 300 + "}",),
|
||||
"[" * 300 + "]" * 300,
|
||||
id="values-nested-300-levels",
|
||||
),
|
||||
pytest.param(
|
||||
('{"values": ' + "[" * 100000 + "]" * 100000 + "}",),
|
||||
'{"values": ' + "[" * 100000 + "]" * 100000 + "}",
|
||||
|
|
|
|||
|
|
@ -7909,6 +7909,40 @@ def test_convert_tool_response_to_message_keeps_arguments_too_deep_to_decode(arg
|
|||
assert message.content == arguments
|
||||
|
||||
|
||||
_DEEPLY_NESTED: Final = "[" * 300 + "]" * 300
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"arguments, schema, expected",
|
||||
[
|
||||
pytest.param('{"values": ' + _DEEPLY_NESTED + "}", None, _DEEPLY_NESTED, id="values_answer"),
|
||||
pytest.param(
|
||||
'{"name": "json_tool_call", "parameters": {"tier": ' + _DEEPLY_NESTED + ', "summary": "x"}}',
|
||||
_RISK_SCHEMA,
|
||||
'{"tier": ' + _DEEPLY_NESTED + ', "summary": "x"}',
|
||||
id="envelope",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_convert_tool_response_to_message_unwraps_a_deeply_nested_answer(
|
||||
arguments: str, schema: dict[str, object] | None, expected: str
|
||||
):
|
||||
message = AnthropicConfig.convert_tool_response_to_message(
|
||||
tool_calls=[
|
||||
ChatCompletionToolCallChunk(
|
||||
id="toolu_1",
|
||||
type="function",
|
||||
function=ChatCompletionToolCallFunctionChunk(name=RESPONSE_FORMAT_TOOL_NAME, arguments=arguments),
|
||||
index=0,
|
||||
)
|
||||
],
|
||||
response_format_schema=None if schema is None else _schema(schema),
|
||||
)
|
||||
|
||||
assert message is not None
|
||||
assert message.content == expected
|
||||
|
||||
|
||||
_DIGITS_PAST_THE_INT_LIMIT: Final = "1" * (sys.get_int_max_str_digits() + 1)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue