From 9439b97804223f2c1a9fbf60cd792a312a906d78 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Wed, 8 Jul 2026 12:58:25 +0200 Subject: [PATCH] =?UTF-8?q?fix(gateway):=20=E9=80=82=E9=85=8D=E5=99=A8?= =?UTF-8?q?=E7=9E=AC=E6=97=B6=E9=94=99=E8=AF=AF=E6=B6=88=E6=81=AF=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E2=80=94=E2=80=94=E5=8F=AA=E7=95=99=E7=B1=BB=E5=90=8D?= =?UTF-8?q?+=E7=8A=B6=E6=80=81=E7=A0=81=E4=B8=8D=E6=B3=84=E6=BC=8F?= =?UTF-8?q?=E5=8E=82=E5=95=86=E5=93=8D=E5=BA=94=E4=BD=93=EF=BC=88CR-M2-2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/test_adapter_error_sanitization.py | 92 +++++++++++++++++++ .../ww_llm_gateway/adapters/anthropic.py | 9 +- .../ww_llm_gateway/adapters/base.py | 16 ++++ .../ww_llm_gateway/adapters/gemini.py | 9 +- .../ww_llm_gateway/adapters/openai_compat.py | 9 +- 5 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 packages/llm_gateway/tests/test_adapter_error_sanitization.py diff --git a/packages/llm_gateway/tests/test_adapter_error_sanitization.py b/packages/llm_gateway/tests/test_adapter_error_sanitization.py new file mode 100644 index 0000000..4222378 --- /dev/null +++ b/packages/llm_gateway/tests/test_adapter_error_sanitization.py @@ -0,0 +1,92 @@ +"""适配器错误脱敏单测(CR-M2-2:上抛消息不得内嵌厂商 HTTP 响应体)。 + +厂商异常的 `str(exc)` 常携带完整响应体(可能含请求内容/内部细节)。适配器把瞬时 +故障包装成 `TransientProviderError` 时,消息应只含**异常类名 + 状态码**,绝不含原始 +响应体(防泄漏,§9.3)。完整细节仍可留在服务端日志。 +""" + +from __future__ import annotations + +import uuid +from types import SimpleNamespace +from typing import Any, cast + +import pytest +from openai import AsyncOpenAI +from ww_llm_gateway.adapters.base import provider_error_summary +from ww_llm_gateway.adapters.openai_compat import OpenAICompatAdapter +from ww_llm_gateway.errors import TransientProviderError +from ww_llm_gateway.types import LlmRequest, Scope + +_SECRET = "SECRET_BODY_MARKER_xyz" + + +def _req(**kw: Any) -> LlmRequest: + kw.setdefault("tier", "writer") + kw.setdefault("input", "x") + return LlmRequest(scope=Scope(user_id=uuid.UUID(int=1)), **kw) + + +class _LeakyRateLimit(Exception): + """底层厂商异常:`str()` 内嵌响应体,且带 429 状态码(触发瞬时判定)。""" + + def __init__(self) -> None: + super().__init__(f'HTTP 429 {{"error": "{_SECRET}"}}') + self.status_code = 429 + + +def test_provider_error_summary_omits_body_keeps_class_and_status() -> None: + summary = provider_error_summary(_LeakyRateLimit()) + + assert _SECRET not in summary + assert "_LeakyRateLimit" in summary + assert "429" in summary + + +def test_provider_error_summary_class_only_when_no_status() -> None: + class Boom(Exception): + def __init__(self) -> None: + super().__init__(_SECRET) + + summary = provider_error_summary(Boom()) + + assert _SECRET not in summary + assert summary == "Boom" + + +class _RaisingCompletions: + def __init__(self, *, stream: bool) -> None: + self._stream = stream + + async def create(self, **kw: Any) -> Any: + raise _LeakyRateLimit() + + +def _adapter(*, stream: bool) -> OpenAICompatAdapter: + client = SimpleNamespace(chat=SimpleNamespace(completions=_RaisingCompletions(stream=stream))) + return OpenAICompatAdapter("deepseek", cast(AsyncOpenAI, client)) + + +async def test_complete_wraps_transient_without_leaking_body() -> None: + adapter = _adapter(stream=False) + + with pytest.raises(TransientProviderError) as exc_info: + await adapter.complete(_req(), "deepseek-chat") + + msg = exc_info.value.message + assert _SECRET not in msg + assert "_LeakyRateLimit" in msg + assert "429" in msg + + +async def test_stream_wraps_transient_without_leaking_body() -> None: + adapter = _adapter(stream=True) + + with pytest.raises(TransientProviderError) as exc_info: + async for _ in adapter.stream(_req(), "deepseek-chat"): + pass + + msg = exc_info.value.message + assert _SECRET not in msg + assert "_LeakyRateLimit" in msg + assert "429" in msg diff --git a/packages/llm_gateway/ww_llm_gateway/adapters/anthropic.py b/packages/llm_gateway/ww_llm_gateway/adapters/anthropic.py index 5d12ede..ab58b13 100644 --- a/packages/llm_gateway/ww_llm_gateway/adapters/anthropic.py +++ b/packages/llm_gateway/ww_llm_gateway/adapters/anthropic.py @@ -22,6 +22,7 @@ from .base import ( ProviderUsage, StreamChunk, is_transient_by_name, + provider_error_summary, ) if TYPE_CHECKING: @@ -131,7 +132,9 @@ class AnthropicAdapter: return await self._complete_text(req, model) except Exception as exc: if _is_transient(exc): - raise TransientProviderError(str(exc), provider=self.provider) from exc + raise TransientProviderError( + provider_error_summary(exc), provider=self.provider + ) from exc raise async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult: @@ -187,5 +190,7 @@ class AnthropicAdapter: yield StreamChunk(usage=_usage_from(usage)) except Exception as exc: if _is_transient(exc): - raise TransientProviderError(str(exc), provider=self.provider) from exc + raise TransientProviderError( + provider_error_summary(exc), provider=self.provider + ) from exc raise diff --git a/packages/llm_gateway/ww_llm_gateway/adapters/base.py b/packages/llm_gateway/ww_llm_gateway/adapters/base.py index 587b20d..6c77275 100644 --- a/packages/llm_gateway/ww_llm_gateway/adapters/base.py +++ b/packages/llm_gateway/ww_llm_gateway/adapters/base.py @@ -23,6 +23,22 @@ def _is_transient_status(status: object) -> bool: ) +def provider_error_summary(exc: Exception) -> str: + """脱敏摘要:只含异常类名 + 可选状态码,绝不含原始厂商 HTTP 响应体(CR-M2-2)。 + + 厂商异常的 `str(exc)` 常内嵌完整响应体(可能含请求内容/内部细节),不应进我们 + 构造并上抛的错误消息(防泄漏,§9.3)。完整细节仍可留在服务端日志(若已记)。 + 状态码优先取 `status_code`,回退取 `code`(Gemini 部分错误用 `exc.code`)。 + """ + name = type(exc).__name__ + status = getattr(exc, "status_code", None) + if not isinstance(status, int): + status = getattr(exc, "code", None) + if isinstance(status, int): + return f"{name} (status={status})" + return name + + def is_transient_by_name( exc: Exception, names: frozenset[str], *, extra_codes: bool = False ) -> bool: diff --git a/packages/llm_gateway/ww_llm_gateway/adapters/gemini.py b/packages/llm_gateway/ww_llm_gateway/adapters/gemini.py index e20b005..237431f 100644 --- a/packages/llm_gateway/ww_llm_gateway/adapters/gemini.py +++ b/packages/llm_gateway/ww_llm_gateway/adapters/gemini.py @@ -20,6 +20,7 @@ from .base import ( ProviderUsage, StreamChunk, is_transient_by_name, + provider_error_summary, ) _TRANSIENT_NAMES = frozenset( @@ -103,7 +104,9 @@ class GeminiAdapter: ) except Exception as exc: if _is_transient(exc): - raise TransientProviderError(str(exc), provider=self.provider) from exc + raise TransientProviderError( + provider_error_summary(exc), provider=self.provider + ) from exc raise text = getattr(resp, "text", "") or "" usage = _usage_from(getattr(resp, "usage_metadata", None)) @@ -126,5 +129,7 @@ class GeminiAdapter: yield StreamChunk(usage=_usage_from(meta)) except Exception as exc: if _is_transient(exc): - raise TransientProviderError(str(exc), provider=self.provider) from exc + raise TransientProviderError( + provider_error_summary(exc), provider=self.provider + ) from exc raise diff --git a/packages/llm_gateway/ww_llm_gateway/adapters/openai_compat.py b/packages/llm_gateway/ww_llm_gateway/adapters/openai_compat.py index 3891554..3735094 100644 --- a/packages/llm_gateway/ww_llm_gateway/adapters/openai_compat.py +++ b/packages/llm_gateway/ww_llm_gateway/adapters/openai_compat.py @@ -21,6 +21,7 @@ from .base import ( ProviderUsage, StreamChunk, is_transient_by_name, + provider_error_summary, ) # OpenAI 兼容 SDK 的瞬时错误类名(按名匹配,覆盖 DeepSeek/Kimi/Qwen/GLM 等共用 SDK)。 @@ -115,7 +116,9 @@ class OpenAICompatAdapter: return await self._complete_text(req, model) except Exception as exc: if _is_transient(exc): - raise TransientProviderError(str(exc), provider=self.provider) from exc + raise TransientProviderError( + provider_error_summary(exc), provider=self.provider + ) from exc raise async def _complete_text(self, req: LlmRequest, model: str) -> ProviderResult: @@ -158,5 +161,7 @@ class OpenAICompatAdapter: yield StreamChunk(usage=_usage_from(chunk.usage)) except Exception as exc: if _is_transient(exc): - raise TransientProviderError(str(exc), provider=self.provider) from exc + raise TransientProviderError( + provider_error_summary(exc), provider=self.provider + ) from exc raise