mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
fix(request-body): 使用 deepcopy 防止请求体在处理流程中被意外修改
handler 基类和格式转换 registry 中,原始请求体通过浅拷贝或直接引用传递, 导致下游处理(模型映射、格式转换、重试整流)可能修改原始数据, 影响后续重试或并发请求的正确性。统一改用 copy.deepcopy 隔离副本。
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
146
tests/api/handlers/base/test_cli_request_body_isolation.py
Normal file
146
tests/api/handlers/base/test_cli_request_body_isolation.py
Normal 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"
|
||||||
132
tests/core/api_format/conversion/test_registry_non_mutation.py
Normal file
132
tests/core/api_format/conversion/test_registry_non_mutation.py
Normal 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
|
||||||
Reference in New Issue
Block a user