fix(request-body): 使用 deepcopy 防止请求体在处理流程中被意外修改

handler 基类和格式转换 registry 中,原始请求体通过浅拷贝或直接引用传递,
导致下游处理(模型映射、格式转换、重试整流)可能修改原始数据,
影响后续重试或并发请求的正确性。统一改用 copy.deepcopy 隔离副本。
This commit is contained in:
fawney19
2026-03-17 13:23:02 +08:00
parent d63d5eff85
commit d480aa11f3
7 changed files with 313 additions and 33 deletions

View File

@@ -22,6 +22,7 @@ Chat Handler Base - Chat API 格式的通用基类
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import copy
import json import json
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import AsyncGenerator, Awaitable, Callable from collections.abc import AsyncGenerator, Awaitable, Callable
@@ -499,7 +500,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试 # 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试
# 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流} # 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流}
request_body_ref: dict[str, Any] = {"body": original_request_body} request_body_ref: dict[str, Any] = {"body": copy.deepcopy(original_request_body)}
# 创建类型安全的流式上下文 # 创建类型安全的流式上下文
ctx = StreamContext( ctx = StreamContext(
@@ -723,10 +724,9 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
) )
# 应用模型映射到请求体 # 应用模型映射到请求体
request_body = copy.deepcopy(original_request_body)
if mapped_model: if mapped_model:
request_body = self.apply_mapped_model(original_request_body, mapped_model) request_body = self.apply_mapped_model(request_body, mapped_model)
else:
request_body = dict(original_request_body)
provider_type = str(getattr(provider, "provider_type", "") or "").lower() provider_type = str(getattr(provider, "provider_type", "") or "").lower()
behavior = get_provider_behavior( behavior = get_provider_behavior(

View File

@@ -11,6 +11,7 @@ ChatSyncExecutor - 非流式请求执行器
from __future__ import annotations from __future__ import annotations
import copy
import json import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -123,7 +124,7 @@ class ChatSyncExecutor:
# 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试 # 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试
# 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流} # 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流}
request_body_ref: dict[str, Any] = {"body": original_request_body} request_body_ref: dict[str, Any] = {"body": copy.deepcopy(original_request_body)}
# 捕获的上下文变量 # 捕获的上下文变量
ctx = self._ctx ctx = self._ctx

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import codecs import codecs
import copy
import json import json
import time import time
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
@@ -97,7 +98,7 @@ class CliStreamMixin:
# 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试 # 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试
# 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流} # 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流}
request_body_ref: dict[str, Any] = {"body": original_request_body} request_body_ref: dict[str, Any] = {"body": copy.deepcopy(original_request_body)}
# 使用子类实现的方法提取 model不同 API 格式的 model 位置不同) # 使用子类实现的方法提取 model不同 API 格式的 model 位置不同)
# 注意:使用 original_request_body因为整流只修改 messages不影响 model 字段 # 注意:使用 original_request_body因为整流只修改 messages不影响 model 字段
@@ -309,11 +310,10 @@ class CliStreamMixin:
) )
# 应用模型映射到请求体(子类可覆盖此方法处理不同格式) # 应用模型映射到请求体(子类可覆盖此方法处理不同格式)
request_body = copy.deepcopy(original_request_body)
if mapped_model: if mapped_model:
ctx.mapped_model = mapped_model # 保存映射后的模型名,用于 Usage 记录 ctx.mapped_model = mapped_model # 保存映射后的模型名,用于 Usage 记录
request_body = self.apply_mapped_model(original_request_body, mapped_model) request_body = self.apply_mapped_model(request_body, mapped_model)
else:
request_body = original_request_body
client_api_format = ( client_api_format = (
ctx.client_api_format.value ctx.client_api_format.value

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import copy
import json import json
import time import time
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@@ -115,7 +116,7 @@ class CliSyncMixin:
# 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试 # 可变请求体容器:允许 TaskService 在遇到 Thinking 签名错误时整流请求体后重试
# 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流} # 结构: {"body": 实际请求体, "_rectified": 是否已整流, "_rectified_this_turn": 本轮是否整流}
request_body_ref: dict[str, Any] = {"body": original_request_body} request_body_ref: dict[str, Any] = {"body": copy.deepcopy(original_request_body)}
async def sync_request_func( async def sync_request_func(
provider: "Provider", provider: "Provider",
@@ -136,11 +137,10 @@ class CliSyncMixin:
) )
# 应用模型映射到请求体(子类可覆盖此方法处理不同格式) # 应用模型映射到请求体(子类可覆盖此方法处理不同格式)
request_body = copy.deepcopy(request_body_ref["body"])
if mapped_model: if mapped_model:
mapped_model_result = mapped_model # 保存映射后的模型名,用于 Usage 记录 mapped_model_result = mapped_model # 保存映射后的模型名,用于 Usage 记录
request_body = self.apply_mapped_model(request_body_ref["body"], mapped_model) request_body = self.apply_mapped_model(request_body, mapped_model)
else:
request_body = dict(request_body_ref["body"])
client_api_format = ( client_api_format = (
api_format.value if hasattr(api_format, "value") else str(api_format) api_format.value if hasattr(api_format, "value") else str(api_format)

View File

@@ -10,6 +10,7 @@ source -> internal -> target
""" """
import ast import ast
import copy
import importlib import importlib
import inspect import inspect
import threading import threading
@@ -250,7 +251,7 @@ class FormatConversionRegistry:
output_limit: int | None = None, output_limit: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
if self._same_normalizer(source_format, target_format) and not target_variant: if self._same_normalizer(source_format, target_format) and not target_variant:
return request return copy.deepcopy(request)
# 同 normalizer + variant: 优先尝试轻量补丁(跳过 internal 转换) # 同 normalizer + variant: 优先尝试轻量补丁(跳过 internal 转换)
if self._same_normalizer(source_format, target_format) and target_variant: if self._same_normalizer(source_format, target_format) and target_variant:
@@ -258,7 +259,7 @@ class FormatConversionRegistry:
with _track_conversion_metrics( with _track_conversion_metrics(
"request_patch", str(source_format).upper(), str(target_format).upper() "request_patch", str(source_format).upper(), str(target_format).upper()
): ):
patched = normalizer.patch_for_variant(request, target_variant) patched = normalizer.patch_for_variant(copy.deepcopy(request), target_variant)
if patched is not None: if patched is not None:
return patched return patched
@@ -269,7 +270,7 @@ class FormatConversionRegistry:
"request", str(source_format).upper(), str(target_format).upper() "request", str(source_format).upper(), str(target_format).upper()
): ):
try: try:
internal = src.request_to_internal(request) internal = src.request_to_internal(copy.deepcopy(request))
internal.output_limit = output_limit internal.output_limit = output_limit
repair_stats = self._repair_internal_tool_call_ids(internal) repair_stats = self._repair_internal_tool_call_ids(internal)
if repair_stats["generated_tool_use_ids"] or repair_stats["filled_tool_result_ids"]: if repair_stats["generated_tool_use_ids"] or repair_stats["filled_tool_result_ids"]:
@@ -295,7 +296,7 @@ class FormatConversionRegistry:
) -> dict[str, Any]: ) -> dict[str, Any]:
"""异步版本的 convert_request在 internal 阶段执行图片 URL 下载等异步操作。""" """异步版本的 convert_request在 internal 阶段执行图片 URL 下载等异步操作。"""
if self._same_normalizer(source_format, target_format) and not target_variant: if self._same_normalizer(source_format, target_format) and not target_variant:
return request return copy.deepcopy(request)
# 同 normalizer + variant: 优先尝试轻量补丁(跳过 internal 转换) # 同 normalizer + variant: 优先尝试轻量补丁(跳过 internal 转换)
if self._same_normalizer(source_format, target_format) and target_variant: if self._same_normalizer(source_format, target_format) and target_variant:
@@ -303,7 +304,7 @@ class FormatConversionRegistry:
with _track_conversion_metrics( with _track_conversion_metrics(
"request_patch", str(source_format).upper(), str(target_format).upper() "request_patch", str(source_format).upper(), str(target_format).upper()
): ):
patched = normalizer.patch_for_variant(request, target_variant) patched = normalizer.patch_for_variant(copy.deepcopy(request), target_variant)
if patched is not None: if patched is not None:
return patched return patched
@@ -314,7 +315,7 @@ class FormatConversionRegistry:
"request", str(source_format).upper(), str(target_format).upper() "request", str(source_format).upper(), str(target_format).upper()
): ):
try: try:
internal = src.request_to_internal(request) internal = src.request_to_internal(copy.deepcopy(request))
internal.output_limit = output_limit internal.output_limit = output_limit
repair_stats = self._repair_internal_tool_call_ids(internal) repair_stats = self._repair_internal_tool_call_ids(internal)
if repair_stats["generated_tool_use_ids"] or repair_stats["filled_tool_result_ids"]: if repair_stats["generated_tool_use_ids"] or repair_stats["filled_tool_result_ids"]:
@@ -352,15 +353,15 @@ class FormatConversionRegistry:
而不是上游返回的映射后模型名。 而不是上游返回的映射后模型名。
""" """
if self._same_normalizer(source_format, target_format): if self._same_normalizer(source_format, target_format):
response_copy = copy.deepcopy(response)
# 即使格式相同,也需要替换 model 字段 # 即使格式相同,也需要替换 model 字段
if requested_model and isinstance(response, dict): if requested_model and isinstance(response_copy, dict):
response = dict(response) # 避免修改原始响应
# 支持不同格式的 model 字段名 # 支持不同格式的 model 字段名
if "model" in response: if "model" in response_copy:
response["model"] = requested_model response_copy["model"] = requested_model
elif "modelVersion" in response: elif "modelVersion" in response_copy:
response["modelVersion"] = requested_model response_copy["modelVersion"] = requested_model
return response return response_copy
src = self._require_normalizer(source_format) src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format) tgt = self._require_normalizer(target_format)
@@ -369,7 +370,7 @@ class FormatConversionRegistry:
"response", str(source_format).upper(), str(target_format).upper() "response", str(source_format).upper(), str(target_format).upper()
): ):
try: try:
internal = src.response_to_internal(response) internal = src.response_to_internal(copy.deepcopy(response))
return tgt.response_from_internal(internal, requested_model=requested_model) return tgt.response_from_internal(internal, requested_model=requested_model)
except Exception as e: except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e raise FormatConversionError(source_format, target_format, str(e)) from e
@@ -381,7 +382,7 @@ class FormatConversionRegistry:
target_format: str, target_format: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
if self._same_normalizer(source_format, target_format): if self._same_normalizer(source_format, target_format):
return error_response return copy.deepcopy(error_response)
src = self._require_normalizer(source_format) src = self._require_normalizer(source_format)
tgt = self._require_normalizer(target_format) tgt = self._require_normalizer(target_format)
@@ -400,7 +401,7 @@ class FormatConversionRegistry:
"error", str(source_format).upper(), str(target_format).upper() "error", str(source_format).upper(), str(target_format).upper()
): ):
try: try:
internal = src.error_to_internal(error_response) internal = src.error_to_internal(copy.deepcopy(error_response))
return tgt.error_from_internal(internal) return tgt.error_from_internal(internal)
except Exception as e: except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e raise FormatConversionError(source_format, target_format, str(e)) from e
@@ -428,7 +429,7 @@ class FormatConversionRegistry:
tgt_base = self._video_format_to_base(target_format) tgt_base = self._video_format_to_base(target_format)
if src_base == tgt_base: if src_base == tgt_base:
return request return copy.deepcopy(request)
src = self._require_normalizer(src_base) src = self._require_normalizer(src_base)
tgt = self._require_normalizer(tgt_base) tgt = self._require_normalizer(tgt_base)
@@ -437,7 +438,7 @@ class FormatConversionRegistry:
"video_request", str(source_format).upper(), str(target_format).upper() "video_request", str(source_format).upper(), str(target_format).upper()
): ):
try: try:
internal = src.video_request_to_internal(request) internal = src.video_request_to_internal(copy.deepcopy(request))
return tgt.video_request_from_internal(internal) return tgt.video_request_from_internal(internal)
except Exception as e: except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e raise FormatConversionError(source_format, target_format, str(e)) from e
@@ -462,7 +463,7 @@ class FormatConversionRegistry:
tgt_base = self._video_format_to_base(target_format) tgt_base = self._video_format_to_base(target_format)
if src_base == tgt_base: if src_base == tgt_base:
return task_response return copy.deepcopy(task_response)
src = self._require_normalizer(src_base) src = self._require_normalizer(src_base)
tgt = self._require_normalizer(tgt_base) tgt = self._require_normalizer(tgt_base)
@@ -471,7 +472,7 @@ class FormatConversionRegistry:
"video_task", str(source_format).upper(), str(target_format).upper() "video_task", str(source_format).upper(), str(target_format).upper()
): ):
try: try:
internal = src.video_task_to_internal(task_response) internal = src.video_task_to_internal(copy.deepcopy(task_response))
return tgt.video_task_from_internal(internal) return tgt.video_task_from_internal(internal)
except Exception as e: except Exception as e:
raise FormatConversionError(source_format, target_format, str(e)) from e raise FormatConversionError(source_format, target_format, str(e)) from e

View File

@@ -0,0 +1,146 @@
from __future__ import annotations
import copy
from types import SimpleNamespace
from typing import Any
import pytest
import src.api.handlers.base.cli_stream_mixin as mixmod
from src.api.handlers.base.cli_stream_mixin import CliStreamMixin
from src.api.handlers.base.stream_context import StreamContext
class _StopBuild(Exception):
pass
class _DummyAuthInfo:
auth_header = "authorization"
auth_value = "Bearer test"
decrypted_auth_config = None
def as_tuple(self) -> tuple[str, str]:
return self.auth_header, self.auth_value
class _CaptureBuilder:
def __init__(self) -> None:
self.request_body: dict[str, Any] | None = None
def build(self, request_body: dict[str, Any], *args: Any, **kwargs: Any) -> Any:
self.request_body = request_body
raise _StopBuild()
class _DummyCliStreamHandler(CliStreamMixin):
FORMAT_ID = "openai:cli"
def __init__(self) -> None:
self.primary_api_format = "openai:cli"
self.request_id = "req-test"
self.api_key = SimpleNamespace(id="user-key-1")
self._request_builder = _CaptureBuilder()
async def _get_mapped_model(self, source_model: str, provider_id: str) -> str | None:
return None
def apply_mapped_model(self, request_body: dict[str, Any], mapped_model: str) -> dict[str, Any]:
out = dict(request_body)
out["model"] = mapped_model
return out
def prepare_provider_request_body(self, request_body: dict[str, Any]) -> dict[str, Any]:
request_body.pop("_aether_compact", None)
request_body["input"][0]["content"][0]["text"] = "prepared"
return request_body
def finalize_provider_request(
self,
request_body: dict[str, Any],
*,
mapped_model: str | None,
provider_api_format: str | None,
) -> dict[str, Any]:
request_body["input"][0]["content"].append({"type": "input_text", "text": "finalized"})
return request_body
def get_model_for_url(
self,
request_body: dict[str, Any],
mapped_model: str | None,
) -> str | None:
return mapped_model or str(request_body.get("model") or "")
@pytest.mark.asyncio
async def test_execute_stream_request_does_not_mutate_original_request_body(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_get_provider_auth(endpoint: Any, key: Any) -> _DummyAuthInfo:
return _DummyAuthInfo()
monkeypatch.setattr(mixmod, "get_provider_auth", _fake_get_provider_auth)
monkeypatch.setattr(
mixmod,
"get_provider_behavior",
lambda **kwargs: SimpleNamespace(
envelope=None,
same_format_variant=None,
cross_format_variant=None,
),
)
monkeypatch.setattr(mixmod, "get_upstream_stream_policy", lambda *args, **kwargs: None)
monkeypatch.setattr(
mixmod,
"resolve_upstream_is_stream",
lambda *, client_is_stream, policy: client_is_stream,
)
monkeypatch.setattr(mixmod, "enforce_stream_mode_for_upstream", lambda *args, **kwargs: None)
monkeypatch.setattr(
mixmod,
"maybe_patch_request_with_prompt_cache_key",
lambda request_body, **kwargs: request_body,
)
handler = _DummyCliStreamHandler()
ctx = StreamContext(model="gpt-test", api_format="openai:cli")
ctx.client_api_format = "openai:cli"
provider = SimpleNamespace(name="provider", id="provider-1", provider_type="", proxy=None)
endpoint = SimpleNamespace(id="endpoint-1", api_format="openai:cli", base_url="https://x")
key = SimpleNamespace(id="key-1", proxy=None)
candidate = SimpleNamespace(
mapping_matched_model=None, needs_conversion=False, output_limit=None
)
original_request_body = {
"model": "gpt-test",
"_aether_compact": True,
"input": [
{
"role": "user",
"content": [
{"type": "input_text", "text": "hello"},
],
}
],
}
snapshot = copy.deepcopy(original_request_body)
with pytest.raises(_StopBuild):
await handler._execute_stream_request(
ctx,
provider,
endpoint,
key,
original_request_body,
{},
candidate=candidate,
)
assert original_request_body == snapshot
assert handler._request_builder.request_body is not None
assert "_aether_compact" not in handler._request_builder.request_body
assert handler._request_builder.request_body["input"][0]["content"][0]["text"] == "prepared"
assert handler._request_builder.request_body["input"][0]["content"][-1]["text"] == "finalized"

View File

@@ -0,0 +1,132 @@
from __future__ import annotations
import copy
from typing import Any
from src.core.api_format.conversion.internal import (
FormatCapabilities,
InternalMessage,
InternalRequest,
InternalResponse,
Role,
TextBlock,
)
from src.core.api_format.conversion.normalizer import FormatNormalizer
from src.core.api_format.conversion.registry import FormatConversionRegistry
class _BaseTestNormalizer(FormatNormalizer):
capabilities = FormatCapabilities()
def response_to_internal(self, response: dict[str, Any]) -> InternalResponse:
return InternalResponse(id=str(response.get("id") or ""), model="", content=[])
def response_from_internal(
self,
internal: InternalResponse,
*,
requested_model: str | None = None,
) -> dict[str, Any]:
return {
"id": internal.id,
"model": requested_model or internal.model,
}
class _SameFormatNormalizer(_BaseTestNormalizer):
FORMAT_ID = "TEST:SAME"
def request_to_internal(self, request: dict[str, Any]) -> InternalRequest:
return InternalRequest(model=str(request.get("model") or ""), messages=[])
def request_from_internal(
self,
internal: InternalRequest,
*,
target_variant: str | None = None,
) -> dict[str, Any]:
return {"model": internal.model, "variant": target_variant}
class _MutatingSourceNormalizer(_BaseTestNormalizer):
FORMAT_ID = "TEST:MUTSRC"
def request_to_internal(self, request: dict[str, Any]) -> InternalRequest:
request.pop("ephemeral", None)
request["messages"][0]["content"][0]["text"] = "mutated"
return InternalRequest(
model=str(request.get("model") or ""),
messages=[
InternalMessage(
role=Role.USER,
content=[TextBlock(text=str(request["messages"][0]["content"][0]["text"]))],
)
],
)
def request_from_internal(
self,
internal: InternalRequest,
*,
target_variant: str | None = None,
) -> dict[str, Any]:
return {"model": internal.model, "variant": target_variant}
class _TargetNormalizer(_BaseTestNormalizer):
FORMAT_ID = "TEST:MUTTGT"
def request_to_internal(self, request: dict[str, Any]) -> InternalRequest:
return InternalRequest(model=str(request.get("model") or ""), messages=[])
def request_from_internal(
self,
internal: InternalRequest,
*,
target_variant: str | None = None,
) -> dict[str, Any]:
text = ""
if internal.messages and internal.messages[0].content:
first = internal.messages[0].content[0]
if isinstance(first, TextBlock):
text = first.text
return {"model": internal.model, "text": text, "variant": target_variant}
def test_convert_request_same_format_returns_detached_copy() -> None:
registry = FormatConversionRegistry()
registry.register(_SameFormatNormalizer())
original = {
"model": "gpt-test",
"messages": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}],
}
out = registry.convert_request(original, "test:same", "test:same")
assert out == original
assert out is not original
assert out["messages"] is not original["messages"]
out["messages"][0]["content"][0]["text"] = "changed"
assert original["messages"][0]["content"][0]["text"] == "hello"
def test_convert_request_cross_format_does_not_mutate_original_input() -> None:
registry = FormatConversionRegistry()
registry.register(_MutatingSourceNormalizer())
registry.register(_TargetNormalizer())
original = {
"model": "gpt-test",
"ephemeral": "keep-me",
"messages": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}],
}
snapshot = copy.deepcopy(original)
out = registry.convert_request(original, "test:mutsrc", "test:muttgt")
assert out["model"] == "gpt-test"
assert out["text"] == "mutated"
assert original == snapshot