Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 9 additions & 19 deletions astrbot/core/provider/sources/anthropic_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
create_proxy_client,
is_connection_error,
log_connection_failure,
resolve_anthropic_httpx_module,
)

from ..register import register_provider_adapter
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 9 additions & 5 deletions astrbot/core/provider/sources/openai_embedding_source.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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")
)
Expand Down
12 changes: 4 additions & 8 deletions astrbot/core/provider/sources/openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
16 changes: 9 additions & 7 deletions astrbot/core/provider/sources/openai_tts_api_source.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"),
Expand Down
9 changes: 9 additions & 0 deletions astrbot/core/provider/sources/whisper_api_source.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"])
Expand Down
71 changes: 69 additions & 2 deletions astrbot/core/utils/network_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -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
)
38 changes: 30 additions & 8 deletions tests/test_anthropic_kimi_code_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading