From e286da75c45e4b62793c25365a7621c59cff03d8 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 20 Mar 2026 10:06:20 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=E6=88=AA=E6=96=AD=E5=99=A8=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1=E5=94=AF=E4=B8=80=20user=20=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E6=99=BA=E8=B0=B1=E7=AD=89=20provider=20?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=20400=20(#6581)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 截断器丢失唯一 user 消息导致 API 400 修复 #6196 当对话只有一条 user 消息(长 tool chain 场景:system → user → assistant → tool → assistant → tool → ...),三个截断方法都会把这条 user 消息丢掉, 导致智谱、Gemini 等要求 user 消息的 provider 返回 400。 改动: - 提取 `_split_system_rest()` 去掉三个方法里重复的 system/non-system 拆分 - 新增 `_ensure_user_message()`:截断后如果没有 user 了,从原始消息里补回 第一条 user,避免违反 API 格式要求 - 删掉 `truncate_by_dropping_oldest_turns` 里把没有 user 就清空全部消息的逻辑 - 5 个新测试覆盖单 user + 长 tool chain 场景,3 个旧测试更新断言 * style: format code --------- Co-authored-by: Yufeng He <40085740+universeplayer@users.noreply.github.com> Co-authored-by: RC-CHN <1051989940@qq.com> --- astrbot/core/agent/context/truncator.py | 79 ++++++++++++++----------- tests/agent/test_truncator.py | 72 ++++++++++++++++++++-- 2 files changed, 112 insertions(+), 39 deletions(-) diff --git a/astrbot/core/agent/context/truncator.py b/astrbot/core/agent/context/truncator.py index afd89f2be..b552b5ef2 100644 --- a/astrbot/core/agent/context/truncator.py +++ b/astrbot/core/agent/context/truncator.py @@ -12,6 +12,37 @@ class ContextTruncator: and len(message.tool_calls) > 0 ) + @staticmethod + def _split_system_rest( + messages: list[Message], + ) -> tuple[list[Message], list[Message]]: + """把 system 消息和后面的对话消息分开。""" + first_non_system = 0 + for i, msg in enumerate(messages): + if msg.role != "system": + first_non_system = i + break + return messages[:first_non_system], messages[first_non_system:] + + @staticmethod + def _ensure_user_message( + system_messages: list[Message], + truncated: list[Message], + original_messages: list[Message], + ) -> list[Message]: + """截断后如果没有 user 消息了,从原始列表里把第一条 user 补回来。 + 很多 provider (智谱、Gemini 等) 要求 system 之后必须紧跟 user,否则直接 400。 + """ + if any(m.role == "user" for m in truncated): + return system_messages + truncated + + # 从原始消息里找第一条 user + first_user = next((m for m in original_messages if m.role == "user"), None) + if first_user is None: + return system_messages + truncated + + return system_messages + [first_user] + truncated + def fix_messages(self, messages: list[Message]) -> list[Message]: """修复消息列表,确保 tool call 和 tool response 的配对关系有效。 @@ -81,14 +112,7 @@ class ContextTruncator: if keep_most_recent_turns == -1: return messages - first_non_system = 0 - for i, msg in enumerate(messages): - if msg.role != "system": - first_non_system = i - break - - system_messages = messages[:first_non_system] - non_system_messages = messages[first_non_system:] + system_messages, non_system_messages = self._split_system_rest(messages) if len(non_system_messages) // 2 <= keep_most_recent_turns: return messages @@ -99,7 +123,7 @@ class ContextTruncator: else: truncated_contexts = non_system_messages[-num_to_keep * 2 :] - # 找到第一个 role 为 user 的索引,确保上下文格式正确 + # 对齐到第一条 user 消息 index = next( (i for i, item in enumerate(truncated_contexts) if item.role == "user"), None, @@ -107,8 +131,9 @@ class ContextTruncator: if index is not None and index > 0: truncated_contexts = truncated_contexts[index:] - result = system_messages + truncated_contexts - + result = self._ensure_user_message( + system_messages, truncated_contexts, messages + ) return self.fix_messages(result) def truncate_by_dropping_oldest_turns( @@ -120,31 +145,24 @@ class ContextTruncator: if drop_turns <= 0: return messages - first_non_system = 0 - for i, msg in enumerate(messages): - if msg.role != "system": - first_non_system = i - break - - system_messages = messages[:first_non_system] - non_system_messages = messages[first_non_system:] + system_messages, non_system_messages = self._split_system_rest(messages) if len(non_system_messages) // 2 <= drop_turns: truncated_non_system = [] else: truncated_non_system = non_system_messages[drop_turns * 2 :] + # 对齐到第一条 user index = next( (i for i, item in enumerate(truncated_non_system) if item.role == "user"), None, ) if index is not None: truncated_non_system = truncated_non_system[index:] - elif truncated_non_system: - truncated_non_system = [] - - result = system_messages + truncated_non_system + result = self._ensure_user_message( + system_messages, truncated_non_system, messages + ) return self.fix_messages(result) def truncate_by_halving( @@ -155,14 +173,7 @@ class ContextTruncator: if len(messages) <= 2: return messages - first_non_system = 0 - for i, msg in enumerate(messages): - if msg.role != "system": - first_non_system = i - break - - system_messages = messages[:first_non_system] - non_system_messages = messages[first_non_system:] + system_messages, non_system_messages = self._split_system_rest(messages) messages_to_delete = len(non_system_messages) // 2 if messages_to_delete == 0: @@ -170,6 +181,7 @@ class ContextTruncator: truncated_non_system = non_system_messages[messages_to_delete:] + # 对齐到第一条 user index = next( (i for i, item in enumerate(truncated_non_system) if item.role == "user"), None, @@ -177,6 +189,7 @@ class ContextTruncator: if index is not None: truncated_non_system = truncated_non_system[index:] - result = system_messages + truncated_non_system - + result = self._ensure_user_message( + system_messages, truncated_non_system, messages + ) return self.fix_messages(result) diff --git a/tests/agent/test_truncator.py b/tests/agent/test_truncator.py index c85fb7d55..7dac80f9c 100644 --- a/tests/agent/test_truncator.py +++ b/tests/agent/test_truncator.py @@ -104,8 +104,9 @@ class TestContextTruncator: messages, keep_most_recent_turns=0, drop_turns=1 ) - # Should result in empty or minimal list - assert len(result) == 0 + # 截断后至少保留一条 user 消息 (#6196) + assert len(result) >= 1 + assert result[0].role == "user" def test_truncate_by_turns_below_threshold(self): """Test truncate_by_turns when messages are below threshold.""" @@ -201,8 +202,9 @@ class TestContextTruncator: messages = self.create_messages(4) result = truncator.truncate_by_dropping_oldest_turns(messages, drop_turns=2) - # Should drop all turns - assert len(result) == 0 + # 即使 drop 掉所有 turn,也会把 user 消息补回来 (#6196) + assert len(result) >= 1 + assert result[0].role == "user" def test_truncate_by_dropping_oldest_turns_drop_more_than_available(self): """Test truncate_by_dropping_oldest_turns with drop_turns > available turns.""" @@ -211,8 +213,9 @@ class TestContextTruncator: messages = self.create_messages(4) result = truncator.truncate_by_dropping_oldest_turns(messages, drop_turns=5) - # Should result in empty list - assert len(result) == 0 + # 同理,user 消息会被保留 (#6196) + assert len(result) >= 1 + assert result[0].role == "user" def test_truncate_by_dropping_oldest_turns_ensures_user_first(self): """Test that result starts with user message after dropping.""" @@ -372,3 +375,60 @@ class TestContextTruncator: assert len(result) >= 0 # May keep system messages or clear all if len(result) > 0: assert all(msg.role == "system" for msg in result) + + # ==================== #6196: 长 tool chain 只有一条 user 消息 ==================== + + def _build_tool_chain(self, tool_rounds: int = 20) -> list[Message]: + """构造 system -> user -> (assistant -> tool) * N 的长链,只有一条 user。""" + msgs = [ + self.create_message("system", "You are a helpful assistant."), + self.create_message("user", "帮我查一下天气"), + ] + for i in range(tool_rounds): + msgs.append(self.create_message("assistant", f"调用工具 {i}")) + msgs.append(self.create_message("tool", f"工具结果 {i}")) + return msgs + + def test_drop_oldest_preserves_sole_user(self): + """#6196: drop 1 turn 不应丢掉唯一的 user 消息。""" + truncator = ContextTruncator() + msgs = self._build_tool_chain(20) # 1 system + 1 user + 40 asst/tool = 42 + result = truncator.truncate_by_dropping_oldest_turns(msgs, drop_turns=1) + roles = [m.role for m in result] + assert "user" in roles, "唯一的 user 消息被丢掉了" + assert roles[0] == "system" + + def test_halving_preserves_sole_user(self): + """#6196: 对半砍不应丢掉唯一的 user 消息。""" + truncator = ContextTruncator() + msgs = self._build_tool_chain(20) + result = truncator.truncate_by_halving(msgs) + roles = [m.role for m in result] + assert "user" in roles, "唯一的 user 消息被丢掉了" + + def test_truncate_by_turns_preserves_sole_user(self): + """#6196: keep_most_recent_turns 也不应丢掉唯一的 user 消息。""" + truncator = ContextTruncator() + msgs = self._build_tool_chain(20) + result = truncator.truncate_by_turns( + msgs, keep_most_recent_turns=3, drop_turns=1 + ) + roles = [m.role for m in result] + assert "user" in roles, "唯一的 user 消息被丢掉了" + + def test_drop_oldest_heavy_drops_still_has_user(self): + """#6196: 大量 drop 也不会丢 user。""" + truncator = ContextTruncator() + msgs = self._build_tool_chain(30) + result = truncator.truncate_by_dropping_oldest_turns(msgs, drop_turns=10) + roles = [m.role for m in result] + assert "user" in roles + + def test_normal_multi_user_not_affected(self): + """正常多 user 对话不受影响。""" + truncator = ContextTruncator() + msgs = self.create_messages(20, include_system=True) + result_before = truncator.truncate_by_dropping_oldest_turns(msgs, drop_turns=2) + # 多 user 场景下截断后仍有 user + roles = [m.role for m in result_before] + assert "user" in roles From 0b690344913cda8acc1e2bca07114b10717bd76c Mon Sep 17 00:00:00 2001 From: leonforcode Date: Fri, 20 Mar 2026 12:05:54 +0800 Subject: [PATCH 2/6] fix: prevent truncation logic from removing the only user message in long tool-calling conversations (#6198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 压缩算法删除 user 消息 Bug 修复 * perf: improve truncate algo --------- Co-authored-by: Soulter <905617992@qq.com> --- astrbot/core/agent/context/truncator.py | 63 ++++++++++++++----------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/astrbot/core/agent/context/truncator.py b/astrbot/core/agent/context/truncator.py index b552b5ef2..9abf57433 100644 --- a/astrbot/core/agent/context/truncator.py +++ b/astrbot/core/agent/context/truncator.py @@ -16,7 +16,11 @@ class ContextTruncator: def _split_system_rest( messages: list[Message], ) -> tuple[list[Message], list[Message]]: - """把 system 消息和后面的对话消息分开。""" + """Split messages into system messages and the rest. + + Returns: + tuple: (system_messages, non_system_messages) + """ first_non_system = 0 for i, msg in enumerate(messages): if msg.role != "system": @@ -30,13 +34,14 @@ class ContextTruncator: truncated: list[Message], original_messages: list[Message], ) -> list[Message]: - """截断后如果没有 user 消息了,从原始列表里把第一条 user 补回来。 - 很多 provider (智谱、Gemini 等) 要求 system 之后必须紧跟 user,否则直接 400。 + """Ensure the result always contains the first user message right after + system messages. This is required by many LLM APIs (e.g. Zhipu) that + mandate a ``user`` message immediately following the ``system`` message. """ - if any(m.role == "user" for m in truncated): + if truncated and truncated[0].role == "user": return system_messages + truncated - # 从原始消息里找第一条 user + # Locate the first user message from the *original* list. first_user = next((m for m in original_messages if m.role == "user"), None) if first_user is None: return system_messages + truncated @@ -44,13 +49,13 @@ class ContextTruncator: return system_messages + [first_user] + truncated def fix_messages(self, messages: list[Message]) -> list[Message]: - """修复消息列表,确保 tool call 和 tool response 的配对关系有效。 + """Fix the message list to ensure the validity of tool call and tool response pairing. - 此方法确保: - 1. 每个 `tool` 消息前面都有一个包含 tool_calls 的 `assistant` 消息 - 2. 每个包含 tool_calls 的 `assistant` 消息后面都有对应的 `tool` 响应 + This method ensures that: + 1. Each `tool` message is preceded by an `assistant` message containing `tool_calls`. + 2. Each `assistant` message containing `tool_calls` is followed by corresponding ` - 这是 OpenAI Chat Completions API 规范的要求(Gemini 对此执行严格检查)。 + This is a requirement of the OpenAI Chat Completions API specification (Gemini enforces this strictly). """ if not messages: return messages @@ -69,24 +74,25 @@ class ContextTruncator: for msg in messages: if msg.role == "tool": - # 只有在有挂起的 assistant(tool_calls) 时才记录 tool 响应 + # Only record tool responses when there is a pending assistant(tool_calls) if pending_assistant is not None: pending_tools.append(msg) - # else: 孤立的 tool 消息,直接忽略 + # Isolated tool messages without a preceding assistant(tool_calls) are ignored continue if self._has_tool_calls(msg): - # 遇到新的 assistant(tool_calls) 前,先处理旧的 pending 链 + # When encountering a new assistant(tool_calls), first process the old pending chain flush_pending_if_valid() pending_assistant = msg continue - # 非 tool,且不含 tool_calls 的消息 - # 先结束任何 pending 链,再正常追加 + # Non-tool messages that do not contain tool_calls will break the pending chain. + # Flush any pending chain first, then append the current message normally. flush_pending_if_valid() fixed_messages.append(msg) - # 结束时处理最后一个 pending 链 + # Flush the last pending chain at the end, + # ensuring that any remaining valid assistant(tool_calls) and its tools are included in the final list. flush_pending_if_valid() return fixed_messages @@ -97,17 +103,18 @@ class ContextTruncator: keep_most_recent_turns: int, drop_turns: int = 1, ) -> list[Message]: - """截断上下文列表,确保不超过最大长度。 - 一个 turn 包含一个 user 消息和一个 assistant 消息。 - 这个方法会保证截断后的上下文列表符合 OpenAI 的上下文格式。 + """ + Turn-based truncation strategy, which drops the oldest turns while keeping the most recent N turns. + A turn consists of a user message and an assistant message. + This method ensures that the truncated context list conforms to OpenAI's context format. Args: - messages: 上下文列表 - keep_most_recent_turns: 保留最近的对话轮数 - drop_turns: 一次性丢弃的对话轮数 + messages: The original list of messages in the context. + keep_most_recent_turns: The number of most recent turns to keep. If set to -1, it means keeping all turns (no truncation). + drop_turns: The number of turns to drop from the beginning. Returns: - 截断后的上下文列表 + The truncated list of messages. """ if keep_most_recent_turns == -1: return messages @@ -123,7 +130,7 @@ class ContextTruncator: else: truncated_contexts = non_system_messages[-num_to_keep * 2 :] - # 对齐到第一条 user 消息 + # Find the first user message index = next( (i for i, item in enumerate(truncated_contexts) if item.role == "user"), None, @@ -141,7 +148,7 @@ class ContextTruncator: messages: list[Message], drop_turns: int = 1, ) -> list[Message]: - """丢弃最旧的 N 个对话轮次。""" + """Drop the oldest N turns, regardless of the number of turns to keep.""" if drop_turns <= 0: return messages @@ -152,7 +159,7 @@ class ContextTruncator: else: truncated_non_system = non_system_messages[drop_turns * 2 :] - # 对齐到第一条 user + # Find the first user message index = next( (i for i, item in enumerate(truncated_non_system) if item.role == "user"), None, @@ -169,7 +176,7 @@ class ContextTruncator: self, messages: list[Message], ) -> list[Message]: - """对半砍策略,删除 50% 的消息""" + """Halve the number of messages, keeping the most recent ones.""" if len(messages) <= 2: return messages @@ -181,7 +188,7 @@ class ContextTruncator: truncated_non_system = non_system_messages[messages_to_delete:] - # 对齐到第一条 user + # Find the first user message index = next( (i for i, item in enumerate(truncated_non_system) if item.role == "user"), None, From b07dbb3d26d41a5397f5b7ca9ed4335264db5e9b Mon Sep 17 00:00:00 2001 From: daniel5u Date: Fri, 20 Mar 2026 13:32:06 +0800 Subject: [PATCH 3/6] feat: add Kimi Coding Plan provider with Anthropic API compatibility (#6559) * Add Kimi Code provider * Add icon mapping for Kimi Code provider * Clarify Kimi CodingPlan provider labeling * Refine Kimi Code header handling * modified docker compose * fix: correct Kimi Coding Plan label and update API base URL --------- Co-authored-by: Soulter <905617992@qq.com> --- astrbot/core/config/default.py | 14 ++++ astrbot/core/provider/manager.py | 4 + .../core/provider/sources/anthropic_source.py | 33 +++++++- .../core/provider/sources/kimi_code_source.py | 27 +++++++ compose-with-shipyard.yml | 5 +- .../i18n/locales/en-US/features/provider.json | 1 + .../i18n/locales/ru-RU/features/provider.json | 3 +- .../i18n/locales/zh-CN/features/provider.json | 1 + dashboard/src/utils/providerUtils.js | 8 +- tests/test_anthropic_kimi_code_provider.py | 81 +++++++++++++++++++ 10 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 astrbot/core/provider/sources/kimi_code_source.py create mode 100644 tests/test_anthropic_kimi_code_provider.py diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 58c172681..3842b1177 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1112,6 +1112,20 @@ CONFIG_METADATA_2 = { "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 0df9f791a..761b84c9d 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -375,6 +375,10 @@ class ProviderManager: 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 be70fdc74..203d0610f 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.entities import LLMResponse, TokenUsage 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 @@ from ..register import register_provider_adapter "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 @@ class ProviderAnthropic(Provider): 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 @@ class ProviderAnthropic(Provider): 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 000000000..02c200271 --- /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 24ced5a95..7703293fa 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 87e26e225..1dca1a4a5 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 1287bfbd4..9bc9f31ed 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 d3f5d95b1..7a8a1a4d8 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 4bfe3ea6e..7c992f531 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 000000000..ea22e376c --- /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, + } From 6a42ad793480c9836cab75e3970f19103e8bf78f Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:42:58 +0800 Subject: [PATCH 4/6] fix(openai): improve logging for proxy and API base configuration (#6669) fix: #6558 --- .../provider/sources/openai_embedding_source.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py index 04397b182..2b62d865c 100644 --- a/astrbot/core/provider/sources/openai_embedding_source.py +++ b/astrbot/core/provider/sources/openai_embedding_source.py @@ -19,17 +19,15 @@ class OpenAIEmbeddingProvider(EmbeddingProvider): self.provider_config = provider_config 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] 使用代理: {proxy}") + logger.info(f"[OpenAI Embedding] {provider_id} Using proxy: {proxy}") http_client = httpx.AsyncClient(proxy=proxy) - api_base = provider_config.get("embedding_api_base", "").strip() - if not api_base: - api_base = "https://api.openai.com/v1" - else: - api_base = api_base.removesuffix("/") - if not api_base.endswith("/v1"): - api_base = f"{api_base}/v1" + api_base = provider_config.get( + "embedding_api_base", "https://api.openai.com/v1" + ).strip() + logger.info(f"[OpenAI Embedding] {provider_id} Using API Base: {api_base}") self.client = AsyncOpenAI( api_key=provider_config.get("embedding_api_key"), base_url=api_base, From 735bd43648b094b62dd56eabc5d1cc7b2faf7760 Mon Sep 17 00:00:00 2001 From: letr <123731298+letr007@users.noreply.github.com> Date: Fri, 20 Mar 2026 13:45:21 +0800 Subject: [PATCH 5/6] fix(dashboard): simplify persona selector layout for mobile screens (#5907) --- .../folder/BaseFolderItemSelector.vue | 93 ++++++++++++++++--- 1 file changed, 81 insertions(+), 12 deletions(-) diff --git a/dashboard/src/components/folder/BaseFolderItemSelector.vue b/dashboard/src/components/folder/BaseFolderItemSelector.vue index 4b4b4f1c4..9e5c5130f 100644 --- a/dashboard/src/components/folder/BaseFolderItemSelector.vue +++ b/dashboard/src/components/folder/BaseFolderItemSelector.vue @@ -14,19 +14,23 @@ - + - + mdi-account-circle {{ labels.dialogTitle || '选择项目' }} - +
-
+