-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
feat: implement user agent handling across providers and normalize headers #8445
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -18,11 +18,13 @@ | |||||||||||||||||||||||
| from astrbot import logger | ||||||||||||||||||||||||
| from astrbot.api.provider import Provider | ||||||||||||||||||||||||
| from astrbot.core.agent.message import AudioURLPart, ContentPart, ImageURLPart, TextPart | ||||||||||||||||||||||||
| from astrbot.core.config.default import ASTRBOT_USER_AGENT | ||||||||||||||||||||||||
| from astrbot.core.exceptions import EmptyModelOutputError | ||||||||||||||||||||||||
| from astrbot.core.message.message_event_result import MessageChain | ||||||||||||||||||||||||
| from astrbot.core.provider.entities import LLMResponse, TokenUsage | ||||||||||||||||||||||||
| from astrbot.core.provider.func_tool_manager import ToolSet | ||||||||||||||||||||||||
| from astrbot.core.utils.astrbot_path import get_astrbot_temp_path | ||||||||||||||||||||||||
| from astrbot.core.utils.http_headers import apply_default_headers, normalize_headers | ||||||||||||||||||||||||
| from astrbot.core.utils.io import download_file, download_image_by_url | ||||||||||||||||||||||||
| from astrbot.core.utils.media_utils import ensure_wav | ||||||||||||||||||||||||
| from astrbot.core.utils.network_utils import is_connection_error, log_connection_failure | ||||||||||||||||||||||||
|
|
@@ -76,24 +78,49 @@ def __init__( | |||||||||||||||||||||||
| if self.api_base and self.api_base.endswith("/"): | ||||||||||||||||||||||||
| self.api_base = self.api_base[:-1] | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| self.custom_headers = self._resolve_custom_headers(provider_config) | ||||||||||||||||||||||||
| self._http_client: httpx.AsyncClient | None = None | ||||||||||||||||||||||||
| self._stale_http_clients: list[httpx.AsyncClient] = [] | ||||||||||||||||||||||||
| self._init_client() | ||||||||||||||||||||||||
| self.set_model(provider_config.get("model", "unknown")) | ||||||||||||||||||||||||
| self._init_safety_settings() | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @staticmethod | ||||||||||||||||||||||||
| def _resolve_custom_headers(provider_config: dict) -> dict[str, str]: | ||||||||||||||||||||||||
| headers = apply_default_headers( | ||||||||||||||||||||||||
| normalize_headers(provider_config.get("custom_headers", {})), | ||||||||||||||||||||||||
| {"user-agent": ASTRBOT_USER_AGENT}, | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||
| "user-agent" if key.lower() == "user-agent" else key: value | ||||||||||||||||||||||||
| for key, value in headers.items() | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @staticmethod | ||||||||||||||||||||||||
| def _set_gemini_user_agent(client: object, user_agent: str) -> None: | ||||||||||||||||||||||||
| api_client = getattr(client, "_api_client", None) | ||||||||||||||||||||||||
| http_options = getattr(api_client, "_http_options", None) | ||||||||||||||||||||||||
| if http_options is None or http_options.headers is None: | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
|
Comment on lines
+100
to
+104
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To prevent potential
Suggested change
|
||||||||||||||||||||||||
| for key in list(http_options.headers): | ||||||||||||||||||||||||
| if key.lower() == "user-agent": | ||||||||||||||||||||||||
| http_options.headers.pop(key) | ||||||||||||||||||||||||
| http_options.headers["user-agent"] = user_agent | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def _init_client(self) -> None: | ||||||||||||||||||||||||
| """初始化Gemini客户端""" | ||||||||||||||||||||||||
| proxy = self.provider_config.get("proxy", "") | ||||||||||||||||||||||||
| http_options = types.HttpOptions( | ||||||||||||||||||||||||
| base_url=self.api_base, | ||||||||||||||||||||||||
| headers=dict(self.custom_headers), | ||||||||||||||||||||||||
| timeout=self.timeout * 1000, # 毫秒 | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| # 强制使用 httpx 作为异步 HTTP 后端,避免 aiohttp 响应类型兼容问题 (#7564) | ||||||||||||||||||||||||
| # httpx.AsyncClient 的 timeout 单位为秒(与 HttpOptions 的毫秒不同) | ||||||||||||||||||||||||
| async_client_kwargs: dict = { | ||||||||||||||||||||||||
| "base_url": self.api_base, | ||||||||||||||||||||||||
| "headers": dict(self.custom_headers), | ||||||||||||||||||||||||
| "timeout": self.timeout, | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| if proxy: | ||||||||||||||||||||||||
|
|
@@ -112,10 +139,15 @@ def _init_client(self) -> None: | |||||||||||||||||||||||
| self._http_client = httpx.AsyncClient(**async_client_kwargs) | ||||||||||||||||||||||||
| http_options.httpx_async_client = self._http_client | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| self.client = genai.Client( | ||||||||||||||||||||||||
| genai_client = genai.Client( | ||||||||||||||||||||||||
| api_key=self.chosen_api_key, | ||||||||||||||||||||||||
| http_options=http_options, | ||||||||||||||||||||||||
| ).aio | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
| self._set_gemini_user_agent( | ||||||||||||||||||||||||
| genai_client, | ||||||||||||||||||||||||
| self.custom_headers["user-agent"], | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
| self.client = genai_client.aio | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def _init_safety_settings(self) -> None: | ||||||||||||||||||||||||
| """初始化安全设置""" | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| from collections.abc import Mapping | ||
|
|
||
|
|
||
| def normalize_headers(headers: object) -> dict[str, str]: | ||
| if not isinstance(headers, dict): | ||
| return {} | ||
| return {str(key): str(value) for key, value in headers.items()} | ||
|
Comment on lines
+4
to
+7
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In We should defensively convert def normalize_headers(headers: object) -> dict[str, str]:
if not isinstance(headers, dict):
return {}
return {
str(key): str(value) if value is not None else ""
for key, value in headers.items()
} |
||
|
|
||
|
|
||
| def apply_default_headers( | ||
| headers: dict[str, str], | ||
| default_headers: Mapping[str, str], | ||
| ) -> dict[str, str]: | ||
| merged_headers = dict(headers) | ||
| for default_name, default_value in default_headers.items(): | ||
| existing_name = next( | ||
| ( | ||
| header_name | ||
| for header_name in merged_headers | ||
| if header_name.lower() == default_name.lower() | ||
| ), | ||
| None, | ||
| ) | ||
| if existing_name is None: | ||
| merged_headers[default_name] = default_value | ||
| continue | ||
| if merged_headers[existing_name].strip(): | ||
| continue | ||
| merged_headers.pop(existing_name) | ||
| merged_headers[default_name] = default_value | ||
| return merged_headers | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
|
|
||
| import astrbot.core.provider.sources.anthropic_source as anthropic_source | ||
| import astrbot.core.provider.sources.kimi_code_source as kimi_code_source | ||
| from astrbot.core.config.default import ASTRBOT_USER_AGENT | ||
| from astrbot.core.exceptions import EmptyModelOutputError | ||
| from astrbot.core.provider.entities import LLMResponse | ||
|
|
||
|
|
@@ -16,6 +17,25 @@ async def close(self): | |
| return None | ||
|
|
||
|
|
||
| def test_anthropic_provider_uses_astrbot_default_user_agent(monkeypatch): | ||
| monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic) | ||
|
|
||
| provider = anthropic_source.ProviderAnthropic( | ||
| provider_config={ | ||
| "id": "anthropic-test", | ||
| "type": "anthropic_chat_completion", | ||
| "model": "claude-test", | ||
| "key": ["test-key"], | ||
| }, | ||
|
Comment on lines
+20
to
+29
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (testing): Extend Anthropics tests to cover custom headers with/without user agent and type normalization. Consider adding a couple of nearby tests to fully cover the new header behavior:
This will better demonstrate that existing Anthropics header behavior is preserved with the new shared helpers. Suggested implementation: assert provider.client.kwargs["default_headers"] == {
"User-Agent": ASTRBOT_USER_AGENT,
}
def test_anthropic_provider_preserves_custom_user_agent_and_headers(monkeypatch):
monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic)
custom_headers = {
"User-Agent": "MyApp/1.0 (test)",
"X-Extra-Header": "extra-value",
}
provider = anthropic_source.ProviderAnthropic(
provider_config={
"id": "anthropic-test",
"type": "anthropic_chat_completion",
"model": "claude-test",
"key": ["test-key"],
"custom_headers": custom_headers,
},
provider_settings={},
)
# Custom non-empty User-Agent should be preserved, and the default ASTRBOT_USER_AGENT
# should not be added on top.
assert provider.custom_headers == custom_headers
assert provider.client.kwargs["default_headers"] == custom_headers
def test_anthropic_provider_normalizes_custom_header_values_to_strings(monkeypatch):
monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic)
custom_headers = {
"User-Agent": "MyApp/2.0 (test)",
"X-Int-Header": 123,
"X-Bool-Header": True,
}
provider = anthropic_source.ProviderAnthropic(
provider_config={
"id": "anthropic-test",
"type": "anthropic_chat_completion",
"model": "claude-test",
"key": ["test-key"],
"custom_headers": custom_headers,
},
provider_settings={},
)
expected_headers = {
"User-Agent": "MyApp/2.0 (test)",
"X-Int-Header": "123",
"X-Bool-Header": "True",
}
assert provider.custom_headers == expected_headers
assert provider.client.kwargs["default_headers"] == expected_headers
def test_anthropic_provider_passes_custom_headers_via_default_headers(monkeypatch):
monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic)If |
||
| provider_settings={}, | ||
| ) | ||
|
|
||
| assert provider.custom_headers == {"User-Agent": ASTRBOT_USER_AGENT} | ||
| assert provider.client.kwargs["default_headers"] == { | ||
| "User-Agent": ASTRBOT_USER_AGENT, | ||
| } | ||
|
|
||
|
|
||
| def test_anthropic_provider_passes_custom_headers_via_default_headers(monkeypatch): | ||
| monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic) | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (complexity): Consider simplifying header handling by computing normalized headers once and reusing them everywhere instead of mutating Gemini client internals after creation.
You can simplify this without losing any functionality by:
HttpOptionsandhttpx.AsyncClient.That removes indirection and reflection and keeps the header behavior centralized.
1. Simplify
_resolve_custom_headersNormalize keys (including
user-agent) and apply defaults in one pass:This removes the extra comprehension after
apply_default_headers.2. Remove
_set_gemini_user_agentand avoid private internalsYou already pass headers into
HttpOptions, which are used by the Gemini client. If you ensureself.custom_headers["user-agent"]is always the final value (as above), you don’t need to mutatehttp_options.headersvia private attributes:Then simplify
_init_clientto rely only on the precomputed, final headers:This keeps:
self.custom_headers).httpx.AsyncClientandHttpOptions._api_client/_http_options.