Files
Bubbles/ai_providers/ai_kimi.py
2026-02-04 17:35:08 +08:00

269 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import logging
import time
from typing import List
import httpx
from openai import APIConnectionError, APIError, AuthenticationError, OpenAI
try:
from function.func_summary import MessageSummary
except ImportError: # pragma: no cover - fallback when typing
MessageSummary = object
class Kimi:
"""Moonshot Kimi provider (兼容OpenAI SDK)"""
def __init__(self, conf: dict, message_summary_instance: MessageSummary = None, bot_wxid: str = None) -> None:
key = conf.get("key")
api = conf.get("api", "https://api.moonshot.cn/v1")
proxy = conf.get("proxy")
prompt = conf.get("prompt")
self.model = conf.get("model", "kimi-k2")
self.max_history_messages = conf.get("max_history_messages", 30)
self.show_reasoning = bool(conf.get("show_reasoning", False))
self.LOG = logging.getLogger("Kimi")
self.message_summary = message_summary_instance
self.bot_wxid = bot_wxid
if not self.message_summary:
self.LOG.warning("MessageSummary 实例未提供给 Kimi上下文功能将不可用")
if not self.bot_wxid:
self.LOG.warning("bot_wxid 未提供给 Kimi可能无法正确识别机器人自身消息")
if proxy:
self.client = OpenAI(api_key=key, base_url=api, http_client=httpx.Client(proxy=proxy))
else:
self.client = OpenAI(api_key=key, base_url=api)
self.system_content_msg = {
"role": "system",
"content": prompt or "你是 Kimi一个由 Moonshot AI 打造的贴心助手。"
}
def __repr__(self) -> str:
return "Kimi"
@staticmethod
def value_check(conf: dict) -> bool:
if conf and conf.get("key"):
return True
return False
def get_answer(
self,
question: str,
wxid: str,
system_prompt_override=None,
specific_max_history=None,
tools=None,
tool_handler=None,
tool_choice=None,
tool_max_iterations: int = 10
) -> str:
api_messages = []
effective_system_prompt = system_prompt_override if system_prompt_override else self.system_content_msg.get("content")
if effective_system_prompt:
api_messages.append({"role": "system", "content": effective_system_prompt})
now_time = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
api_messages.append({"role": "system", "content": f"Current time is: {now_time}"})
if self.message_summary and self.bot_wxid:
limit_to_use = specific_max_history if specific_max_history is not None else self.max_history_messages
try:
limit_to_use = int(limit_to_use) if limit_to_use is not None else None
except (TypeError, ValueError):
limit_to_use = self.max_history_messages
if limit_to_use == 0:
history = []
context_summary = None
elif hasattr(self.message_summary, 'get_compressed_context'):
history, context_summary = self.message_summary.get_compressed_context(
wxid, max_context_chars=8000, max_recent=limit_to_use
)
else:
history = self.message_summary.get_messages(wxid)
if limit_to_use and limit_to_use > 0:
history = history[-limit_to_use:]
context_summary = None
if context_summary:
api_messages.append({"role": "system", "content": f"Earlier conversation context:\n{context_summary}"})
for msg in history:
role = "assistant" if msg.get("sender_wxid") == self.bot_wxid else "user"
content = msg.get("content") or ""
if not content:
continue
if role == "user":
sender_name = msg.get("sender", "未知用户")
api_messages.append({"role": role, "content": f"{sender_name}: {content}"})
else:
api_messages.append({"role": role, "content": content})
else:
self.LOG.debug(f"wxid={wxid} 无法加载历史记录message_summary 或 bot_wxid 未设置)")
if question:
api_messages.append({"role": "user", "content": question})
if tools and not tool_handler:
self.LOG.warning("Kimi: 提供了 tools 但没有 tool_handler忽略工具调用。")
tools = None
try:
response_text, reasoning_text = self._execute_with_tools(
api_messages=api_messages,
tools=tools,
tool_handler=tool_handler,
tool_choice=tool_choice,
tool_max_iterations=tool_max_iterations
)
if (
self.show_reasoning
and reasoning_text
and isinstance(reasoning_text, str)
and reasoning_text.strip()
):
reasoning_output = reasoning_text.strip()
final_answer = response_text.strip() if isinstance(response_text, str) else response_text
return f"【思考过程】\n{reasoning_output}\n\n【最终回答】\n{final_answer}"
return response_text
except (AuthenticationError, APIConnectionError, APIError) as e:
self.LOG.error(f"Kimi API 调用失败: {e}")
raise
except Exception as e:
self.LOG.error(f"Kimi 未知错误: {e}", exc_info=True)
raise
def _execute_with_tools(
self,
api_messages,
tools=None,
tool_handler=None,
tool_choice=None,
tool_max_iterations: int = 10
):
iterations = 0
params_base = {"model": self.model}
runtime_tools = tools if tools and isinstance(tools, list) else None
runtime_tool_choice = tool_choice
reasoning_segments: List[str] = []
while True:
params = dict(params_base)
params["messages"] = api_messages
if runtime_tools:
params["tools"] = runtime_tools
if runtime_tool_choice:
params["tool_choice"] = runtime_tool_choice
response = self.client.chat.completions.create(**params)
choice = response.choices[0]
message = choice.message
finish_reason = choice.finish_reason
reasoning_chunk = self._extract_reasoning_text(message)
if reasoning_chunk:
reasoning_segments.append(reasoning_chunk)
if (
runtime_tools
and message
and getattr(message, "tool_calls", None)
and finish_reason == "tool_calls"
and tool_handler
):
iterations += 1
api_messages.append({
"role": "assistant",
"content": message.content or "",
"tool_calls": message.tool_calls
})
if tool_max_iterations is not None and iterations > max(tool_max_iterations, 0):
api_messages.append({
"role": "system",
"content": "你已经达到允许的最大工具调用次数,请根据现有信息直接给出最终回答。"
})
runtime_tool_choice = "none"
continue
for tool_call in message.tool_calls:
tool_name = tool_call.function.name
raw_arguments = tool_call.function.arguments or "{}"
try:
parsed_arguments = json.loads(raw_arguments)
except json.JSONDecodeError:
parsed_arguments = {"_raw": raw_arguments}
try:
tool_output = tool_handler(tool_name, parsed_arguments)
except Exception as handler_exc:
self.LOG.error(f"工具 {tool_name} 执行失败: {handler_exc}", exc_info=True)
tool_output = json.dumps(
{"error": f"{tool_name} failed: {handler_exc.__class__.__name__}"},
ensure_ascii=False
)
if not isinstance(tool_output, str):
tool_output = json.dumps(tool_output, ensure_ascii=False)
api_messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_output
})
runtime_tool_choice = None
continue
response_text = message.content if message and message.content else ""
if response_text.startswith("\n\n"):
response_text = response_text[2:]
response_text = response_text.replace("\n\n", "\n")
reasoning_text = "\n".join(seg for seg in reasoning_segments if seg).strip()
return response_text, reasoning_text
def _extract_reasoning_text(self, message) -> str:
"""Moonshot 在 ChatCompletionMessage 上挂载 reasoning_content 字段"""
if not message:
return ""
raw_reasoning = getattr(message, "reasoning_content", None)
if not raw_reasoning:
return ""
def _normalize_segment(segment) -> str:
if isinstance(segment, str):
return segment
if isinstance(segment, dict):
return segment.get("content") or segment.get("text") or ""
if isinstance(segment, list):
return "\n".join(filter(None, (_normalize_segment(item) for item in segment)))
return str(segment) if segment is not None else ""
if isinstance(raw_reasoning, list):
segments = []
for part in raw_reasoning:
normalized = _normalize_segment(part)
if normalized:
segments.append(normalized)
return "\n".join(segments).strip()
return _normalize_segment(raw_reasoning).strip()
__all__ = ["Kimi"]