mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-21 23:41:39 +08:00
feat(llm): 增加 Kimi 文本适配器与 KIMI_* 环境变量
纯文本可选 MA_LLM_TEXT_PROVIDER=kimi,与 OPENAI_* 配料/视觉分离;.env.example 与测试补充。 Made-with: Cursor
This commit is contained in:
parent
5e70bb1be3
commit
2b595f794e
10
.env.example
10
.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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
146
backend/pipeline/llm/providers/adapters/kimi_moonshot_text.py
Normal file
146
backend/pipeline/llm/providers/adapters/kimi_moonshot_text.py
Normal file
@ -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()
|
||||
@ -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}。",
|
||||
|
||||
@ -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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user