-
Notifications
You must be signed in to change notification settings - Fork 7.8k
feat: multi-provider LLM support via Prompture #463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,40 @@ | ||
| # LLM API配置(支持 OpenAI SDK 格式的任意 LLM API) | ||
| # 推荐使用阿里百炼平台qwen-plus模型:https://bailian.console.aliyun.com/ | ||
| # 注意消耗较大,可先进行小于40轮的模拟尝试 | ||
| # ===== LLM API Configuration ===== | ||
| # Default: any OpenAI-compatible API | ||
| # With Prompture installed (pip install prompture): 12+ providers supported | ||
| # | ||
| # ── OpenAI-compatible (default, no Prompture needed) ── | ||
| LLM_API_KEY=your_api_key_here | ||
| LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 | ||
| LLM_MODEL_NAME=qwen-plus | ||
| # | ||
| # ── With Prompture: use "provider/model" format ── | ||
| # LM Studio (free, local): | ||
| # LLM_MODEL_NAME=lmstudio/local-model | ||
| # LLM_BASE_URL=http://localhost:1234/v1 | ||
| # LLM_API_KEY=lm-studio | ||
| # | ||
| # Ollama (free, local): | ||
| # LLM_MODEL_NAME=ollama/llama3.1:8b | ||
| # | ||
| # Kimi / Moonshot: | ||
| # LLM_MODEL_NAME=moonshot/moonshot-v1-8k | ||
| # LLM_API_KEY=your_moonshot_key | ||
| # | ||
| # Claude: | ||
| # LLM_MODEL_NAME=claude/claude-sonnet-4-20250514 | ||
| # LLM_API_KEY=sk-ant-... | ||
| # | ||
| # Groq (fast, free tier): | ||
| # LLM_MODEL_NAME=groq/llama-3.1-70b-versatile | ||
| # LLM_API_KEY=gsk_... | ||
| # | ||
| # See all providers: https://github.com/jhd3197/prompture#providers | ||
|
|
||
| # ===== ZEP记忆图谱配置 ===== | ||
| # 每月免费额度即可支撑简单使用:https://app.getzep.com/ | ||
| # ===== ZEP Memory Graph ===== | ||
| # Free monthly quota: https://app.getzep.com/ | ||
| ZEP_API_KEY=your_zep_api_key_here | ||
|
|
||
| # ===== 加速 LLM 配置(可选)===== | ||
| # 注意如果不使用加速配置,env文件中就不要出现下面的配置项 | ||
| LLM_BOOST_API_KEY=your_api_key_here | ||
| LLM_BOOST_BASE_URL=your_base_url_here | ||
| LLM_BOOST_MODEL_NAME=your_model_name_here | ||
| # ===== Boost LLM (optional) ===== | ||
| # LLM_BOOST_API_KEY=your_api_key_here | ||
| # LLM_BOOST_BASE_URL=your_base_url_here | ||
| # LLM_BOOST_MODEL_NAME=your_model_name_here |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,103 +1,210 @@ | ||
| """ | ||
| LLM客户端封装 | ||
| 统一使用OpenAI格式调用 | ||
| Supports two backends: | ||
| 1. Prompture (optional) — 12+ providers: LM Studio, Ollama, Claude, Groq, Kimi, etc. | ||
| 2. OpenAI SDK (default fallback) — any OpenAI-compatible API | ||
| Install Prompture for multi-provider support: pip install prompture | ||
| """ | ||
|
|
||
| import json | ||
| import re | ||
| from typing import Optional, Dict, Any, List | ||
| from openai import OpenAI | ||
|
|
||
| from ..config import Config | ||
|
|
||
| # Try to import Prompture; fall back to OpenAI SDK if not installed | ||
| try: | ||
| from prompture.agents import Conversation | ||
| from prompture.infra.provider_env import ProviderEnvironment | ||
| from prompture.extraction.tools import strip_think_tags, clean_json_text | ||
| _HAS_PROMPTURE = True | ||
| except ImportError: | ||
| _HAS_PROMPTURE = False | ||
|
|
||
| if not _HAS_PROMPTURE: | ||
| from openai import OpenAI | ||
|
|
||
|
|
||
| # Provider name → ProviderEnvironment field name | ||
| _KEY_MAP = { | ||
| "openai": "openai_api_key", | ||
| "claude": "claude_api_key", | ||
| "google": "google_api_key", | ||
| "groq": "groq_api_key", | ||
| "grok": "grok_api_key", | ||
| "openrouter": "openrouter_api_key", | ||
| "moonshot": "moonshot_api_key", | ||
| } | ||
|
|
||
|
|
||
| class LLMClient: | ||
| """LLM客户端""" | ||
|
|
||
| """LLM客户端 | ||
|
|
||
| When Prompture is installed, ``model`` accepts the ``"provider/model"`` | ||
| format for multi-provider support:: | ||
|
|
||
| "lmstudio/local-model" → LM Studio (free, local) | ||
| "ollama/llama3.1:8b" → Ollama (free, local) | ||
| "openai/gpt-4o" → OpenAI | ||
| "claude/claude-sonnet-4-20250514" → Anthropic | ||
| "moonshot/moonshot-v1-8k" → Kimi / Moonshot | ||
| "groq/llama-3.1-70b" → Groq | ||
|
|
||
| Without Prompture, the original OpenAI SDK backend is used (any | ||
| OpenAI-compatible API via LLM_BASE_URL). | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| api_key: Optional[str] = None, | ||
| base_url: Optional[str] = None, | ||
| model: Optional[str] = None | ||
| model: Optional[str] = None, | ||
| ): | ||
| self.api_key = api_key or Config.LLM_API_KEY | ||
| self.base_url = base_url or Config.LLM_BASE_URL | ||
| self.model = model or Config.LLM_MODEL_NAME | ||
|
|
||
|
|
||
| if _HAS_PROMPTURE: | ||
| self._init_prompture() | ||
| else: | ||
| self._init_openai() | ||
|
Comment on lines
63
to
+70
|
||
|
|
||
| # ── Prompture backend ────────────────────────────────────────── | ||
|
|
||
| def _init_prompture(self): | ||
| env_kwargs: Dict[str, Any] = {} | ||
| if self.api_key: | ||
| provider = self.model.split("/")[0] if "/" in self.model else "openai" | ||
| env_field = _KEY_MAP.get(provider) | ||
| if env_field: | ||
| env_kwargs[env_field] = self.api_key | ||
|
|
||
| self._env = ProviderEnvironment(**env_kwargs) if env_kwargs else None | ||
| self._driver_options: Dict[str, Any] = {} | ||
| if self.base_url: | ||
| self._driver_options["base_url"] = self.base_url | ||
|
|
||
|
Comment on lines
+82
to
+86
|
||
| def _make_conversation(self, temperature: float, max_tokens: int) -> "Conversation": | ||
| opts: Dict[str, Any] = { | ||
| "temperature": temperature, | ||
| "max_tokens": max_tokens, | ||
| **self._driver_options, | ||
| } | ||
| return Conversation(self.model, options=opts, env=self._env) | ||
|
|
||
| # ── OpenAI fallback backend ──────────────────────────────────── | ||
|
|
||
| def _init_openai(self): | ||
| if not self.api_key: | ||
| raise ValueError("LLM_API_KEY 未配置") | ||
|
|
||
| self.client = OpenAI( | ||
| api_key=self.api_key, | ||
| base_url=self.base_url | ||
| ) | ||
|
|
||
| self.client = OpenAI(api_key=self.api_key, base_url=self.base_url) | ||
|
|
||
| # ── Public API ───────────────────────────────────────────────── | ||
|
|
||
| def chat( | ||
| self, | ||
| messages: List[Dict[str, str]], | ||
| temperature: float = 0.7, | ||
| max_tokens: int = 4096, | ||
| response_format: Optional[Dict] = None | ||
| response_format: Optional[Dict] = None, | ||
| ) -> str: | ||
| """ | ||
| 发送聊天请求 | ||
|
|
||
| Args: | ||
| messages: 消息列表 | ||
| temperature: 温度参数 | ||
| max_tokens: 最大token数 | ||
| response_format: 响应格式(如JSON模式) | ||
|
|
||
| Returns: | ||
| 模型响应文本 | ||
| """ | ||
| kwargs = { | ||
| "model": self.model, | ||
| "messages": messages, | ||
| "temperature": temperature, | ||
| "max_tokens": max_tokens, | ||
| } | ||
|
|
||
| if response_format: | ||
| kwargs["response_format"] = response_format | ||
|
|
||
| response = self.client.chat.completions.create(**kwargs) | ||
| content = response.choices[0].message.content | ||
| # 部分模型(如MiniMax M2.5)会在content中包含<think>思考内容,需要移除 | ||
| content = re.sub(r'<think>[\s\S]*?</think>', '', content).strip() | ||
| return content | ||
|
|
||
| if _HAS_PROMPTURE: | ||
| content = self._chat_prompture(messages, temperature, max_tokens) | ||
| return strip_think_tags(content) | ||
| else: | ||
| content = self._chat_openai(messages, temperature, max_tokens, response_format) | ||
| # Fallback: strip think tags with regex when Prompture is not available | ||
| return re.sub(r'<think>[\s\S]*?</think>', '', content).strip() | ||
|
|
||
| def chat_json( | ||
| self, | ||
| messages: List[Dict[str, str]], | ||
| temperature: float = 0.3, | ||
| max_tokens: int = 4096 | ||
| max_tokens: int = 4096, | ||
| ) -> Dict[str, Any]: | ||
| """ | ||
| 发送聊天请求并返回JSON | ||
|
|
||
| Args: | ||
| messages: 消息列表 | ||
| temperature: 温度参数 | ||
| max_tokens: 最大token数 | ||
|
|
||
| Returns: | ||
| 解析后的JSON对象 | ||
| """ | ||
| response = self.chat( | ||
| messages=messages, | ||
| temperature=temperature, | ||
| max_tokens=max_tokens, | ||
| response_format={"type": "json_object"} | ||
| ) | ||
| # 清理markdown代码块标记 | ||
| cleaned_response = response.strip() | ||
| cleaned_response = re.sub(r'^```(?:json)?\s*\n?', '', cleaned_response, flags=re.IGNORECASE) | ||
| cleaned_response = re.sub(r'\n?```\s*$', '', cleaned_response) | ||
| cleaned_response = cleaned_response.strip() | ||
| if _HAS_PROMPTURE: | ||
| response = self._chat_prompture(messages, temperature, max_tokens) | ||
| # Prompture's clean_json_text strips think tags + markdown fences | ||
| cleaned = clean_json_text(response) | ||
| else: | ||
| response = self._chat_openai( | ||
| messages, temperature, max_tokens, | ||
| response_format={"type": "json_object"}, | ||
| ) | ||
|
Comment on lines
+148
to
+156
|
||
| # Fallback cleaning when Prompture is not available | ||
| cleaned = re.sub(r'<think>[\s\S]*?</think>', '', response).strip() | ||
| cleaned = re.sub(r'^```(?:json)?\s*\n?', '', cleaned, flags=re.IGNORECASE) | ||
| cleaned = re.sub(r'\n?```\s*$', '', cleaned) | ||
| cleaned = cleaned.strip() | ||
|
|
||
| try: | ||
| return json.loads(cleaned_response) | ||
| return json.loads(cleaned) | ||
| except json.JSONDecodeError: | ||
| raise ValueError(f"LLM返回的JSON格式无效: {cleaned_response}") | ||
| raise ValueError(f"LLM返回的JSON格式无效: {cleaned}") | ||
|
|
||
| # ── Private: Prompture path ──────────────────────────────────── | ||
|
|
||
| def _chat_prompture( | ||
| self, | ||
| messages: List[Dict[str, str]], | ||
| temperature: float, | ||
| max_tokens: int, | ||
| ) -> str: | ||
| conv = self._make_conversation(temperature, max_tokens) | ||
|
|
||
| # Inject system prompt | ||
| system_parts = [m["content"] for m in messages if m["role"] == "system"] | ||
| if system_parts: | ||
| conv._messages.append({"role": "system", "content": "\n".join(system_parts)}) | ||
|
|
||
| # Replay prior turns | ||
| non_system = [m for m in messages if m["role"] != "system"] | ||
| for msg in non_system[:-1]: | ||
| conv._messages.append({"role": msg["role"], "content": msg["content"]}) | ||
|
|
||
|
Comment on lines
+178
to
+187
|
||
| prompt = non_system[-1]["content"] if non_system else "" | ||
| return conv.ask(prompt) | ||
|
|
||
| # ── Private: OpenAI fallback path ────────────────────────────── | ||
|
|
||
| def _chat_openai( | ||
| self, | ||
| messages: List[Dict[str, str]], | ||
| temperature: float, | ||
| max_tokens: int, | ||
| response_format: Optional[Dict] = None, | ||
| ) -> str: | ||
| kwargs = { | ||
| "model": self.model, | ||
| "messages": messages, | ||
| "temperature": temperature, | ||
| "max_tokens": max_tokens, | ||
| } | ||
| if response_format: | ||
| kwargs["response_format"] = response_format | ||
|
|
||
| response = self.client.chat.completions.create(**kwargs) | ||
| return response.choices[0].message.content | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
README lists
uvas the required Python package manager in Prerequisites, but the new optional Prompture instructions usepip install prompture. To keep setup instructions consistent, consider usinguv pip install prompture(or mention both) so users don’t end up installing into a different environment than the one created bynpm run setup:backend.