Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 34 additions & 10 deletions .env.example
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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,29 @@ LLM_MODEL_NAME=qwen-plus
ZEP_API_KEY=your_zep_api_key
```

#### Multi-Provider Support (Optional)

Install [Prompture](https://github.com/jhd3197/prompture) to unlock 12+ LLM providers beyond OpenAI-compatible APIs:

```bash
pip install prompture
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README lists uv as the required Python package manager in Prerequisites, but the new optional Prompture instructions use pip install prompture. To keep setup instructions consistent, consider using uv pip install prompture (or mention both) so users don’t end up installing into a different environment than the one created by npm run setup:backend.

Suggested change
pip install prompture
uv pip install prompture

Copilot uses AI. Check for mistakes.
```

Then use `"provider/model"` format in your `.env`:

| Provider | `LLM_MODEL_NAME` | Cost |
|---|---|---|
| LM Studio | `lmstudio/local-model` | Free (local) |
| Ollama | `ollama/llama3.1:8b` | Free (local) |
| OpenAI | `openai/gpt-4o` | Paid |
| Claude | `claude/claude-sonnet-4-20250514` | Paid |
| Kimi / Moonshot | `moonshot/moonshot-v1-8k` | Paid |
| Groq | `groq/llama-3.1-70b-versatile` | Free tier |
| Google | `google/gemini-1.5-pro` | Paid |
| OpenRouter | `openrouter/anthropic/claude-2` | Paid |

> Without Prompture, the original OpenAI SDK backend works as before — no changes needed.

#### 2. Install Dependencies

```bash
Expand Down
201 changes: 154 additions & 47 deletions backend/app/utils/llm_client.py
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
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When Prompture is installed, the client always selects the Prompture backend regardless of LLM_MODEL_NAME. This means users with Prompture installed but still using a plain model name (e.g., qwen-plus for an OpenAI-compatible base URL) will be routed through Prompture instead of the existing OpenAI SDK path. Consider choosing the backend based on the model string format (e.g., only use Prompture when LLM_MODEL_NAME contains a provider prefix like provider/...), or add an explicit config flag to force the OpenAI backend.

Copilot uses AI. Check for mistakes.

# ── 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
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_init_prompture() always forwards base_url into Prompture driver options. Because Config.LLM_BASE_URL has a non-empty default, self.base_url will almost always be set (even when the user didn’t intend to override endpoints). This can unintentionally force non-OpenAI providers (e.g., Claude/Groq/Google) to use an OpenAI-compatible base URL. Prefer only passing base_url to Prompture when it’s explicitly configured for OpenAI-compatible/local providers, or gate it based on the parsed provider.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chat_json() enforces JSON mode only on the OpenAI backend (via response_format={"type": "json_object"}), but the Prompture path just calls _chat_prompture() without any equivalent JSON enforcement. This creates inconsistent behavior and will likely increase JSON parse failures when Prompture is installed. Add a Prompture-side option/parameter to request strict JSON output (or a schema) if supported, or fall back to the OpenAI backend for chat_json() when strict JSON mode isn’t available.

Copilot uses AI. Check for mistakes.
# 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
Copy link

Copilot AI Apr 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_chat_prompture() mutates conv._messages directly. Because this is a private/internal attribute (leading underscore), it’s not part of a stable public API and may break on Prompture upgrades. Prefer using Prompture’s public methods for adding system/history messages (or constructing the conversation with initial messages) rather than appending to _messages directly.

Copilot uses AI. Check for mistakes.
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
7 changes: 6 additions & 1 deletion backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ flask>=3.0.0
flask-cors>=6.0.0

# ============= LLM 相关 =============
# OpenAI SDK(统一使用 OpenAI 格式调用 LLM)
# OpenAI SDK(默认 LLM 后端
openai>=1.0.0

# Prompture(可选)— 多供应商 LLM 支持:LM Studio, Ollama, Claude, Groq, Kimi 等
# Install for multi-provider support: pip install prompture
# https://github.com/jhd3197/prompture
# prompture>=0.1.0

# ============= Zep Cloud =============
zep-cloud==3.13.0

Expand Down
Loading