diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 5b4ea7686a..3a71325f87 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1112,6 +1112,20 @@ class ChatProviderTemplate(TypedDict): "api_base": "https://api.anthropic.com/v1", "timeout": 120, "proxy": "", + "custom_headers": {}, + "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, + }, + "Kimi Coding Plan": { + "id": "kimi-code", + "provider": "kimi-code", + "type": "kimi_code_chat_completion", + "provider_type": "chat_completion", + "enable": True, + "key": [], + "api_base": "https://api.kimi.com/coding/", + "timeout": 120, + "proxy": "", + "custom_headers": {"User-Agent": "claude-code/0.1.0"}, "anth_thinking_config": {"type": "", "budget": 0, "effort": ""}, }, "Moonshot": { diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 0df9f791ae..761b84c9db 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -375,6 +375,10 @@ def dynamic_import_provider(self, type: str) -> None: from .sources.anthropic_source import ( ProviderAnthropic as ProviderAnthropic, ) + case "kimi_code_chat_completion": + from .sources.kimi_code_source import ( + ProviderKimiCode as ProviderKimiCode, + ) case "googlegenai_chat_completion": from .sources.gemini_source import ( ProviderGoogleGenAI as ProviderGoogleGenAI, diff --git a/astrbot/core/provider/sources/anthropic_source.py b/astrbot/core/provider/sources/anthropic_source.py index be70fdc74d..203d0610ff 100644 --- a/astrbot/core/provider/sources/anthropic_source.py +++ b/astrbot/core/provider/sources/anthropic_source.py @@ -16,7 +16,6 @@ from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.network_utils import ( - create_proxy_client, is_connection_error, log_connection_failure, ) @@ -29,6 +28,30 @@ "Anthropic Claude API 提供商适配器", ) class ProviderAnthropic(Provider): + @staticmethod + def _normalize_custom_headers(provider_config: dict) -> dict[str, str] | None: + custom_headers = provider_config.get("custom_headers", {}) + if not isinstance(custom_headers, dict) or not custom_headers: + return None + normalized_headers: dict[str, str] = {} + for key, value in custom_headers.items(): + normalized_headers[str(key)] = str(value) + return normalized_headers or None + + @classmethod + def _resolve_custom_headers( + cls, + provider_config: dict, + *, + required_headers: dict[str, str] | None = None, + ) -> dict[str, str] | None: + merged_headers = cls._normalize_custom_headers(provider_config) or {} + 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 + def __init__( self, provider_config, @@ -46,6 +69,7 @@ def __init__( if isinstance(self.timeout, str): self.timeout = int(self.timeout) self.thinking_config = provider_config.get("anth_thinking_config", {}) + self.custom_headers = self._resolve_custom_headers(provider_config) if use_api_key: self._init_api_key(provider_config) @@ -66,7 +90,12 @@ def _init_api_key(self, provider_config: dict) -> None: def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: """创建带代理的 HTTP 客户端""" proxy = provider_config.get("proxy", "") - return create_proxy_client("Anthropic", proxy) + if proxy: + logger.info(f"[Anthropic] 使用代理: {proxy}") + return httpx.AsyncClient(proxy=proxy, headers=self.custom_headers) + if self.custom_headers: + return httpx.AsyncClient(headers=self.custom_headers) + return None def _apply_thinking_config(self, payloads: dict) -> None: thinking_type = self.thinking_config.get("type", "") diff --git a/astrbot/core/provider/sources/kimi_code_source.py b/astrbot/core/provider/sources/kimi_code_source.py new file mode 100644 index 0000000000..02c200271f --- /dev/null +++ b/astrbot/core/provider/sources/kimi_code_source.py @@ -0,0 +1,27 @@ +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" + + +@register_provider_adapter( + "kimi_code_chat_completion", + "Kimi Code Provider Adapter", +) +class ProviderKimiCode(ProviderAnthropic): + def __init__( + self, + provider_config: dict, + provider_settings: dict, + ) -> None: + merged_provider_config = dict(provider_config) + merged_provider_config.setdefault("api_base", KIMI_CODE_API_BASE) + merged_provider_config.setdefault("model", KIMI_CODE_DEFAULT_MODEL) + merged_provider_config["custom_headers"] = self._resolve_custom_headers( + merged_provider_config, + required_headers={"User-Agent": KIMI_CODE_USER_AGENT}, + ) + + super().__init__(merged_provider_config, provider_settings) diff --git a/compose-with-shipyard.yml b/compose-with-shipyard.yml index 24ced5a95b..7703293fa7 100644 --- a/compose-with-shipyard.yml +++ b/compose-with-shipyard.yml @@ -4,7 +4,10 @@ version: '3.8' services: astrbot: - image: soulter/astrbot:latest + build: + context: . + dockerfile: Dockerfile + image: astrbot:kimi-code container_name: astrbot restart: always ports: # mappings description: https://github.com/AstrBotDevs/AstrBot/issues/497 diff --git a/dashboard/src/i18n/locales/en-US/features/provider.json b/dashboard/src/i18n/locales/en-US/features/provider.json index 87e26e2253..1dca1a4a55 100644 --- a/dashboard/src/i18n/locales/en-US/features/provider.json +++ b/dashboard/src/i18n/locales/en-US/features/provider.json @@ -21,6 +21,7 @@ }, "description": { "openai": "Supports all OpenAI API compatible providers.", + "kimi_code": "Dedicated Kimi CodingPlan / Kimi Code integration using a custom Anthropic API compatibility layer.", "default": "" } }, diff --git a/dashboard/src/i18n/locales/ru-RU/features/provider.json b/dashboard/src/i18n/locales/ru-RU/features/provider.json index 1287bfbd47..9bc9f31edd 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/provider.json +++ b/dashboard/src/i18n/locales/ru-RU/features/provider.json @@ -21,6 +21,7 @@ }, "description": { "openai": "Поддерживаются все провайдеры, совместимые с OpenAI API.", + "kimi_code": "Специальная интеграция Kimi CodingPlan / Kimi Code с отдельной совместимой адаптацией Anthropic API.", "vllm_rerank": "Также поддерживает Jina AI, Cohere, PPIO и другие.", "default": "Преобразование речи в текст" } @@ -148,4 +149,4 @@ "modelId": "ID модели" } } -} \ No newline at end of file +} diff --git a/dashboard/src/i18n/locales/zh-CN/features/provider.json b/dashboard/src/i18n/locales/zh-CN/features/provider.json index d3f5d95b1d..7a8a1a4d83 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/provider.json +++ b/dashboard/src/i18n/locales/zh-CN/features/provider.json @@ -21,6 +21,7 @@ }, "description": { "openai": "也支持所有兼容 OpenAI API 的模型提供商。", + "kimi_code": "Kimi 的 CodingPlan / Kimi Code 专用接入,使用特殊的 Anthropic API 兼容适配。", "vllm_rerank": "也支持 Jina AI, Cohere, PPIO 等提供商。", "default": "" } diff --git a/dashboard/src/utils/providerUtils.js b/dashboard/src/utils/providerUtils.js index 4bfe3ea6e9..7c992f531d 100644 --- a/dashboard/src/utils/providerUtils.js +++ b/dashboard/src/utils/providerUtils.js @@ -21,6 +21,8 @@ export function getProviderIcon(type) { 'nvidia': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/nvidia-color.svg', 'siliconflow': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/siliconcloud.svg', 'moonshot': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg', + 'kimi': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg', + 'kimi-code': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/kimi.svg', 'ppio': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/ppio.svg', 'dify': 'https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@latest/icons/dify-color.svg', "coze": "https://cdn.jsdelivr.net/npm/@lobehub/icons-static-svg@1.66.0/icons/coze.svg", @@ -50,9 +52,11 @@ export function getProviderIcon(type) { * @returns {string} 提供商描述 */ export function getProviderDescription(template, name, tm) { - if (name == 'OpenAI') { + if (name === 'OpenAI') { return tm('providers.description.openai', { type: template.type }); - } else if (name == 'vLLM Rerank') { + } else if (template.provider === 'kimi-code') { + return tm('providers.description.kimi_code'); + } else if (name === 'vLLM Rerank') { return tm('providers.description.vllm_rerank', { type: template.type }); } return tm('providers.description.default', { type: template.type }); diff --git a/tests/test_anthropic_kimi_code_provider.py b/tests/test_anthropic_kimi_code_provider.py new file mode 100644 index 0000000000..ea22e376c3 --- /dev/null +++ b/tests/test_anthropic_kimi_code_provider.py @@ -0,0 +1,81 @@ +import httpx + +import astrbot.core.provider.sources.anthropic_source as anthropic_source +import astrbot.core.provider.sources.kimi_code_source as kimi_code_source + + +class _FakeAsyncAnthropic: + def __init__(self, **kwargs): + self.kwargs = kwargs + + async def close(self): + return None + + +def test_anthropic_provider_injects_custom_headers_into_http_client(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"], + "custom_headers": { + "User-Agent": "custom-agent/1.0", + "X-Test-Header": 123, + }, + }, + provider_settings={}, + ) + + assert provider.custom_headers == { + "User-Agent": "custom-agent/1.0", + "X-Test-Header": "123", + } + assert isinstance(provider.client.kwargs["http_client"], httpx.AsyncClient) + assert provider.client.kwargs["http_client"].headers["User-Agent"] == "custom-agent/1.0" + assert provider.client.kwargs["http_client"].headers["X-Test-Header"] == "123" + + +def test_kimi_code_provider_sets_defaults_and_preserves_custom_headers(monkeypatch): + monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic) + + provider = kimi_code_source.ProviderKimiCode( + provider_config={ + "id": "kimi-code", + "type": "kimi_code_chat_completion", + "key": ["test-key"], + "custom_headers": {"X-Trace-Id": "trace-1"}, + }, + provider_settings={}, + ) + + assert provider.base_url == kimi_code_source.KIMI_CODE_API_BASE + assert provider.get_model() == kimi_code_source.KIMI_CODE_DEFAULT_MODEL + assert provider.custom_headers == { + "User-Agent": kimi_code_source.KIMI_CODE_USER_AGENT, + "X-Trace-Id": "trace-1", + } + assert provider.client.kwargs["http_client"].headers["User-Agent"] == ( + kimi_code_source.KIMI_CODE_USER_AGENT + ) + assert provider.client.kwargs["http_client"].headers["X-Trace-Id"] == "trace-1" + + +def test_kimi_code_provider_restores_required_user_agent_when_blank(monkeypatch): + monkeypatch.setattr(anthropic_source, "AsyncAnthropic", _FakeAsyncAnthropic) + + provider = kimi_code_source.ProviderKimiCode( + provider_config={ + "id": "kimi-code", + "type": "kimi_code_chat_completion", + "key": ["test-key"], + "custom_headers": {"User-Agent": " "}, + }, + provider_settings={}, + ) + + assert provider.custom_headers == { + "User-Agent": kimi_code_source.KIMI_CODE_USER_AGENT, + }