mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
fix(azure): strip output-only reasoning content from Responses input (#50046)
* fix(azure): strip output-only reasoning content from Responses input * test(azure): cover idless encrypted reasoning replay * fix(azure): preserve existing status handling during reasoning replay * fix(azure): scope reasoning content filtering by provider * fix(azure): drop status and content from replayed reasoning items * fix(azure): forward non-object input items and drop reasoning content on every Azure route * fix(azure): drop replayed reasoning content only under drop_params Reasoning status is still dropped on every Azure route. Caller-set reasoning content now follows the created_by convention from #48783: it is dropped when drop_params is on and forwarded otherwise, so Azure answers its own 400. --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
8404bc094c
commit
91f039f4e5
4 changed files with 299 additions and 70 deletions
|
|
@ -3,11 +3,10 @@ from collections.abc import Callable, Mapping
|
|||
from copy import deepcopy
|
||||
from functools import partial
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, cast
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from openai.types.responses import ResponseReasoningItem
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -16,6 +15,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import prompt_cach
|
|||
from litellm.litellm_core_utils.url_utils import encode_url_path_segment
|
||||
from litellm.llms.azure.chat.gpt_reasoning_transformation import AzureOpenAIGPT5Config
|
||||
from litellm.llms.azure.common_utils import BaseAzureLLM
|
||||
from litellm.llms.azure.ptu_capacity import is_str_keyed_mapping
|
||||
from litellm.llms.base_llm.responses.transformation import SUMMARY_ONLY_REASONING_FIELD_VALUES, ReasoningFieldValues
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
from litellm.types.llms.openai import *
|
||||
|
|
@ -72,6 +72,21 @@ def _with_responses_api_version(api_base: str | None, litellm_params: Mapping[st
|
|||
return MappingProxyType({**litellm_params, "api_version": _resolve_responses_api_version(api_base, litellm_params)})
|
||||
|
||||
|
||||
_AZURE_REJECTED_INPUT_FIELDS: Final = MappingProxyType(
|
||||
{"message": frozenset({"status"}), "reasoning": frozenset({"status"})}
|
||||
)
|
||||
_AZURE_REJECTED_INPUT_FIELDS_UNDER_DROP_PARAMS: Final = MappingProxyType(
|
||||
{**_AZURE_REJECTED_INPUT_FIELDS, "reasoning": frozenset({"status", "content"})}
|
||||
)
|
||||
|
||||
|
||||
def _without_fields_azure_rejects(item: object, rejected_fields: Mapping[str, frozenset[str]]) -> object:
|
||||
if not is_str_keyed_mapping(item):
|
||||
return item
|
||||
rejected: Final = rejected_fields.get(str(item.get("type")), frozenset())
|
||||
return {key: value for key, value in item.items() if key not in rejected} if rejected else item
|
||||
|
||||
|
||||
class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
||||
# Parameters not supported by Azure Responses API
|
||||
AZURE_UNSUPPORTED_PARAMS = ["context_management"]
|
||||
|
|
@ -122,68 +137,17 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
|
|||
def get_stripped_model_name(self, model: str) -> str:
|
||||
return model.replace("responses/", "").replace("o_series/", "").replace("azure_ai/", "")
|
||||
|
||||
def _handle_reasoning_item(self, item: dict[str, Any]) -> Mapping[str, object]:
|
||||
"""
|
||||
Handle reasoning items to filter out the status field.
|
||||
Issue: https://github.com/BerriAI/litellm/issues/13484
|
||||
|
||||
Azure OpenAI API does not accept 'status' field in reasoning input items.
|
||||
"""
|
||||
if item.get("type") == "reasoning":
|
||||
try:
|
||||
# Ensure required fields are present for ResponseReasoningItem
|
||||
item_data: Final = dict(item)
|
||||
if "summary" not in item_data:
|
||||
item_data["summary"] = (
|
||||
item_data.get("reasoning_content", "")[:100] + "..."
|
||||
if len(item_data.get("reasoning_content", "")) > 100
|
||||
else item_data.get("reasoning_content", "")
|
||||
)
|
||||
|
||||
# Create ResponseReasoningItem object from the item data
|
||||
reasoning_item: Final = ResponseReasoningItem.model_validate(item_data)
|
||||
|
||||
# Convert back to dict with exclude_none=True to exclude None fields
|
||||
dict_reasoning_item: Final = reasoning_item.model_dump(exclude_none=True)
|
||||
dict_reasoning_item.pop("status", None)
|
||||
|
||||
return dict_reasoning_item
|
||||
except Exception as e:
|
||||
verbose_logger.debug("Failed to create ResponseReasoningItem, falling back to manual filtering: %s", e)
|
||||
# Fallback: manually filter out known None fields
|
||||
filtered_item: Final = {
|
||||
k: v
|
||||
for k, v in item.items()
|
||||
if v is not None or k not in {"status", "content", "encrypted_content"}
|
||||
}
|
||||
return filtered_item
|
||||
return item
|
||||
|
||||
def _validate_input_param(
|
||||
self, input: str | ResponseInputParam, *, drops_set_created_by: bool = False
|
||||
self, input: str | ResponseInputParam, *, drop_params: bool = False
|
||||
) -> str | ResponseInputParam:
|
||||
"""
|
||||
Override parent method to also filter out 'status' field from message items.
|
||||
Azure OpenAI API does not accept 'status' field in input messages.
|
||||
"""
|
||||
from typing import cast
|
||||
|
||||
# First call parent's validation
|
||||
validated_input: Final = super()._validate_input_param(input, drops_set_created_by=drops_set_created_by)
|
||||
|
||||
# Then filter out status from message items
|
||||
if isinstance(validated_input, list):
|
||||
filtered_input: Final[list[object]] = []
|
||||
for item in validated_input:
|
||||
if isinstance(item, dict) and item.get("type") == "message":
|
||||
# Filter out status field from message items
|
||||
filtered_item = {k: v for k, v in item.items() if k != "status"}
|
||||
filtered_input.append(filtered_item)
|
||||
else:
|
||||
filtered_input.append(item)
|
||||
return cast(ResponseInputParam, filtered_input)
|
||||
|
||||
return validated_input
|
||||
validated_input: Final = super()._validate_input_param(input, drop_params=drop_params)
|
||||
if not isinstance(validated_input, list):
|
||||
return validated_input
|
||||
rejected_fields: Final = (
|
||||
_AZURE_REJECTED_INPUT_FIELDS_UNDER_DROP_PARAMS if drop_params else _AZURE_REJECTED_INPUT_FIELDS
|
||||
)
|
||||
sanitized_items: Final = [_without_fields_azure_rejects(item, rejected_fields) for item in validated_input]
|
||||
return cast("ResponseInputParam", sanitized_items) # cast-ok: items keep their shape minus rejected fields
|
||||
|
||||
def transform_responses_api_request(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -669,7 +669,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
prompt_cache_options: object = None,
|
||||
) -> tuple[str | ResponseInputParam, Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None]:
|
||||
validated_input: Final = self._validate_input_param(
|
||||
self._drop_bridge_minted_reasoning_items(input), drops_set_created_by=drops_params(litellm_params)
|
||||
self._drop_bridge_minted_reasoning_items(input), drop_params=drops_params(litellm_params)
|
||||
)
|
||||
reads_breakpoints: Final = self._reads_prompt_cache_breakpoints(
|
||||
model=model, litellm_params=litellm_params, prompt_cache_options=prompt_cache_options
|
||||
|
|
@ -951,7 +951,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
return tools if all(new is old for new, old in zip(sanitized, tools, strict=True)) else sanitized
|
||||
|
||||
def _validate_input_param(
|
||||
self, input: str | ResponseInputParam, *, drops_set_created_by: bool = False
|
||||
self, input: str | ResponseInputParam, *, drop_params: bool = False
|
||||
) -> str | ResponseInputParam:
|
||||
"""
|
||||
Ensure all input fields if pydantic are converted to dict
|
||||
|
|
@ -965,9 +965,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
# if it's pydantic, convert to dict
|
||||
if isinstance(item, BaseModel):
|
||||
validated_input.append(
|
||||
replayable_responses_item(
|
||||
item.model_dump(exclude_none=True), drops_set_created_by=drops_set_created_by
|
||||
)
|
||||
replayable_responses_item(item.model_dump(exclude_none=True), drops_set_created_by=drop_params)
|
||||
)
|
||||
elif isinstance(item, dict):
|
||||
validated_input.append(
|
||||
|
|
@ -975,7 +973,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
|||
self._handle_reasoning_item(cast(dict[str, object], item))
|
||||
if item.get("type") == "reasoning"
|
||||
else item,
|
||||
drops_set_created_by=drops_set_created_by,
|
||||
drops_set_created_by=drop_params,
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ class PerplexityResponsesConfig(OpenAIResponsesAPIConfig):
|
|||
"""Handle preset/ model prefix: send as {"preset": name} instead of {"model": name}."""
|
||||
input = self._ensure_message_type(input)
|
||||
if model.startswith("preset/"):
|
||||
input = self._validate_input_param(input, drops_set_created_by=drops_params(litellm_params))
|
||||
input = self._validate_input_param(input, drop_params=drops_params(litellm_params))
|
||||
data: Final[dict] = {
|
||||
"preset": model[len("preset/") :],
|
||||
"input": input,
|
||||
|
|
|
|||
|
|
@ -7,15 +7,18 @@ from unittest.mock import MagicMock, patch
|
|||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from openai.types.responses import ResponseReasoningItem
|
||||
from pydantic import JsonValue, TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.constants import AZURE_DEFAULT_RESPONSES_API_VERSION
|
||||
from litellm.litellm_core_utils.get_model_cost_map import get_model_cost_map
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.llms.azure.responses.o_series_transformation import (
|
||||
AzureOpenAIOSeriesResponsesAPIConfig,
|
||||
)
|
||||
from litellm.llms.azure.responses.transformation import AzureOpenAIResponsesAPIConfig
|
||||
from litellm.llms.azure_ai.responses.transformation import AzureAIAgentResponsesAPIConfig, AzureAIResponsesAPIConfig
|
||||
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
|
@ -1547,3 +1550,267 @@ def test_openai_gpt5_resolves_to_responses_config_with_temperature() -> None:
|
|||
config: Final = ProviderConfigManager.get_provider_responses_api_config(provider=LlmProviders.OPENAI, model="gpt-5")
|
||||
assert type(config) is OpenAIResponsesAPIConfig
|
||||
assert "temperature" in config.get_supported_openai_params("gpt-5")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config", "model"),
|
||||
[
|
||||
(AzureOpenAIResponsesAPIConfig(), "gpt-6.1-sol"),
|
||||
(AzureOpenAIOSeriesResponsesAPIConfig(), "gpt-6.1-sol"),
|
||||
(AzureAIResponsesAPIConfig(), "gpt-6.1-sol"),
|
||||
(AzureAIAgentResponsesAPIConfig(), "agents/replay-test"),
|
||||
],
|
||||
ids=["azure", "o_series", "azure_ai", "azure_ai_agent"],
|
||||
)
|
||||
@pytest.mark.parametrize("as_model", [False, True], ids=["dict", "pydantic"])
|
||||
@pytest.mark.parametrize("drop_params", [True, False], ids=["drop-params", "no-drop-params"])
|
||||
def test_azure_reasoning_replay_removes_output_content_only_under_drop_params_without_changing_history(
|
||||
config: AzureOpenAIResponsesAPIConfig, model: str, as_model: bool, drop_params: bool
|
||||
) -> None:
|
||||
replayable_reasoning: Final = {
|
||||
"id": "rs_saved_reasoning",
|
||||
"type": "reasoning",
|
||||
"summary": [{"type": "summary_text", "text": "Check the file before changing it."}],
|
||||
"encrypted_content": "opaque-saved-reasoning",
|
||||
}
|
||||
saved_reasoning: Final = {
|
||||
**replayable_reasoning,
|
||||
"content": [{"type": "reasoning_text", "text": "I should inspect the file first."}],
|
||||
}
|
||||
tool_call: Final = {
|
||||
"type": "function_call",
|
||||
"id": "fc_read",
|
||||
"call_id": "call_read",
|
||||
"name": "read_file",
|
||||
"arguments": '{"path":"main.py"}',
|
||||
}
|
||||
tool_result: Final = {"type": "function_call_output", "call_id": "call_read", "output": "print(1)"}
|
||||
answer: Final = {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "The file prints 1.", "annotations": []}],
|
||||
}
|
||||
prompt: Final = {"role": "user", "content": "Continue."}
|
||||
history: Final = [
|
||||
ResponseReasoningItem.model_validate(saved_reasoning) if as_model else saved_reasoning,
|
||||
tool_call,
|
||||
tool_result,
|
||||
answer,
|
||||
prompt,
|
||||
]
|
||||
original: Final = deepcopy(history)
|
||||
|
||||
request: Final = config.transform_responses_api_request(
|
||||
model=model,
|
||||
input=history,
|
||||
response_api_optional_request_params={},
|
||||
litellm_params=GenericLiteLLMParams(drop_params=drop_params),
|
||||
headers={},
|
||||
)
|
||||
|
||||
sent_reasoning: Final = replayable_reasoning if drop_params else saved_reasoning
|
||||
assert request["input"] == [sent_reasoning, tool_call, tool_result, answer, prompt]
|
||||
assert history == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("as_model", [False, True], ids=["dict", "pydantic"])
|
||||
def test_openai_replay_without_drop_params_keeps_reasoning_content_and_message_status(as_model: bool) -> None:
|
||||
saved_reasoning: Final = {
|
||||
"id": "rs_saved_reasoning",
|
||||
"type": "reasoning",
|
||||
"summary": [],
|
||||
"content": [{"type": "reasoning_text", "text": "Inspect the file."}],
|
||||
"encrypted_content": "opaque-saved-reasoning",
|
||||
}
|
||||
saved_message: Final = {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "The file is ready.", "annotations": []}],
|
||||
}
|
||||
history: Final = [
|
||||
ResponseReasoningItem.model_validate(saved_reasoning) if as_model else saved_reasoning,
|
||||
saved_message,
|
||||
]
|
||||
original: Final = deepcopy(history)
|
||||
|
||||
request: Final = OpenAIResponsesAPIConfig().transform_responses_api_request(
|
||||
model="gpt-6.1-sol",
|
||||
input=history,
|
||||
response_api_optional_request_params={},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert request["input"] == [saved_reasoning, saved_message]
|
||||
assert history == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[AzureOpenAIResponsesAPIConfig(), AzureOpenAIOSeriesResponsesAPIConfig(), AzureAIResponsesAPIConfig()],
|
||||
ids=["azure", "o_series", "azure_ai"],
|
||||
)
|
||||
def test_azure_family_forwards_input_items_that_are_not_objects_for_the_provider_to_judge(
|
||||
config: AzureOpenAIResponsesAPIConfig,
|
||||
) -> None:
|
||||
saved_reasoning: Final = {"type": "reasoning", "summary": [], "status": "completed", "encrypted_content": "opaque"}
|
||||
|
||||
request: Final = config.transform_responses_api_request(
|
||||
model="gpt-6.1-sol",
|
||||
input=["Continue.", saved_reasoning, ["nested"]],
|
||||
response_api_optional_request_params={},
|
||||
litellm_params=GenericLiteLLMParams(),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert request["input"] == [
|
||||
"Continue.",
|
||||
{"type": "reasoning", "summary": [], "encrypted_content": "opaque"},
|
||||
["nested"],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures(httpx_transport.__name__)
|
||||
@pytest.mark.parametrize("async_call", [False, True], ids=["sync", "async"])
|
||||
@pytest.mark.parametrize("stream", [False, True])
|
||||
@pytest.mark.parametrize(("reasoning_index", "include_id"), [(51, True), (10, False)], ids=["with-id", "without-id"])
|
||||
async def test_azure_responses_replays_reasoning_history_without_fields_azure_rejects(
|
||||
respx_mock: respx.MockRouter, stream: bool, async_call: bool, reasoning_index: int, include_id: bool
|
||||
) -> None:
|
||||
prefix: Final = [{"role": "user", "content": f"Earlier context {index}."} for index in range(reasoning_index)]
|
||||
replayable_reasoning: Final = {
|
||||
"type": "reasoning",
|
||||
"summary": [],
|
||||
"encrypted_content": "opaque-saved-reasoning",
|
||||
**({"id": "rs_saved_reasoning"} if include_id else {}),
|
||||
}
|
||||
saved_reasoning: Final = {
|
||||
**replayable_reasoning,
|
||||
"status": "completed",
|
||||
"content": [{"type": "reasoning_text", "text": "Inspect the file before editing."}],
|
||||
}
|
||||
answer: Final = {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "Ready to continue.", "annotations": []}],
|
||||
}
|
||||
prompt: Final = {"role": "user", "content": "Continue."}
|
||||
replayable_followup: Final = {
|
||||
"type": "reasoning",
|
||||
"summary": [],
|
||||
"encrypted_content": "opaque-followup-reasoning",
|
||||
**({"id": "rs_followup_reasoning"} if include_id else {}),
|
||||
}
|
||||
saved_followup: Final = {
|
||||
**replayable_followup,
|
||||
"status": "completed",
|
||||
"content": [{"type": "reasoning_text", "text": "Check the next file."}],
|
||||
}
|
||||
history: Final = [*prefix, saved_reasoning, answer, saved_followup, prompt]
|
||||
original: Final = deepcopy(history)
|
||||
|
||||
def respond(request: httpx.Request) -> httpx.Response:
|
||||
body: Final = TypeAdapter(dict[str, JsonValue]).validate_json(request.content)
|
||||
items: Final = body["input"]
|
||||
assert isinstance(items, list)
|
||||
reasoning: Final = items[reasoning_index]
|
||||
assert isinstance(reasoning, dict)
|
||||
if "status" in reasoning:
|
||||
return httpx.Response(
|
||||
400,
|
||||
json={
|
||||
"error": {
|
||||
"message": f"Unknown parameter: 'input[{reasoning_index}].status'.",
|
||||
"type": "invalid_request_error",
|
||||
"param": f"input[{reasoning_index}].status",
|
||||
"code": "unknown_parameter",
|
||||
}
|
||||
},
|
||||
)
|
||||
if reasoning.get("content"):
|
||||
return httpx.Response(
|
||||
400,
|
||||
json={
|
||||
"error": {
|
||||
"message": f"Invalid 'input[{reasoning_index}].content': array too long. "
|
||||
"Expected an array with maximum length 0, "
|
||||
"but got an array with length 1 instead.",
|
||||
"type": "invalid_request_error",
|
||||
"param": f"input[{reasoning_index}].content",
|
||||
"code": "array_above_max_length",
|
||||
}
|
||||
},
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
text=_COMPLETED_RESPONSE_SSE if stream else json.dumps(_COMPLETED_RESPONSE),
|
||||
headers={"content-type": "text/event-stream" if stream else "application/json"},
|
||||
)
|
||||
|
||||
route: Final = respx_mock.post(host="example-resource.openai.azure.com").mock(side_effect=respond)
|
||||
request_params: Final = {
|
||||
"model": "azure/gpt-6.1-sol",
|
||||
"input": history,
|
||||
"api_base": _AZURE_RESOURCE,
|
||||
"api_key": "azure-test-key",
|
||||
"stream": stream,
|
||||
"store": False,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"drop_params": True,
|
||||
}
|
||||
response: Final = await litellm.aresponses(**request_params) if async_call else litellm.responses(**request_params)
|
||||
deltas: Final = (
|
||||
(
|
||||
[event.delta async for event in response if event.type == "response.output_text.delta"]
|
||||
if async_call
|
||||
else [event.delta for event in response if event.type == "response.output_text.delta"]
|
||||
)
|
||||
if stream
|
||||
else []
|
||||
)
|
||||
text: Final = "".join(deltas) if stream else response.output[0].content[0].text
|
||||
|
||||
assert text == "Done."
|
||||
assert route.call_count == 1
|
||||
assert TypeAdapter(dict[str, JsonValue]).validate_json(route.calls[0].request.content)["input"] == [
|
||||
*prefix,
|
||||
replayable_reasoning,
|
||||
answer,
|
||||
replayable_followup,
|
||||
prompt,
|
||||
]
|
||||
assert history == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("as_model", "include_id"),
|
||||
[(False, True), (False, False), (True, True)],
|
||||
ids=["dictionary-with-id", "dictionary-without-id", "pydantic"],
|
||||
)
|
||||
@pytest.mark.parametrize("drop_params", [True, False], ids=["drop-params", "no-drop-params"])
|
||||
def test_azure_reasoning_replay_drops_status_always_and_content_under_drop_params(
|
||||
as_model: bool, include_id: bool, drop_params: bool
|
||||
) -> None:
|
||||
replayable: Final = {
|
||||
"type": "reasoning",
|
||||
"summary": [],
|
||||
"encrypted_content": "opaque-saved-reasoning",
|
||||
**({"id": "rs_saved_reasoning"} if include_id else {}),
|
||||
}
|
||||
saved_content: Final = [{"type": "reasoning_text", "text": "Inspect the file first."}]
|
||||
saved_reasoning: Final = {**replayable, "status": "completed", "content": saved_content}
|
||||
history: Final = [ResponseReasoningItem.model_validate(saved_reasoning) if as_model else saved_reasoning]
|
||||
original: Final = deepcopy(history)
|
||||
|
||||
request: Final = AzureOpenAIResponsesAPIConfig().transform_responses_api_request(
|
||||
model="gpt-6.1-sol",
|
||||
input=history,
|
||||
response_api_optional_request_params={},
|
||||
litellm_params=GenericLiteLLMParams(drop_params=drop_params),
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert request["input"] == [replayable if drop_params else {**replayable, "content": saved_content}]
|
||||
assert history == original
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue