From 1e055ffb74f95aab4b3d1cf3d9179f24d2e90b03 Mon Sep 17 00:00:00 2001 From: shentry <111497882+shentry@users.noreply.github.com> Date: Sun, 26 Jul 2026 18:03:09 +0800 Subject: [PATCH] fix: strip x-stainless-* telemetry headers from provider requests The OpenAI and Anthropic Python SDKs are both generated by Stainless, which stamps every request with x-stainless-* headers describing the SDK version, OS, architecture and Python runtime. Some OpenAI-compatible relays treat those headers as an "official SDK" signature and reject the request with HTTP 403 "Your request was blocked", while the identical request sent by curl succeeds. Remove them with an httpx request event hook installed by create_proxy_client. A hook rather than a custom AsyncHTTPTransport: httpx resolves proxies through mounts, which outrank an explicitly passed transport=, so a header-stripping transport would be silently bypassed for every user who configured a proxy. Route the remaining SDK client construction through create_proxy_client so the hook is not limited to the chat path: - anthropic: built a client only when a proxy was set, otherwise let the SDK use its own default client - openai embedding / tts: same - whisper: never built one at all, and consequently ignored the "proxy" field its config template already exposes Fixes #8531 --- .../core/provider/sources/anthropic_source.py | 28 +- .../sources/openai_embedding_source.py | 14 +- .../core/provider/sources/openai_source.py | 12 +- .../provider/sources/openai_tts_api_source.py | 16 +- .../provider/sources/whisper_api_source.py | 9 + astrbot/core/utils/network_utils.py | 71 ++++- tests/test_anthropic_kimi_code_provider.py | 38 ++- tests/unit/test_network_utils.py | 246 ++++++++++++++++++ 8 files changed, 385 insertions(+), 49 deletions(-) diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index 27cc459622..8813d11957 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -24,6 +24,7 @@ create_proxy_client, is_connection_error, log_connection_failure, + resolve_anthropic_httpx_module, ) from ..register import register_provider_adapter @@ -114,31 +115,20 @@ def _init_api_key(self, provider_config: dict) -> None: http_client=self._create_http_client(provider_config), ) - def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: + def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient: """Create an HTTP client with optional proxy and system SSL trust store. - The Anthropic SDK validates ``http_client`` with - ``isinstance(..., httpx.AsyncClient)`` against its own ``httpx`` import. - When multiple ``httpx`` installations are present on ``sys.path`` - (e.g. bundled Python + system Python), constructing the client from a - different ``httpx`` module makes that check fail. We therefore prefer - the SDK's own ``httpx`` module when available. + A client is always built, even without a proxy, so that every request + goes through the ``x-stainless-*`` stripping hook that + ``create_proxy_client`` installs. Letting the SDK fall back to its own + default client would leave those telemetry headers in place and keep + relays that reject them unusable. """ - proxy = provider_config.get("proxy", "") - if not proxy: - return None - httpx_module: Any = httpx - try: - from anthropic import _base_client as anthropic_base_client - - httpx_module = getattr(anthropic_base_client, "httpx", httpx) - except ImportError: - pass return create_proxy_client( "Anthropic", - proxy, + provider_config.get("proxy", ""), headers=self.custom_headers, - httpx_module=httpx_module, + httpx_module=resolve_anthropic_httpx_module(), ) def _apply_thinking_config(self, payloads: dict) -> None: diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index f1e5bc880e..1f11434f7f 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -1,10 +1,13 @@ import re from urllib.parse import urlparse -import httpx from openai import AsyncOpenAI from astrbot import logger +from astrbot.core.utils.network_utils import ( + create_proxy_client, + resolve_openai_httpx_module, +) from ..entities import ProviderType from ..provider import EmbeddingProvider @@ -30,10 +33,11 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: self.provider_settings = provider_settings proxy = provider_config.get("proxy", "") provider_id = provider_config.get("id", "unknown_id") - http_client = None - if proxy: - logger.info(f"[OpenAI Embedding] {provider_id} Using proxy: {proxy}") - http_client = httpx.AsyncClient(proxy=proxy) + http_client = create_proxy_client( + f"OpenAI Embedding:{provider_id}", + proxy, + httpx_module=resolve_openai_httpx_module(), + ) api_base = _normalize_api_base( provider_config.get("embedding_api_base", "https://api.openai.com/v1") ) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index f7870b7137..4c10b7a913 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -37,6 +37,7 @@ create_proxy_client, is_connection_error, log_connection_failure, + resolve_openai_httpx_module, ) from astrbot.core.utils.string_utils import normalize_and_dedupe_strings @@ -344,14 +345,9 @@ async def _fallback_to_text_only_and_retry( def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient: """创建带代理的 HTTP 客户端""" proxy = provider_config.get("proxy", "") - httpx_module: Any = httpx - try: - from openai import _base_client as openai_base_client - - httpx_module = getattr(openai_base_client, "httpx", httpx) - except ImportError: - pass - return create_proxy_client("OpenAI", proxy, httpx_module=httpx_module) + return create_proxy_client( + "OpenAI", proxy, httpx_module=resolve_openai_httpx_module() + ) def __init__(self, provider_config, provider_settings) -> None: super().__init__(provider_config, provider_settings) diff --git a/astrbot/core/provider/sources/openai_tts_api_source.py b/astrbot/core/provider/sources/openai_tts_api_source.py index 217b189251..9687d430e5 100644 --- a/astrbot/core/provider/sources/openai_tts_api_source.py +++ b/astrbot/core/provider/sources/openai_tts_api_source.py @@ -1,11 +1,13 @@ import os import uuid -import httpx from openai import NOT_GIVEN, AsyncOpenAI -from astrbot import logger from astrbot.core.utils.astrbot_path import get_astrbot_temp_path +from astrbot.core.utils.network_utils import ( + create_proxy_client, + resolve_openai_httpx_module, +) from ..entities import ProviderType from ..provider import TTSProvider @@ -31,11 +33,11 @@ def __init__( if isinstance(timeout, str): timeout = int(timeout) - proxy = provider_config.get("proxy", "") - http_client = None - if proxy: - logger.info(f"[OpenAI TTS] 使用代理: {proxy}") - http_client = httpx.AsyncClient(proxy=proxy) + http_client = create_proxy_client( + "OpenAI TTS", + provider_config.get("proxy", ""), + httpx_module=resolve_openai_httpx_module(), + ) self.client = AsyncOpenAI( api_key=self.chosen_api_key, base_url=provider_config.get("api_base"), diff --git a/astrbot/core/provider/sources/whisper_api_source.py b/astrbot/core/provider/sources/whisper_api_source.py index 60aee070b7..3da72cbc5d 100644 --- a/astrbot/core/provider/sources/whisper_api_source.py +++ b/astrbot/core/provider/sources/whisper_api_source.py @@ -1,6 +1,10 @@ from openai import NOT_GIVEN, AsyncOpenAI from astrbot.core.utils.media_utils import MediaResolver +from astrbot.core.utils.network_utils import ( + create_proxy_client, + resolve_openai_httpx_module, +) from ..entities import ProviderType from ..provider import STTProvider @@ -25,6 +29,11 @@ def __init__( api_key=self.chosen_api_key, base_url=provider_config.get("api_base"), timeout=provider_config.get("timeout", NOT_GIVEN), + http_client=create_proxy_client( + "OpenAI Whisper", + provider_config.get("proxy", ""), + httpx_module=resolve_openai_httpx_module(), + ), ) self.set_model(provider_config["model"]) diff --git a/astrbot/core/utils/network_utils.py b/astrbot/core/utils/network_utils.py index 9b7b4aef00..a2dd070c6a 100644 --- a/astrbot/core/utils/network_utils.py +++ b/astrbot/core/utils/network_utils.py @@ -10,6 +10,64 @@ _SYSTEM_SSL_CTX = build_ssl_context_with_certifi() +STAINLESS_HEADER_PREFIX = "x-stainless-" + + +async def strip_sdk_telemetry_headers(request: httpx.Request) -> None: + """Drop the ``x-stainless-*`` headers a Stainless-generated SDK injected. + + The OpenAI and Anthropic Python SDKs are both generated by Stainless, which + stamps every request with telemetry headers naming the SDK version, the + operating system, the CPU architecture and the Python runtime. Some + OpenAI-compatible relays read those headers as an "official SDK" signature + and answer with HTTP 403 ``Your request was blocked``, even though the very + same request sent by curl succeeds. The headers are informational only, so + dropping them costs nothing and keeps those endpoints reachable. + + Matching on the prefix instead of a fixed list is deliberate: the two SDKs + already disagree on the exact set (Anthropic also sends + ``x-stainless-timeout``) and Stainless keeps adding new ones. + + This is wired up as an httpx request event hook rather than as a custom + transport. httpx resolves proxies through ``mounts``, which take priority + over an explicitly passed ``transport=``, so a header-stripping transport + would be silently bypassed for everyone who configured a proxy. Event hooks + run before transport selection and therefore apply either way. + """ + for key in [ + key + for key in request.headers + if key.lower().startswith(STAINLESS_HEADER_PREFIX) + ]: + del request.headers[key] + + +def resolve_openai_httpx_module() -> Any: + """Return the ``httpx`` module the OpenAI SDK type-checks ``http_client`` against. + + The SDK validates ``http_client`` with ``isinstance(..., httpx.AsyncClient)`` + against its own ``httpx`` import. When several ``httpx`` installations share + ``sys.path`` (a bundled Python next to the system one, for example), a client + built from a different module fails that check, so prefer the SDK's module. + """ + try: + from openai import _base_client as openai_base_client + except ImportError: + return httpx + return getattr(openai_base_client, "httpx", httpx) + + +def resolve_anthropic_httpx_module() -> Any: + """Return the ``httpx`` module the Anthropic SDK type-checks ``http_client`` against. + + See :func:`resolve_openai_httpx_module` for why this matters. + """ + try: + from anthropic import _base_client as anthropic_base_client + except ImportError: + return httpx + return getattr(anthropic_base_client, "httpx", httpx) + def is_connection_error(exc: BaseException) -> bool: """Check if an exception is a connection/network related error. @@ -101,6 +159,9 @@ def create_proxy_client( with certifi as a fallback, ensuring compatibility across different environments including Windows where the system store may be incomplete. + Every request sent through the returned client has its ``x-stainless-*`` + telemetry headers removed; see :func:`strip_sdk_telemetry_headers`. + Note: The caller is responsible for closing the client when done. Consider using the client as a context manager or calling aclose() explicitly. @@ -118,9 +179,15 @@ def create_proxy_client( An httpx.AsyncClient created with the hybrid SSL context (system store + certifi); the proxy is applied only if one is provided. """ resolved_verify = _SYSTEM_SSL_CTX if verify is None else verify + event_hooks = {"request": [strip_sdk_telemetry_headers]} if proxy: logger.info(f"[{provider_label}] Using proxy: {proxy}") return httpx_module.AsyncClient( - proxy=proxy, verify=resolved_verify, headers=headers + proxy=proxy, + verify=resolved_verify, + headers=headers, + event_hooks=event_hooks, ) - return httpx_module.AsyncClient(verify=resolved_verify, headers=headers) + return httpx_module.AsyncClient( + verify=resolved_verify, headers=headers, event_hooks=event_hooks + ) diff --git a/tests/test_anthropic_kimi_code_provider.py b/tests/test_anthropic_kimi_code_provider.py index 0dc33f58ba..579ec9ac96 100644 --- a/tests/test_anthropic_kimi_code_provider.py +++ b/tests/test_anthropic_kimi_code_provider.py @@ -9,6 +9,7 @@ import astrbot.core.provider.sources.request_retry as request_retry from astrbot.core.exceptions import EmptyModelOutputError from astrbot.core.provider.entities import LLMResponse +from astrbot.core.utils import network_utils class _FakeAsyncAnthropic: @@ -40,13 +41,18 @@ def test_anthropic_provider_passes_custom_headers_via_default_headers(monkeypatc "User-Agent": "custom-agent/1.0", "X-Test-Header": "123", } - # Custom headers are forwarded via the SDK's `default_headers` parameter, - # not via a custom http_client (which is reserved for proxy configuration). + # Custom headers are forwarded via the SDK's `default_headers` parameter. assert provider.client.kwargs["default_headers"] == { "User-Agent": "custom-agent/1.0", "X-Test-Header": "123", } - assert provider.client.kwargs["http_client"] is None + # An http_client is always supplied, even without a proxy, so that the + # x-stainless-* stripping hook applies to every request. + http_client = provider.client.kwargs["http_client"] + assert isinstance(http_client, httpx.AsyncClient) + assert http_client._event_hooks["request"] == [ + network_utils.strip_sdk_telemetry_headers + ] def test_kimi_code_provider_sets_defaults_and_preserves_custom_headers(monkeypatch): @@ -92,18 +98,34 @@ def test_kimi_code_provider_restores_required_user_agent_when_blank(monkeypatch) } -def test_create_http_client_returns_none_when_no_proxy(monkeypatch): - def fail_if_called(*args, **kwargs): - raise AssertionError("create_proxy_client should not be called without a proxy") +def test_create_http_client_builds_client_without_proxy(monkeypatch): + """A client is required even without a proxy so telemetry headers get stripped.""" + captured: dict[str, object] = {} + sentinel = object() - monkeypatch.setattr(anthropic_source, "create_proxy_client", fail_if_called) + def fake_create_proxy_client( + provider_label: str, + proxy: str | None = None, + headers: dict[str, str] | None = None, + verify=None, + httpx_module=None, + ): + captured["proxy"] = proxy + captured["headers"] = headers + return sentinel + + monkeypatch.setattr( + anthropic_source, "create_proxy_client", fake_create_proxy_client + ) provider = anthropic_source.ProviderAnthropic.__new__( anthropic_source.ProviderAnthropic ) provider.custom_headers = {"X-Trace-Id": "abc"} - assert provider._create_http_client({"proxy": ""}) is None + assert provider._create_http_client({"proxy": ""}) is sentinel + assert captured["proxy"] == "" + assert captured["headers"] == {"X-Trace-Id": "abc"} def test_create_http_client_uses_anthropic_httpx_module(monkeypatch): diff --git a/tests/unit/test_network_utils.py b/tests/unit/test_network_utils.py index ea3505e387..f77d014b54 100644 --- a/tests/unit/test_network_utils.py +++ b/tests/unit/test_network_utils.py @@ -1,5 +1,6 @@ import ssl +import httpx import pytest from astrbot.core.utils import network_utils @@ -49,3 +50,248 @@ def __init__(self, **kwargs): assert len(captured_calls) == 1 assert captured_calls[0]["verify"] is custom_verify + + +# --- x-stainless-* telemetry header stripping (#8531) --------------------------- + + +CHAT_COMPLETION_BODY = { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "gpt-test", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + } + ], +} + +ANTHROPIC_MESSAGE_BODY = { + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": "claude-test", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, +} + + +def _stainless_headers(sent: dict[str, str]) -> list[str]: + return sorted(key for key in sent if key.lower().startswith("x-stainless-")) + + +def _mock_httpx_module(sent: dict[str, str], body: dict, **client_kwargs): + """A stand-in for the ``httpx`` module whose clients answer from a mock transport. + + ``create_proxy_client`` builds its client from the module it is handed, so this + lets the test exercise the real client construction (event hooks included) + without opening a socket. ``verify``/``proxy`` are dropped because a + ``MockTransport`` handles neither. + """ + + def handler(request: httpx.Request) -> httpx.Response: + sent.clear() + sent.update(dict(request.headers)) + return httpx.Response(200, json=body) + + class _Module: + @staticmethod + def AsyncClient(**kwargs): + kwargs.pop("verify", None) + kwargs.pop("proxy", None) + return httpx.AsyncClient( + transport=httpx.MockTransport(handler), **kwargs, **client_kwargs + ) + + return _Module + + +@pytest.mark.asyncio +async def test_openai_sdk_sends_stainless_headers_without_the_hook(): + """Guards the premise of #8531: the SDK really does stamp these headers on.""" + import openai + + sent: dict[str, str] = {} + module = _mock_httpx_module(sent, CHAT_COMPLETION_BODY) + + async with module.AsyncClient() as http_client: + client = openai.AsyncOpenAI( + api_key="sk-test", base_url="https://relay.test/v1", http_client=http_client + ) + await client.chat.completions.create( + model="gpt-test", messages=[{"role": "user", "content": "hi"}] + ) + + assert _stainless_headers(sent) == [ + "x-stainless-arch", + "x-stainless-async", + "x-stainless-lang", + "x-stainless-os", + "x-stainless-package-version", + "x-stainless-read-timeout", + "x-stainless-retry-count", + "x-stainless-runtime", + "x-stainless-runtime-version", + ] + + +@pytest.mark.asyncio +async def test_openai_sdk_sends_no_stainless_headers_through_create_proxy_client(): + import openai + + sent: dict[str, str] = {} + http_client = network_utils.create_proxy_client( + "OpenAI", httpx_module=_mock_httpx_module(sent, CHAT_COMPLETION_BODY) + ) + + async with http_client: + client = openai.AsyncOpenAI( + api_key="sk-test", base_url="https://relay.test/v1", http_client=http_client + ) + await client.chat.completions.create( + model="gpt-test", messages=[{"role": "user", "content": "hi"}] + ) + + assert _stainless_headers(sent) == [] + # Everything the request actually needs must survive. + assert sent["authorization"] == "Bearer sk-test" + assert sent["content-type"] == "application/json" + + +@pytest.mark.asyncio +async def test_anthropic_sdk_sends_no_stainless_headers_through_create_proxy_client(): + """The Anthropic SDK is Stainless-generated too, and adds x-stainless-timeout.""" + import anthropic + + sent: dict[str, str] = {} + http_client = network_utils.create_proxy_client( + "Anthropic", httpx_module=_mock_httpx_module(sent, ANTHROPIC_MESSAGE_BODY) + ) + + async with http_client: + client = anthropic.AsyncAnthropic( + api_key="sk-test", base_url="https://relay.test", http_client=http_client + ) + await client.messages.create( + model="claude-test", + max_tokens=16, + messages=[{"role": "user", "content": "hi"}], + ) + + assert _stainless_headers(sent) == [] + assert sent["x-api-key"] == "sk-test" + + +@pytest.mark.asyncio +async def test_stripping_survives_the_proxy_transport(): + """A proxy makes httpx route through ``mounts``, which outrank ``transport=``. + + This is why the stripping is an event hook and not an ``AsyncHTTPTransport`` + subclass: a custom transport would be bypassed here, silently leaving the + headers on for every user who configured a proxy. + """ + sent: dict[str, str] = {} + reached_proxy = False + + def proxy_handler(request: httpx.Request) -> httpx.Response: + nonlocal reached_proxy + reached_proxy = True + sent.clear() + sent.update(dict(request.headers)) + return httpx.Response(200, json=CHAT_COMPLETION_BODY) + + http_client = network_utils.create_proxy_client( + "OpenAI", + proxy="http://127.0.0.1:7890", + httpx_module=_mock_httpx_module( + sent, + CHAT_COMPLETION_BODY, + mounts={"all://": httpx.MockTransport(proxy_handler)}, + ), + ) + + async with http_client: + request = http_client.build_request( + "POST", "https://relay.test/v1/chat/completions", json={} + ) + request.headers["x-stainless-lang"] = "python" + request.headers["x-stainless-os"] = "Linux" + response = await http_client.send(request) + + assert response.status_code == 200 + # The mounted (proxy) transport received the request, and it was still cleaned. + assert reached_proxy + assert _stainless_headers(sent) == [] + + +@pytest.mark.asyncio +async def test_strip_sdk_telemetry_headers_leaves_everything_else_alone(): + request = httpx.Request( + "POST", + "https://relay.test/v1/chat/completions", + headers={ + "Authorization": "Bearer sk-test", + "X-Stainless-Lang": "python", + "x-stainless-retry-count": "0", + "X-Custom-Header": "keep-me", + "x-stainless": "no-trailing-dash-stays", + }, + ) + + await network_utils.strip_sdk_telemetry_headers(request) + + assert _stainless_headers(dict(request.headers)) == [] + assert request.headers["authorization"] == "Bearer sk-test" + assert request.headers["x-custom-header"] == "keep-me" + # Only the ``x-stainless-`` prefix is telemetry; a bare ``x-stainless`` is not. + assert request.headers["x-stainless"] == "no-trailing-dash-stays" + + +def test_create_proxy_client_registers_the_hook_with_and_without_proxy( + monkeypatch: pytest.MonkeyPatch, +): + captured_calls: list[dict] = [] + + class _FakeAsyncClient: + def __init__(self, **kwargs): + captured_calls.append(kwargs) + + monkeypatch.setattr(network_utils.httpx, "AsyncClient", _FakeAsyncClient) + + network_utils.create_proxy_client("OpenAI") + network_utils.create_proxy_client("OpenAI", proxy="http://127.0.0.1:7890") + + for call in captured_calls: + assert call["event_hooks"]["request"] == [ + network_utils.strip_sdk_telemetry_headers + ] + + +def test_resolve_sdk_httpx_modules_prefer_the_sdk_import(): + from anthropic import _base_client as anthropic_base_client + from openai import _base_client as openai_base_client + + assert network_utils.resolve_openai_httpx_module() is openai_base_client.httpx + assert network_utils.resolve_anthropic_httpx_module() is anthropic_base_client.httpx + + +def test_resolve_sdk_httpx_modules_fall_back_to_global_httpx( + monkeypatch: pytest.MonkeyPatch, +): + import builtins + + real_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name in {"openai", "anthropic"} and fromlist: + raise ImportError(f"missing {name}._base_client") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + assert network_utils.resolve_openai_httpx_module() is httpx + assert network_utils.resolve_anthropic_httpx_module() is httpx