From f2f000e90d6c02d7f3a9047c1e93db81a65cfa15 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:52:03 +0200 Subject: [PATCH 01/20] fix: standardize provider user agents with the AstrBot version (#10082) Wire build_provider_headers into the fork provider sources, including openai_chat_completions_source and openai_responses_source. Keep Edge TTS as a lazy import. Tests live under tests/unit/provider/. Upstream-Commit: d524b8708a2878a4214104a42c0915f732db8fad Upstream-Author: Soulter <37870767+Soulter@users.noreply.github.com> Upstream-PR: AstrBotDevs#10082 Sync-Disposition: adapt Fork-Adaptation: Map openai_source.py onto openai_chat_completions_source.py and openai_responses_source.py; keep Edge TTS importlib loading; omit missing dashscope embedding and TEI sources; tests under tests/unit/provider/. Tested: uv run pytest tests/unit/provider/test_provider_user_agent.py tests/unit/provider/test_anthropic_kimi_code_provider.py tests/unit/provider/test_openai_chat_completions_source.py tests/unit/provider/test_openai_responses_source.py tests/unit/provider/test_openai_embedding_source.py tests/unit/provider/test_opencode_go_providers.py tests/unit/provider/test_ssycloud_source.py tests/unit/provider/test_mirarouter_source.py -q --test-profile blocking AI-Generated: true Generated-At: 2026-09-16T06:51:43Z --- astrbot/core/config/default.py | 6 +- astrbot/core/provider/headers.py | 24 +++ astrbot/core/provider/provider.py | 4 + .../core/provider/sources/anthropic_source.py | 9 +- .../core/provider/sources/azure_tts_source.py | 14 +- .../provider/sources/bailian_rerank_source.py | 3 +- .../core/provider/sources/dashscope_tts.py | 8 +- .../core/provider/sources/edge_tts_source.py | 7 + .../provider/sources/elevenlabs_tts_source.py | 1 + .../sources/fishaudio_tts_api_source.py | 1 + .../sources/gemini_embedding_source.py | 6 +- .../core/provider/sources/gemini_source.py | 3 + .../provider/sources/gemini_tts_source.py | 6 +- .../provider/sources/gsv_selfhosted_source.py | 1 + .../core/provider/sources/gsvi_tts_source.py | 2 +- .../core/provider/sources/kimi_code_source.py | 3 +- .../provider/sources/mimo_stt_api_source.py | 2 +- .../provider/sources/mimo_tts_api_source.py | 2 +- .../sources/minimax_token_plan_source.py | 2 +- .../sources/minimax_tts_api_source.py | 2 +- .../sources/nvidia_embedding_source.py | 2 +- .../provider/sources/nvidia_rerank_source.py | 3 +- .../sources/ollama_embedding_source.py | 2 +- .../sources/openai_chat_completions_source.py | 8 +- .../sources/openai_embedding_source.py | 1 + .../sources/openai_responses_source.py | 7 +- .../provider/sources/openai_tts_api_source.py | 1 + .../provider/sources/vllm_rerank_source.py | 2 +- .../core/provider/sources/volcengine_tts.py | 2 +- .../provider/sources/whisper_api_source.py | 1 + .../sources/xinference_rerank_source.py | 1 + .../sources/xinference_stt_provider.py | 1 + .../unit/provider/test_provider_user_agent.py | 175 ++++++++++++++++++ 33 files changed, 273 insertions(+), 39 deletions(-) create mode 100644 astrbot/core/provider/headers.py create mode 100644 tests/unit/provider/test_provider_user_agent.py diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index a458c52d86..ebc2e5bcad 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1354,7 +1354,7 @@ "timeout": 120, "proxy_mode": "inherit", "proxy_url": "", - "custom_headers": {"User-Agent": "claude-code/0.1.0"}, + "custom_headers": {}, "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, }, "OpenCode Go Chat Completions": { @@ -1446,7 +1446,7 @@ "timeout": 120, "proxy_mode": "inherit", "proxy_url": "", - "custom_headers": {"User-Agent": "claude-code/0.1.0"}, + "custom_headers": {}, "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, }, "Xiaomi": { @@ -1473,7 +1473,7 @@ "timeout": 120, "proxy_mode": "inherit", "proxy_url": "", - "custom_headers": {"User-Agent": "claude-code/0.1.0"}, + "custom_headers": {}, "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, }, "xAI": { diff --git a/astrbot/core/provider/headers.py b/astrbot/core/provider/headers.py new file mode 100644 index 0000000000..b40d3f94ba --- /dev/null +++ b/astrbot/core/provider/headers.py @@ -0,0 +1,24 @@ +from astrbot import __version__ + +DEFAULT_USER_AGENT = f"astrbot/{__version__}" + + +def build_provider_headers(custom_headers: object = None) -> dict[str, str]: + """Build provider headers with an overridable AstrBot user agent. + + Args: + custom_headers: Optional header mapping from provider configuration. + + Returns: + A new header dictionary with string values and one User-Agent header. + """ + headers = {"User-Agent": DEFAULT_USER_AGENT} + if isinstance(custom_headers, dict): + for name, value in custom_headers.items(): + name, value = str(name), str(value) + if name.lower() == "user-agent": + if value.strip(): + headers["User-Agent"] = value + else: + headers[name] = value + return headers diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index cffcd1ed6b..5f12812524 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -16,6 +16,7 @@ ProviderMeta, RerankResult, ) +from astrbot.core.provider.headers import build_provider_headers from astrbot.core.utils.astrbot_path import get_astrbot_path from astrbot.core.utils.error_redaction import safe_error @@ -36,6 +37,9 @@ def __init__(self, provider_config: dict) -> None: super().__init__() self.model_name = "" self.provider_config = provider_config + self.request_headers = build_provider_headers( + provider_config.get("custom_headers") + ) def set_model(self, model_name: str) -> None: """Set the current model name""" diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index b96640b1d3..f0da819211 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -25,6 +25,7 @@ log_connection_failure, ) +from ..headers import build_provider_headers from ..register import register_provider_adapter from .request_extra_headers import extra_headers_kwargs from .request_retry import retry_provider_request, retry_provider_request_context @@ -70,13 +71,15 @@ def _resolve_custom_headers( provider_config: dict, *, required_headers: dict[str, str] | None = None, - ) -> dict[str, str] | None: - merged_headers = cls._normalize_custom_headers(provider_config) or {} + ) -> dict[str, str]: + merged_headers = build_provider_headers( + cls._normalize_custom_headers(provider_config) + ) if required_headers: for header_name, header_value in required_headers.items(): if not merged_headers.get(header_name, "").strip(): merged_headers[header_name] = header_value - return merged_headers or None + return merged_headers def __init__( self, diff --git a/astrbot/core/provider/sources/azure_tts_source.py b/astrbot/core/provider/sources/azure_tts_source.py index 1c1f9d2e67..7407daddc2 100644 --- a/astrbot/core/provider/sources/azure_tts_source.py +++ b/astrbot/core/provider/sources/azure_tts_source.py @@ -11,7 +11,7 @@ from httpx import AsyncClient, Timeout from astrbot import logger -from astrbot.core.config.default import VERSION +from astrbot.core.provider.headers import build_provider_headers from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.error_redaction import safe_error @@ -35,6 +35,7 @@ def _remove_incomplete_audio(file_path: Path) -> None: class OTTSProvider: def __init__(self, config: dict) -> None: + self.request_headers = build_provider_headers(config.get("custom_headers")) self.skey = config["OTTS_SKEY"] self.api_url = config["OTTS_URL"] self.auth_time_url = config["OTTS_AUTH_TIME"] @@ -60,7 +61,9 @@ async def __aenter__(self): if self._client is not None: await self.__aexit__(None, None, None) self._client = AsyncClient( - timeout=self.timeout, proxy=self.proxy if self.proxy else None + headers=self.request_headers, + timeout=self.timeout, + proxy=self.proxy if self.proxy else None, ) return self @@ -121,7 +124,7 @@ async def get_audio(self, text: str, voice_params: dict) -> str: "volume": voice_params["volume"], }, headers={ - "User-Agent": f"AstrBot/{VERSION}", + **self.request_headers, "UAK": "AstrBot/AzureTTS", }, ) @@ -207,7 +210,7 @@ async def __aenter__(self): await self.__aexit__(None, None, None) self._client = AsyncClient( headers={ - "User-Agent": f"AstrBot/{VERSION}", + **self.request_headers, "Content-Type": "application/ssml+xml", "X-Microsoft-OutputFormat": "riff-48khz-16bit-mono-pcm", }, @@ -274,7 +277,7 @@ async def get_audio(self, text: str) -> str: content=ssml, headers={ "Authorization": f"Bearer {self.token}", - "User-Agent": f"AstrBot/{VERSION}", + **self.request_headers, }, ) response.raise_for_status() @@ -323,6 +326,7 @@ def _parse_provider( otts_config = json.loads(json_str) if not isinstance(otts_config, dict): raise ValueError("OTTS配置必须是JSON对象") + otts_config.setdefault("custom_headers", config.get("custom_headers")) required = {"OTTS_SKEY", "OTTS_URL", "OTTS_AUTH_TIME"} if missing := required - otts_config.keys(): raise ValueError(f"缺少OTTS参数: {', '.join(sorted(missing))}") diff --git a/astrbot/core/provider/sources/bailian_rerank_source.py b/astrbot/core/provider/sources/bailian_rerank_source.py index 39cc4bb251..80fc1bac0f 100644 --- a/astrbot/core/provider/sources/bailian_rerank_source.py +++ b/astrbot/core/provider/sources/bailian_rerank_source.py @@ -75,7 +75,8 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: } self.client = aiohttp.ClientSession( - headers=headers, timeout=aiohttp.ClientTimeout(total=self.timeout) + headers={**self.request_headers, **headers}, + timeout=aiohttp.ClientTimeout(total=self.timeout), ) # 设置模型名称 diff --git a/astrbot/core/provider/sources/dashscope_tts.py b/astrbot/core/provider/sources/dashscope_tts.py index 3a4736bfe5..8b0ca601fe 100644 --- a/astrbot/core/provider/sources/dashscope_tts.py +++ b/astrbot/core/provider/sources/dashscope_tts.py @@ -83,6 +83,7 @@ def _call_qwen_tts(self, model: str, text: str): kwargs = { "model": model, + "headers": self.request_headers.copy(), "messages": None, "api_key": self.chosen_api_key, "voice": self.voice or "Cherry", @@ -133,7 +134,9 @@ async def _download_audio_from_url(self, url: str) -> bytes | None: timeout = max(self.timeout_ms / 1000, 1) if self.timeout_ms else 20 try: async with ( - aiohttp.ClientSession() as session, + aiohttp.ClientSession( + headers={"User-Agent": self.request_headers["User-Agent"]} + ) as session, session.get( url, timeout=aiohttp.ClientTimeout(total=timeout), @@ -154,6 +157,9 @@ async def _synthesize_with_cosyvoice( text: str, ) -> tuple[bytes | None, str]: synthesizer = SpeechSynthesizer( + headers={ + name.lower(): value for name, value in self.request_headers.items() + }, model=model, voice=self.voice, format=AudioFormat.WAV_24000HZ_MONO_16BIT, diff --git a/astrbot/core/provider/sources/edge_tts_source.py b/astrbot/core/provider/sources/edge_tts_source.py index 15fda1641c..e5534077b8 100644 --- a/astrbot/core/provider/sources/edge_tts_source.py +++ b/astrbot/core/provider/sources/edge_tts_source.py @@ -175,6 +175,13 @@ async def get_audio(self, text: str) -> str: if self.pitch: kwargs["pitch"] = self.pitch + from astrbot.core.provider.headers import DEFAULT_USER_AGENT + + try: + edge_tts_constants = importlib.import_module("edge_tts.constants") + edge_tts_constants.WSS_HEADERS["User-Agent"] = DEFAULT_USER_AGENT + except ImportError: + pass communicate = edge_tts_module.Communicate(proxy=self.proxy, **kwargs) await asyncio.wait_for( communicate.save(str(mp3_path)), timeout=self.timeout diff --git a/astrbot/core/provider/sources/elevenlabs_tts_source.py b/astrbot/core/provider/sources/elevenlabs_tts_source.py index c7be820ea9..2844514b10 100644 --- a/astrbot/core/provider/sources/elevenlabs_tts_source.py +++ b/astrbot/core/provider/sources/elevenlabs_tts_source.py @@ -125,6 +125,7 @@ def __init__( client = create_proxy_client( "ElevenLabs TTS", provider_config, + headers=self.request_headers, ) client.timeout = timeout self.client: httpx.AsyncClient | None = client diff --git a/astrbot/core/provider/sources/fishaudio_tts_api_source.py b/astrbot/core/provider/sources/fishaudio_tts_api_source.py index 7c38fad3f4..d306bc8819 100644 --- a/astrbot/core/provider/sources/fishaudio_tts_api_source.py +++ b/astrbot/core/provider/sources/fishaudio_tts_api_source.py @@ -70,6 +70,7 @@ def __init__( self._route = resolve_proxy_route(local_config=provider_config) self.proxy = self._route.proxy_url or "" self.headers = { + **self.request_headers, "Authorization": f"Bearer {self.chosen_api_key}", } self.set_model(provider_config.get("model") or "s2-pro") diff --git a/astrbot/core/provider/sources/gemini_embedding_source.py b/astrbot/core/provider/sources/gemini_embedding_source.py index f07f88ad94..6c1e35e682 100644 --- a/astrbot/core/provider/sources/gemini_embedding_source.py +++ b/astrbot/core/provider/sources/gemini_embedding_source.py @@ -30,7 +30,9 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: api_base: str = provider_config["embedding_api_base"] timeout: int = int(provider_config.get("timeout", 20)) - http_options = types.HttpOptions(timeout=timeout * 1000) + http_options = types.HttpOptions( + timeout=timeout * 1000, headers=self.request_headers + ) if api_base: api_base = api_base.removesuffix("/") http_options.base_url = api_base @@ -43,6 +45,8 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: http_options.async_client_args = httpx_client_kwargs(route) self.client = genai.Client(api_key=api_key, http_options=http_options).aio + # The SDK adds its own lower-case UA alongside our explicit header. + self.client._api_client._http_options.headers.pop("user-agent", None) self.model = provider_config.get( "embedding_model", diff --git a/astrbot/core/provider/sources/gemini_source.py b/astrbot/core/provider/sources/gemini_source.py index 337fc832a6..eeed7c0206 100644 --- a/astrbot/core/provider/sources/gemini_source.py +++ b/astrbot/core/provider/sources/gemini_source.py @@ -84,6 +84,7 @@ def __init__( def _init_client(self) -> None: """初始化Gemini客户端""" http_options = types.HttpOptions( + headers=self.request_headers, base_url=self.api_base, timeout=self.timeout * 1000, # 毫秒 ) @@ -112,6 +113,8 @@ def _init_client(self) -> None: api_key=self.chosen_api_key, http_options=http_options, ).aio + # The SDK adds its own lower-case UA alongside our explicit header. + self.client._api_client._http_options.headers.pop("user-agent", None) def _init_safety_settings(self) -> None: """初始化安全设置""" diff --git a/astrbot/core/provider/sources/gemini_tts_source.py b/astrbot/core/provider/sources/gemini_tts_source.py index 031ae94bd0..a634867a78 100644 --- a/astrbot/core/provider/sources/gemini_tts_source.py +++ b/astrbot/core/provider/sources/gemini_tts_source.py @@ -32,7 +32,9 @@ def __init__( api_key: str = provider_config.get("gemini_tts_api_key", "") api_base: str | None = provider_config.get("gemini_tts_api_base") timeout: int = int(provider_config.get("gemini_tts_timeout", 20)) - http_options = types.HttpOptions(timeout=timeout * 1000) + http_options = types.HttpOptions( + timeout=timeout * 1000, headers=self.request_headers + ) if api_base: api_base = api_base.removesuffix("/") @@ -46,6 +48,8 @@ def __init__( http_options.async_client_args = httpx_client_kwargs(route) self.client = genai.Client(api_key=api_key, http_options=http_options).aio + # The SDK adds its own lower-case UA alongside our explicit header. + self.client._api_client._http_options.headers.pop("user-agent", None) self.model: str = provider_config.get( "gemini_tts_model", "gemini-2.5-flash-preview-tts", diff --git a/astrbot/core/provider/sources/gsv_selfhosted_source.py b/astrbot/core/provider/sources/gsv_selfhosted_source.py index a8930cfb18..d7eb4cdba9 100644 --- a/astrbot/core/provider/sources/gsv_selfhosted_source.py +++ b/astrbot/core/provider/sources/gsv_selfhosted_source.py @@ -47,6 +47,7 @@ def __init__( async def initialize(self) -> None: """异步初始化:在 ProviderManager 中被调用""" session = aiohttp.ClientSession( + headers=self.request_headers, timeout=aiohttp.ClientTimeout(total=self.timeout), ) self._session = session diff --git a/astrbot/core/provider/sources/gsvi_tts_source.py b/astrbot/core/provider/sources/gsvi_tts_source.py index 7c3ae286f0..3d5c93d6ea 100644 --- a/astrbot/core/provider/sources/gsvi_tts_source.py +++ b/astrbot/core/provider/sources/gsvi_tts_source.py @@ -57,7 +57,7 @@ async def get_audio(self, text: str) -> str: completed = False try: path.parent.mkdir(parents=True, exist_ok=True) - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(headers=self.request_headers) as session: async with session.post(url, json=data, headers=headers) as response: if response.status != 200: logger.error( diff --git a/astrbot/core/provider/sources/kimi_code_source.py b/astrbot/core/provider/sources/kimi_code_source.py index 02c200271f..bac18ce190 100644 --- a/astrbot/core/provider/sources/kimi_code_source.py +++ b/astrbot/core/provider/sources/kimi_code_source.py @@ -1,9 +1,10 @@ +from ..headers import DEFAULT_USER_AGENT from ..register import register_provider_adapter from .anthropic_source import ProviderAnthropic KIMI_CODE_API_BASE = "https://api.kimi.com/coding" KIMI_CODE_DEFAULT_MODEL = "kimi-for-coding" -KIMI_CODE_USER_AGENT = "claude-code/0.1.0" +KIMI_CODE_USER_AGENT = DEFAULT_USER_AGENT @register_provider_adapter( diff --git a/astrbot/core/provider/sources/mimo_stt_api_source.py b/astrbot/core/provider/sources/mimo_stt_api_source.py index d5e94d5de6..22e8755bc2 100644 --- a/astrbot/core/provider/sources/mimo_stt_api_source.py +++ b/astrbot/core/provider/sources/mimo_stt_api_source.py @@ -89,7 +89,7 @@ async def get_text(self, audio_url: str) -> str: try: response = await self.client.post( build_api_url(self.api_base), - headers=build_headers(self.chosen_api_key), + headers={**build_headers(self.chosen_api_key), **self.request_headers}, json=payload, ) try: diff --git a/astrbot/core/provider/sources/mimo_tts_api_source.py b/astrbot/core/provider/sources/mimo_tts_api_source.py index d9bb5dac43..c42d627c76 100644 --- a/astrbot/core/provider/sources/mimo_tts_api_source.py +++ b/astrbot/core/provider/sources/mimo_tts_api_source.py @@ -112,7 +112,7 @@ async def get_audio(self, text: str) -> str: try: response = await self.client.post( build_api_url(self.api_base), - headers=build_headers(self.chosen_api_key), + headers={**build_headers(self.chosen_api_key), **self.request_headers}, json=self._build_payload(text), ) diff --git a/astrbot/core/provider/sources/minimax_token_plan_source.py b/astrbot/core/provider/sources/minimax_token_plan_source.py index 8d86c77b73..b16c7e4dc2 100644 --- a/astrbot/core/provider/sources/minimax_token_plan_source.py +++ b/astrbot/core/provider/sources/minimax_token_plan_source.py @@ -47,7 +47,7 @@ async def get_models(self) -> list[str]: logger.warning("No API key configured for MiniMax Token Plan.") return [] try: - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(headers=self.request_headers) as client: resp = await client.get( "https://api.minimaxi.com/v1/models", headers={"Authorization": f"Bearer {key}"}, diff --git a/astrbot/core/provider/sources/minimax_tts_api_source.py b/astrbot/core/provider/sources/minimax_tts_api_source.py index 746446ce17..c563226b82 100644 --- a/astrbot/core/provider/sources/minimax_tts_api_source.py +++ b/astrbot/core/provider/sources/minimax_tts_api_source.py @@ -104,7 +104,7 @@ async def _call_tts_stream(self, text: str) -> AsyncIterator[str]: """进行流式请求""" try: async with ( - aiohttp.ClientSession() as session, + aiohttp.ClientSession(headers=self.request_headers) as session, session.post( self.concat_base_url, headers=self.headers, diff --git a/astrbot/core/provider/sources/nvidia_embedding_source.py b/astrbot/core/provider/sources/nvidia_embedding_source.py index d5c47fcc43..cbf8d47fcb 100644 --- a/astrbot/core/provider/sources/nvidia_embedding_source.py +++ b/astrbot/core/provider/sources/nvidia_embedding_source.py @@ -55,7 +55,7 @@ async def _get_client(self): } timeout = aiohttp.ClientTimeout(total=self.timeout) self.client = aiohttp.ClientSession( - headers=headers, + headers={**self.request_headers, **headers}, timeout=timeout, trust_env=False, ) diff --git a/astrbot/core/provider/sources/nvidia_rerank_source.py b/astrbot/core/provider/sources/nvidia_rerank_source.py index d69cbc3532..03c4c88fbd 100644 --- a/astrbot/core/provider/sources/nvidia_rerank_source.py +++ b/astrbot/core/provider/sources/nvidia_rerank_source.py @@ -42,7 +42,8 @@ async def _get_client(self): "Accept": "application/json", } self.client = aiohttp.ClientSession( - headers=headers, timeout=aiohttp.ClientTimeout(total=self.timeout) + headers={**self.request_headers, **headers}, + timeout=aiohttp.ClientTimeout(total=self.timeout), ) return self.client diff --git a/astrbot/core/provider/sources/ollama_embedding_source.py b/astrbot/core/provider/sources/ollama_embedding_source.py index eb03441d71..cc22fe2194 100644 --- a/astrbot/core/provider/sources/ollama_embedding_source.py +++ b/astrbot/core/provider/sources/ollama_embedding_source.py @@ -48,7 +48,7 @@ async def _get_client(self): } timeout = aiohttp.ClientTimeout(total=self.timeout) self.client = aiohttp.ClientSession( - headers=headers, + headers={**self.request_headers, **headers}, timeout=timeout, trust_env=False, ) diff --git a/astrbot/core/provider/sources/openai_chat_completions_source.py b/astrbot/core/provider/sources/openai_chat_completions_source.py index 57fa62a74e..c9b03998a4 100644 --- a/astrbot/core/provider/sources/openai_chat_completions_source.py +++ b/astrbot/core/provider/sources/openai_chat_completions_source.py @@ -463,16 +463,10 @@ def __init__(self, provider_config, provider_settings) -> None: self.api_keys: list = super().get_keys() self.chosen_api_key = self.api_keys[0] if len(self.api_keys) > 0 else None self.timeout = provider_config.get("timeout", 120) - self.custom_headers = provider_config.get("custom_headers", {}) + self.custom_headers = self.request_headers if isinstance(self.timeout, str): self.timeout = int(self.timeout) - if not isinstance(self.custom_headers, dict) or not self.custom_headers: - self.custom_headers = None - else: - for key in self.custom_headers: - self.custom_headers[key] = str(self.custom_headers[key]) - if provider_config.get("api_version"): # Using Azure OpenAI API self.client = AsyncAzureOpenAI( diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index 6b640fdc8c..070aecafb1 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -44,6 +44,7 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: provider_config.get("embedding_api_base", "https://api.openai.com/v1") ) self.client = AsyncOpenAI( + default_headers=self.request_headers, api_key=provider_config.get("embedding_api_key"), base_url=api_base, timeout=int(provider_config.get("timeout", 20)), diff --git a/astrbot/core/provider/sources/openai_responses_source.py b/astrbot/core/provider/sources/openai_responses_source.py index 8fe61f6775..08d2fcf088 100644 --- a/astrbot/core/provider/sources/openai_responses_source.py +++ b/astrbot/core/provider/sources/openai_responses_source.py @@ -174,12 +174,7 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: self.api_keys = list(self.get_keys()) self.chosen_api_key = self.api_keys[0] if self.api_keys else "" self.timeout = int(provider_config.get("timeout", 120)) - headers = provider_config.get("custom_headers") - self.custom_headers = ( - {str(key): str(value) for key, value in headers.items()} - if isinstance(headers, dict) - else None - ) + self.custom_headers = self.request_headers client_options = { "api_key": self.chosen_api_key, "default_headers": self.custom_headers, diff --git a/astrbot/core/provider/sources/openai_tts_api_source.py b/astrbot/core/provider/sources/openai_tts_api_source.py index b1079fca8b..3ed08d4696 100644 --- a/astrbot/core/provider/sources/openai_tts_api_source.py +++ b/astrbot/core/provider/sources/openai_tts_api_source.py @@ -39,6 +39,7 @@ def __init__( "OpenAI TTS", provider_config, httpx_module=httpx2 ) self.client = AsyncOpenAI( + default_headers=self.request_headers, api_key=self.chosen_api_key, base_url=provider_config.get("api_base"), timeout=timeout, diff --git a/astrbot/core/provider/sources/vllm_rerank_source.py b/astrbot/core/provider/sources/vllm_rerank_source.py index f2b2742f03..ff121feb40 100644 --- a/astrbot/core/provider/sources/vllm_rerank_source.py +++ b/astrbot/core/provider/sources/vllm_rerank_source.py @@ -34,7 +34,7 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: self.timeout = provider_config.get("timeout", 20) self.model = provider_config.get("rerank_model", "BAAI/bge-reranker-base") - h = {} + h = self.request_headers.copy() if self.auth_key: h["Authorization"] = f"Bearer {self.auth_key}" self.client = aiohttp.ClientSession( diff --git a/astrbot/core/provider/sources/volcengine_tts.py b/astrbot/core/provider/sources/volcengine_tts.py index d28ef13cb0..27ddbf5f67 100644 --- a/astrbot/core/provider/sources/volcengine_tts.py +++ b/astrbot/core/provider/sources/volcengine_tts.py @@ -73,7 +73,7 @@ async def get_audio(self, text: str) -> str: try: async with ( - aiohttp.ClientSession() as session, + aiohttp.ClientSession(headers=self.request_headers) as session, session.post( self.api_base, data=json.dumps(payload), diff --git a/astrbot/core/provider/sources/whisper_api_source.py b/astrbot/core/provider/sources/whisper_api_source.py index 30baa05a2e..f5335d1bd9 100644 --- a/astrbot/core/provider/sources/whisper_api_source.py +++ b/astrbot/core/provider/sources/whisper_api_source.py @@ -26,6 +26,7 @@ def __init__( self.chosen_api_key = provider_config.get("api_key", "") self.client = AsyncOpenAI( + default_headers=self.request_headers, api_key=self.chosen_api_key, base_url=provider_config.get("api_base"), timeout=provider_config.get("timeout", NOT_GIVEN), diff --git a/astrbot/core/provider/sources/xinference_rerank_source.py b/astrbot/core/provider/sources/xinference_rerank_source.py index ac6ccc805b..527ff4d4d4 100644 --- a/astrbot/core/provider/sources/xinference_rerank_source.py +++ b/astrbot/core/provider/sources/xinference_rerank_source.py @@ -49,6 +49,7 @@ async def initialize(self) -> None: else: logger.info("Xinference rerank does not use API authentication") self.client = Client(self.base_url) + self.client._headers.update(self.request_headers) running_models = await self.client.list_models() if not isinstance(running_models, Mapping): diff --git a/astrbot/core/provider/sources/xinference_stt_provider.py b/astrbot/core/provider/sources/xinference_stt_provider.py index 84bed2ac4f..3a891b3559 100644 --- a/astrbot/core/provider/sources/xinference_stt_provider.py +++ b/astrbot/core/provider/sources/xinference_stt_provider.py @@ -44,6 +44,7 @@ async def initialize(self) -> None: else: logger.info("Xinference STT: No API key provided.") self.client = Client(self.base_url) + self.client._headers.update(self.request_headers) try: running_models = await self.client.list_models() diff --git a/tests/unit/provider/test_provider_user_agent.py b/tests/unit/provider/test_provider_user_agent.py new file mode 100644 index 0000000000..4809ee47ad --- /dev/null +++ b/tests/unit/provider/test_provider_user_agent.py @@ -0,0 +1,175 @@ +import copy +import socket + +import pytest +import pytest_asyncio +from aiohttp import web + +from astrbot import __version__ +from astrbot.core.provider.headers import DEFAULT_USER_AGENT, build_provider_headers +from astrbot.core.provider.sources.anthropic_source import ProviderAnthropic +from astrbot.core.provider.sources.bailian_rerank_source import BailianRerankProvider +from astrbot.core.provider.sources.gemini_embedding_source import ( + GeminiEmbeddingProvider, +) +from astrbot.core.provider.sources.gemini_source import ProviderGoogleGenAI +from astrbot.core.provider.sources.gemini_tts_source import ProviderGeminiTTSAPI +from astrbot.core.provider.sources.kimi_code_source import ProviderKimiCode +from astrbot.core.provider.sources.nvidia_embedding_source import ( + NvidiaEmbeddingProvider, +) +from astrbot.core.provider.sources.nvidia_rerank_source import NvidiaRerankProvider +from astrbot.core.provider.sources.ollama_embedding_source import ( + OllamaEmbeddingProvider, +) +from astrbot.core.provider.sources.openai_chat_completions_source import ( + ProviderOpenAIChatCompletions, +) +from astrbot.core.provider.sources.openai_embedding_source import ( + OpenAIEmbeddingProvider, +) +from astrbot.core.provider.sources.openai_responses_source import ( + ProviderOpenAIResponses, +) +from astrbot.core.provider.sources.openai_tts_api_source import ProviderOpenAITTSAPI +from astrbot.core.provider.sources.vllm_rerank_source import VLLMRerankProvider +from astrbot.core.provider.sources.whisper_api_source import ProviderOpenAIWhisperAPI + + +@pytest.mark.parametrize( + "custom_headers", [None, {}, [], "invalid", {"User-Agent": " "}] +) +def test_provider_headers_default_to_current_version(custom_headers): + assert build_provider_headers(custom_headers) == { + "User-Agent": f"astrbot/{__version__}" + } + + +@pytest.mark.parametrize("name", ["User-Agent", "user-agent", "USER-AGENT"]) +def test_provider_headers_preserve_custom_values_without_mutation(name): + custom = {name: "custom/1.0", "X-Trace-Id": 123} + original = copy.deepcopy(custom) + assert build_provider_headers(custom) == { + "User-Agent": "custom/1.0", + "X-Trace-Id": "123", + } + assert custom == original + + +@pytest.fixture +def unused_tcp_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +@pytest_asyncio.fixture +async def provider_http_server(unused_tcp_port, monkeypatch): + """Capture actual SDK requests without contacting external providers.""" + requests = [] + monkeypatch.setenv("NO_PROXY", "127.0.0.1") + monkeypatch.setenv("no_proxy", "127.0.0.1") + + async def handle(request): + requests.append(request.headers) + return web.json_response( + { + "object": "list", + "data": [], + "models": [], + "output": {"embeddings": [{"embedding": [0.1], "text_index": 0}]}, + } + ) + + app = web.Application() + app.router.add_route("*", "/{path:.*}", handle) + runner = web.AppRunner(app) + await runner.setup() + try: + await web.TCPSite(runner, "127.0.0.1", unused_tcp_port).start() + yield f"http://127.0.0.1:{unused_tcp_port}", requests + finally: + await runner.cleanup() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "provider_cls", + [ + ProviderOpenAIChatCompletions, + ProviderOpenAIResponses, + OpenAIEmbeddingProvider, + ProviderOpenAITTSAPI, + ProviderOpenAIWhisperAPI, + ProviderAnthropic, + ProviderKimiCode, + ProviderGoogleGenAI, + GeminiEmbeddingProvider, + ProviderGeminiTTSAPI, + ], +) +@pytest.mark.parametrize("custom_headers", [{}, {"user-agent": "custom/1.0"}]) +async def test_provider_sdk_sends_exactly_one_user_agent( + provider_cls, custom_headers, provider_http_server +): + base_url, requests = provider_http_server + config = { + "id": "test-provider", + "model": "test-model", + "key": ["test-key"], + "api_key": "test-key", + "api_base": base_url, + "embedding_api_key": "test-key", + "embedding_api_base": base_url, + "gemini_tts_api_key": "test-key", + "gemini_tts_api_base": base_url, + "custom_headers": custom_headers, + } + original = copy.deepcopy(config) + provider = provider_cls(config, {}) + try: + await provider.client.models.list() + assert len(requests) == 1 + assert requests[0].getall("User-Agent") == [ + custom_headers.get("user-agent", DEFAULT_USER_AGENT) + ] + assert config == original + finally: + await provider.terminate() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "provider_cls", + [ + BailianRerankProvider, + VLLMRerankProvider, + NvidiaEmbeddingProvider, + NvidiaRerankProvider, + OllamaEmbeddingProvider, + ], +) +@pytest.mark.parametrize("custom_headers", [{}, {"USER-AGENT": "custom/1.0"}]) +async def test_aiohttp_provider_sends_user_agent( + provider_cls, custom_headers, provider_http_server +): + base_url, requests = provider_http_server + provider = provider_cls( + { + "embedding_api_key": "test-key", + "embedding_api_base": base_url, + "rerank_api_key": "test-key", + "rerank_api_base": base_url, + "custom_headers": custom_headers, + }, + {}, + ) + try: + client = provider.client or await provider._get_client() + async with client.get(base_url) as response: + assert response.status == 200 + assert requests[0].getall("User-Agent") == [ + custom_headers.get("USER-AGENT", DEFAULT_USER_AGENT) + ] + finally: + await provider.terminate() From daa55f3299014b2842c5bba30646f77fa39b6658 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:54:09 +0200 Subject: [PATCH 02/20] fix(qqofficial): always send the state=10 closing frame in C2C streaming (#10069) Close every open C2C stream segment even when the tail buffer is empty or contains only an empty Plain, using the fork-owned stream delta helper. Upstream-Commit: 31d798944d7d6e379914b3de3b4114977d5d9883 Upstream-Author: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Upstream-PR: AstrBotDevs#10069 Sync-Disposition: adapt Fork-Adaptation: Keep _append_stream_delta and PlatformSendResult; add regressions next to existing C2C stream tests. Tested: uv run pytest tests/unit/platform/test_qqofficial_group_message_create.py -q --test-profile blocking AI-Generated: true Generated-At: 2026-09-16T06:54:09Z --- .../qqofficial/qqofficial_message_event.py | 33 ++++- .../test_qqofficial_group_message_create.py | 128 ++++++++++++++++++ 2 files changed, 155 insertions(+), 6 deletions(-) diff --git a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py index 0a10256389..7d70db1ae4 100644 --- a/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py +++ b/astrbot/core/platform/sources/qqofficial/qqofficial_message_event.py @@ -241,6 +241,27 @@ async def send(self, message: MessageChain) -> PlatformSendResult | None: self.send_buffer = message return await self._post_send() + async def _close_stream_segment(self, stream_payload: dict): + """以 state=10 收尾当前流式段;流已开但 buffer 恰好为空时补最小收尾帧。 + + QQ C2C 流式协议缺 state=10 会在超时后把整段回滚到首包(#10066): + 中间分片已把全文发完、结尾没有剩余内容时也必须补一个 "\\n" 收尾帧, + 否则客户端等不到结束帧,最终只显示首包几个字。 + """ + stream_payload["state"] = 10 + has_content = self.send_buffer is not None and any( + (isinstance(c, Plain) and c.text) or not isinstance(c, Plain) + for c in self.send_buffer.chain + ) + if not has_content: + # 只有空 Plain 的 buffer 也算空:_post_send_one 会拒掉空文本, + # 收尾帧照样缺席(#10069 review) + if stream_payload.get("id") is None: + # 从未发出任何分片,无流可收 + return None + self.send_buffer = MessageChain(chain=[Plain(text="\n")]) + return await self._post_send(stream=stream_payload) + async def send_streaming(self, generator, use_fallback: bool = False): """流式输出仅支持消息列表私聊(C2C),其他消息源退化为普通发送""" # 先标记事件层“已执行发送操作”,避免异常路径遗漏 @@ -267,9 +288,10 @@ async def send_streaming(self, generator, use_fallback: bool = False): # tool_call break 信号:工具开始执行,先把已有 buffer 以 state=10 结束当前流式段 if chain.type == "break": - if self.send_buffer: - stream_payload["state"] = 10 - ret = await self._post_send(stream=stream_payload) + if (self.send_buffer and self.send_buffer.chain) or ( + stream_payload.get("id") is not None + ): + ret = await self._close_stream_segment(stream_payload) ret_id = self._extract_response_message_id(ret) if ret_id is not None: stream_payload["id"] = ret_id @@ -302,9 +324,8 @@ async def send_streaming(self, generator, use_fallback: bool = False): self.send_buffer = None # 清空已发送的分片,避免下次重复发送旧内容 if isinstance(source, botpy.message.C2CMessage): - # 结束流式对话,发送 buffer 中剩余内容 - stream_payload["state"] = 10 - ret = await self._post_send(stream=stream_payload) + # 结束流式对话,发送 buffer 中剩余内容(空尾也要补收尾帧) + ret = await self._close_stream_segment(stream_payload) else: ret = await self._post_send() diff --git a/tests/unit/platform/test_qqofficial_group_message_create.py b/tests/unit/platform/test_qqofficial_group_message_create.py index 2feb89ac9e..f3e7074629 100644 --- a/tests/unit/platform/test_qqofficial_group_message_create.py +++ b/tests/unit/platform/test_qqofficial_group_message_create.py @@ -1126,6 +1126,134 @@ async def generator(): assert sent_text == ["不稀罕"] +@pytest.mark.asyncio +async def test_c2c_stream_closes_with_state10_when_tail_buffer_empty(monkeypatch): + source = botpy.message.C2CMessage( + None, + "evt-1", + {"id": "msg-1", "author": {"user_openid": "user-1"}, "content": "hello"}, + ) + event = QQOfficialMessageEvent.__new__(QQOfficialMessageEvent) + event.message_obj = SimpleNamespace(raw_message=source) + event.send_buffer = None + frames: list[tuple[int | None, str]] = [] + + async def fake_post_send(stream=None): + parts = [] + if event.send_buffer: + for component in event.send_buffer.chain: + if isinstance(component, Plain) and component.text: + parts.append(component.text) + frames.append((stream.get("state") if stream else None, "".join(parts))) + event.send_buffer = None + return {"id": "stream-1"} + + event._post_send = AsyncMock(side_effect=fake_post_send) + times = iter([0.5, 2.0, 2.0, 2.0]) + loop = asyncio.get_running_loop() + monkeypatch.setattr(loop, "time", lambda: next(times, 9.0)) + + async def generator(): + yield MessageChain().message("不") + yield MessageChain().message("稀") + + with patch.object( + AstrMessageEvent, + "send_streaming", + AsyncMock(return_value=None), + ): + await event.send_streaming(generator()) + + assert (1, "不稀") in frames + assert frames[-1] == (10, "\n") + + +@pytest.mark.asyncio +async def test_c2c_stream_break_closes_open_segment_with_empty_buffer(monkeypatch): + source = botpy.message.C2CMessage( + None, + "evt-1", + {"id": "msg-1", "author": {"user_openid": "user-1"}, "content": "hello"}, + ) + event = QQOfficialMessageEvent.__new__(QQOfficialMessageEvent) + event.message_obj = SimpleNamespace(raw_message=source) + event.send_buffer = None + frames: list[tuple[int | None, str]] = [] + + async def fake_post_send(stream=None): + parts = [] + if event.send_buffer: + for component in event.send_buffer.chain: + if isinstance(component, Plain) and component.text: + parts.append(component.text) + frames.append((stream.get("state") if stream else None, "".join(parts))) + event.send_buffer = None + return {"id": "stream-1"} + + event._post_send = AsyncMock(side_effect=fake_post_send) + times = iter([2.0, 2.0, 2.0, 2.0]) + loop = asyncio.get_running_loop() + monkeypatch.setattr(loop, "time", lambda: next(times, 9.0)) + + async def generator(): + yield MessageChain().message("首段文本") + yield MessageChain(type="break") + + with patch.object( + AstrMessageEvent, + "send_streaming", + AsyncMock(return_value=None), + ): + await event.send_streaming(generator()) + + assert frames[0] == (1, "首段文本") + assert frames[1] == (10, "\n") + assert len(frames) == 2 + + +@pytest.mark.asyncio +async def test_c2c_stream_closes_when_tail_is_empty_plain(monkeypatch): + source = botpy.message.C2CMessage( + None, + "evt-1", + {"id": "msg-1", "author": {"user_openid": "user-1"}, "content": "hello"}, + ) + event = QQOfficialMessageEvent.__new__(QQOfficialMessageEvent) + event.message_obj = SimpleNamespace(raw_message=source) + event.send_buffer = None + frames: list[tuple[int | None, str]] = [] + + async def fake_post_send(stream=None): + parts = [] + if event.send_buffer: + for component in event.send_buffer.chain: + if isinstance(component, Plain) and component.text: + parts.append(component.text) + frames.append((stream.get("state") if stream else None, "".join(parts))) + event.send_buffer = None + return {"id": "stream-1"} + + event._post_send = AsyncMock(side_effect=fake_post_send) + times = iter([0.5, 2.0, 2.0, 2.0]) + loop = asyncio.get_running_loop() + monkeypatch.setattr(loop, "time", lambda: next(times, 9.0)) + + async def generator(): + yield MessageChain().message("不") + yield MessageChain().message("稀") + yield MessageChain(chain=[Plain("")]) + + with patch.object( + AstrMessageEvent, + "send_streaming", + AsyncMock(return_value=None), + ): + await event.send_streaming(generator()) + + assert frames[0] == (1, "不稀") + assert frames[-1] == (10, "\n") + + @pytest.mark.asyncio async def test_send_streaming_clears_buffer_when_post_send_raises(): source = botpy.message.C2CMessage( From b8036e50e4f8ee1b3128e410df8617fffe47dcf9 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:58:59 +0200 Subject: [PATCH 03/20] feat: optimize model selection with source filters and sticky groups (#10084) Group the model picker by provider source, add a source filter, and keep sticky headers with virtualized rows. Preserve fork trigger labels, async provider drawers, capability badges, and connectivity tests. Upstream-Commit: 56cbc4a4a6e85f3ffe3fd402854051c0f3d86253 Upstream-Author: Soulter <37870767+Soulter@users.noreply.github.com> Upstream-PR: AstrBotDevs#10084 Sync-Disposition: adapt Fork-Adaptation: Keep buttonText, defineAsyncComponent drawers, and input-variant stored model names; omit ja/ru locales. Tested: cd dashboard && pnpm exec vue-tsc --noEmit && pnpm exec vitest run --config vitest.config.ts tests/configProductization.vitest.ts && pnpm i18n:check AI-Generated: true Generated-At: 2026-09-16T06:58:59Z --- .../components/shared/ProviderSelectMenu.vue | 529 ++++++++++++++---- .../src/i18n/locales/en-US/core/shared.json | 2 + .../src/i18n/locales/zh-CN/core/shared.json | 2 + .../tests/configProductization.vitest.ts | 60 +- 4 files changed, 476 insertions(+), 117 deletions(-) diff --git a/dashboard/src/components/shared/ProviderSelectMenu.vue b/dashboard/src/components/shared/ProviderSelectMenu.vue index bbb2cbb166..bbf3197940 100644 --- a/dashboard/src/components/shared/ProviderSelectMenu.vue +++ b/dashboard/src/components/shared/ProviderSelectMenu.vue @@ -29,16 +29,70 @@
- +
+ + + + + + {{ + sharedTm('providerSelector.allSources') + }} + + + {{ source.id }} + {{ source.apiBase }} + + + + + +
-