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