From 2b595f794e58c26b82d474fe9c0f9950c6a446c0 Mon Sep 17 00:00:00 2001 From: hub-gif <2487812171@qq.com> Date: Mon, 27 Apr 2026 10:47:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(llm):=20=E5=A2=9E=E5=8A=A0=20Kimi=20?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E9=80=82=E9=85=8D=E5=99=A8=E4=B8=8E=20KIMI?= =?UTF-8?q?=5F*=20=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯文本可选 MA_LLM_TEXT_PROVIDER=kimi,与 OPENAI_* 配料/视觉分离;.env.example 与测试补充。 Made-with: Cursor --- .env.example | 10 ++ backend/pipeline/llm/providers/__init__.py | 7 +- .../llm/providers/adapters/__init__.py | 2 + .../providers/adapters/kimi_moonshot_text.py | 146 ++++++++++++++++++ backend/pipeline/llm/providers/factory.py | 8 +- .../pipeline/tests/test_text_llm_providers.py | 40 +++++ 6 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 backend/pipeline/llm/providers/adapters/kimi_moonshot_text.py diff --git a/.env.example b/.env.example index 06572c0..d4e9b89 100644 --- a/.env.example +++ b/.env.example @@ -32,8 +32,18 @@ CSRF_TRUSTED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 # 报告/策略等「纯文本」任务的后端选择: # crawler_openai_compatible(默认)= 经 pipeline.openai_gateway 调自建 OpenAI 兼容网关 # openai_official / openai_chatgpt / chatgpt = 直连 OpenAI 官方 api.openai.com(与上项凭据独立) +# kimi / moonshot / kimi_moonshot = 月之暗面 Kimi(Moonshot)OpenAI 兼容,与下节 KIMI_* 独立;配料/视觉仍用上面 OPENAI_* # MA_LLM_TEXT_PROVIDER=crawler_openai_compatible +# Kimi / Moonshot 纯文本(仅当 MA_LLM_TEXT_PROVIDER 为 kimi / moonshot / kimi_moonshot 时需要;与 OPENAI_* 分离) +# KIMI_API_KEY=sk-... +# KIMI_BASE_URL=https://api.moonshot.cn/v1 +# KIMI_TEXT_MODEL=moonshot-v1-8k +# 与预检 token 上界;若用 moonshot-v1-128k 可设 128000 +# KIMI_CONTEXT_WINDOW=8192 +# 别名:MOONSHOT_API_KEY、MOONSHOT_MODEL +# KIMI_TIMEOUT=600 + # OpenAI 官方 Chat Completions(仅当 MA_LLM_TEXT_PROVIDER 为 openai_official / openai_chatgpt / chatgpt 时需要) # OPENAI_OFFICIAL_API_KEY=sk-... # OPENAI_OFFICIAL_BASE_URL=https://api.openai.com/v1 diff --git a/backend/pipeline/llm/providers/__init__.py b/backend/pipeline/llm/providers/__init__.py index 7eebc7f..d363b32 100644 --- a/backend/pipeline/llm/providers/__init__.py +++ b/backend/pipeline/llm/providers/__init__.py @@ -2,12 +2,17 @@ from __future__ import annotations -from .adapters import CrawlerOpenAiCompatibleTextLlm, OpenAiOfficialChatGptTextLlm +from .adapters import ( + CrawlerOpenAiCompatibleTextLlm, + KimiMoonshotTextLlm, + OpenAiOfficialChatGptTextLlm, +) from .factory import get_text_llm, reset_text_llm_client_for_tests from .protocol import TextLlmClient __all__ = [ "CrawlerOpenAiCompatibleTextLlm", + "KimiMoonshotTextLlm", "OpenAiOfficialChatGptTextLlm", "TextLlmClient", "get_text_llm", diff --git a/backend/pipeline/llm/providers/adapters/__init__.py b/backend/pipeline/llm/providers/adapters/__init__.py index 0cc03a9..f076069 100644 --- a/backend/pipeline/llm/providers/adapters/__init__.py +++ b/backend/pipeline/llm/providers/adapters/__init__.py @@ -2,9 +2,11 @@ from __future__ import annotations from .crawler_openai_compatible import CrawlerOpenAiCompatibleTextLlm +from .kimi_moonshot_text import KimiMoonshotTextLlm from .openai_official_chatgpt import OpenAiOfficialChatGptTextLlm __all__ = [ "CrawlerOpenAiCompatibleTextLlm", + "KimiMoonshotTextLlm", "OpenAiOfficialChatGptTextLlm", ] diff --git a/backend/pipeline/llm/providers/adapters/kimi_moonshot_text.py b/backend/pipeline/llm/providers/adapters/kimi_moonshot_text.py new file mode 100644 index 0000000..c583c4a --- /dev/null +++ b/backend/pipeline/llm/providers/adapters/kimi_moonshot_text.py @@ -0,0 +1,146 @@ +""" +月之暗面 Kimi(Moonshot)OpenAI 兼容 `chat/completions`,**仅用于纯文本**(`call_llm` / 报告 / 策略)。 + +与 `OPENAI_*` / `LLM_*` 分离,避免与自建网关(配料多模态等)混用同一套 Key。 + +启用:`MA_LLM_TEXT_PROVIDER=kimi`(或 `moonshot` / `kimi_moonshot`)。 + +环境变量:`KIMI_API_KEY`(必填)、`KIMI_BASE_URL`(默认 Moonshot 官方 v1)、`KIMI_TEXT_MODEL`、 +`KIMI_CONTEXT_WINDOW`、`KIMI_TIMEOUT` 等;见 `.env.example`。 +""" +from __future__ import annotations + +import os +from typing import Any + +import requests + +from pipeline.openai_gateway.chat_content import normalize_message_content +from pipeline.openai_gateway.estimate import ( + estimate_chat_input_tokens as estimate_crawler_style_input_tokens, +) + +_DEFAULT_BASE = "https://api.moonshot.cn/v1" +_DEFAULT_MODEL = "moonshot-v1-8k" +# 与常见 8k 窗口一致;若使用 moonshot-v1-128k 等请调大 KIMI_CONTEXT_WINDOW +_DEFAULT_CTX = 8192 + +_BUF = 256 +_WANT_MAX = 8192 + + +def _read_timeout() -> tuple[float, float]: + read = 600 + raw = ( + os.environ.get("KIMI_TIMEOUT") + or os.environ.get("LLM_CHAT_TIMEOUT") + or os.environ.get("OPENAI_TIMEOUT") + or "" + ).strip() + if raw: + try: + read = max(60, int(raw)) + except ValueError: + pass + conn = 30.0 + raw_c = (os.environ.get("LLM_CHAT_CONNECT_TIMEOUT") or "").strip() + if raw_c: + try: + conn = max(5.0, float(raw_c)) + except ValueError: + pass + return (conn, float(read)) + + +def _resolve_kimi_credentials() -> tuple[str, str, str]: + key = ( + (os.environ.get("KIMI_API_KEY") or os.environ.get("MOONSHOT_API_KEY") or "").strip() + ) + if not key: + raise ValueError( + "使用 kimi 文本适配器需设置 KIMI_API_KEY(或 MOONSHOT_API_KEY)," + "与配料/视觉所用 OPENAI_API_KEY 分开配置。" + ) + base = (os.environ.get("KIMI_BASE_URL") or _DEFAULT_BASE).strip().rstrip("/") + model = ( + os.environ.get("KIMI_TEXT_MODEL") + or os.environ.get("KIMI_MODEL") + or os.environ.get("MOONSHOT_MODEL") + or _DEFAULT_MODEL + ).strip() + return key, base, model + + +def _context_window() -> int: + raw = (os.environ.get("KIMI_CONTEXT_WINDOW") or str(_DEFAULT_CTX)).strip() + try: + return max(4096, int(raw)) + except ValueError: + return _DEFAULT_CTX + + +def _default_temperature() -> float: + return 0.2 + + +class KimiMoonshotTextLlm: + """Kimi OpenAI 兼容文本补全;行为与 `OpenAiOfficialChatGptTextLlm` 同形(max_tokens 预检)。""" + + def complete_text( + self, + system_prompt: str, + user_prompt: str, + *, + temperature: float | None = None, + ) -> str: + api_key, base, model = _resolve_kimi_credentials() + body: dict[str, Any] = { + "model": model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": _default_temperature() if temperature is None else float(temperature), + "max_tokens": _WANT_MAX, + } + est = estimate_crawler_style_input_tokens(system_prompt, user_prompt) + context_window = _context_window() + if est >= context_window - _BUF - 256: + raise ValueError( + f"提示词过长(估算输入约 {est} tokens,KIMI_CONTEXT_WINDOW={context_window})," + "请缩小输入或换更大上下文的 KIMI_TEXT_MODEL / KIMI_CONTEXT_WINDOW。" + ) + avail = context_window - est - _BUF + want = int(body.get("max_tokens") or _WANT_MAX) + body["max_tokens"] = max(256, min(want, max(avail, 256))) + r = requests.post( + f"{base}/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json=body, + timeout=_read_timeout(), + ) + try: + r.raise_for_status() + except requests.HTTPError as e: + snippet = "" + if e.response is not None: + snippet = (e.response.text or "")[:1200].replace("\r\n", "\n").replace("\n", " ") + if snippet: + raise requests.HTTPError( + f"{e!s} | body: {snippet}", + response=e.response, + request=e.request, + ) from e + raise + data = r.json() + msg = (data.get("choices") or [{}])[0].get("message") or {} + return normalize_message_content(msg.get("content")) + + def estimate_input_tokens(self, system_prompt: str, user_prompt: str) -> int: + return estimate_crawler_style_input_tokens(system_prompt, user_prompt) + + def context_window_tokens(self) -> int: + return _context_window() diff --git a/backend/pipeline/llm/providers/factory.py b/backend/pipeline/llm/providers/factory.py index 533d419..a66413c 100644 --- a/backend/pipeline/llm/providers/factory.py +++ b/backend/pipeline/llm/providers/factory.py @@ -1,11 +1,12 @@ """ -根据 `MA_LLM_TEXT_PROVIDER` 选择文本大模型实现;未设置时与历史行为一致(经 AI_crawler 的 OpenAI 兼容网关)。 +根据 `MA_LLM_TEXT_PROVIDER` 选择文本大模型实现;未设置时与历史行为一致(经 `openai_gateway` 与自建兼容网关)。 """ from __future__ import annotations import os from .adapters.crawler_openai_compatible import CrawlerOpenAiCompatibleTextLlm +from .adapters.kimi_moonshot_text import KimiMoonshotTextLlm from .adapters.openai_official_chatgpt import OpenAiOfficialChatGptTextLlm from .protocol import TextLlmClient @@ -32,9 +33,12 @@ def _build_client(pid: str) -> TextLlmClient: return CrawlerOpenAiCompatibleTextLlm() if pid in ("openai_official", "openai_chatgpt", "chatgpt"): return OpenAiOfficialChatGptTextLlm() + if pid in ("kimi", "moonshot", "kimi_moonshot", "moonshot_kimi"): + return KimiMoonshotTextLlm() known = ( "crawler_openai_compatible, crawler, default, openai_compatible, " - "openai_official, openai_chatgpt, chatgpt" + "openai_official, openai_chatgpt, chatgpt, " + "kimi, moonshot, kimi_moonshot" ) raise ValueError( f"不支持的 {_ENV_KEY}={pid!r};已知取值:{known}。", diff --git a/backend/pipeline/tests/test_text_llm_providers.py b/backend/pipeline/tests/test_text_llm_providers.py index c36b008..6bd1767 100644 --- a/backend/pipeline/tests/test_text_llm_providers.py +++ b/backend/pipeline/tests/test_text_llm_providers.py @@ -6,6 +6,7 @@ import pytest from pipeline.llm.providers import ( CrawlerOpenAiCompatibleTextLlm, + KimiMoonshotTextLlm, OpenAiOfficialChatGptTextLlm, get_text_llm, reset_text_llm_client_for_tests, @@ -37,6 +38,45 @@ def test_openai_official_complete_text_uses_post_and_returns_content(monkeypatch assert out == "ok_out" +def test_kimi_complete_text_uses_post_and_returns_content(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("KIMI_API_KEY", "sk-kimi-test") + + def _fake_post( + url: str, + headers: dict, + json: dict, + timeout: object, + ) -> MagicMock: + assert "moonshot" in url or "chat/completions" in url + assert json["messages"][0]["role"] == "system" + assert headers.get("Authorization", "").startswith("Bearer sk-kimi-") + r = MagicMock() + r.json.return_value = {"choices": [{"message": {"content": "kimi_out"}}]} + r.raise_for_status = MagicMock() + return r + + with patch( + "pipeline.llm.providers.adapters.kimi_moonshot_text.requests.post", + side_effect=_fake_post, + ): + llm = KimiMoonshotTextLlm() + out = llm.complete_text("S", "U", temperature=0.1) + assert out == "kimi_out" + + +def test_factory_selects_kimi_when_env_set(monkeypatch: pytest.MonkeyPatch) -> None: + reset_text_llm_client_for_tests() + monkeypatch.setenv("MA_LLM_TEXT_PROVIDER", "kimi") + monkeypatch.setenv("KIMI_API_KEY", "sk-x") + try: + c = get_text_llm() + assert isinstance(c, KimiMoonshotTextLlm) + finally: + reset_text_llm_client_for_tests() + monkeypatch.delenv("MA_LLM_TEXT_PROVIDER", raising=False) + monkeypatch.delenv("KIMI_API_KEY", raising=False) + + def test_factory_selects_openai_official_when_env_set(monkeypatch: pytest.MonkeyPatch) -> None: reset_text_llm_client_for_tests() monkeypatch.setenv("MA_LLM_TEXT_PROVIDER", "chatgpt")