refactor: 支持多 LLM 提供商自动检测
This commit is contained in:
parent
f25eb4ecfc
commit
c2aff3e4a6
172
README.md
172
README.md
|
|
@ -4,14 +4,15 @@
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- 💬 **多轮对话** - 支持上下文管理的多轮对话
|
- 多轮对话 - 支持上下文管理的多轮对话
|
||||||
- 🔧 **工具调用** - 网页搜索、代码执行、文件操作等
|
- 工具调用 - 网页搜索、代码执行、文件操作等 13 个内置工具
|
||||||
- 🧠 **思维链** - 支持链式思考推理
|
- 思维链 - 支持链式思考推理(DeepSeek R1 / GLM thinking)
|
||||||
- 📁 **工作目录** - 项目级文件隔离,安全操作
|
- 工作目录 - 项目级文件隔离,安全操作
|
||||||
- 📊 **Token 统计** - 按日/周/月统计使用量
|
- Token 统计 - 按日/周/月统计使用量
|
||||||
- 🔄 **流式响应** - 实时 SSE 流式输出
|
- 流式响应 - 实时 SSE 流式输出,穿插显示思考/文本/工具调用
|
||||||
- 📝 **代码编辑器** - 基于 CodeMirror 6,支持 15+ 语言语法高亮和暗色主题
|
- 代码编辑器 - 基于 CodeMirror 6,支持 15+ 语言语法高亮和暗色主题
|
||||||
- 💾 **多数据库** - 支持 MySQL、SQLite、PostgreSQL
|
- 多数据库 - 支持 MySQL、SQLite、PostgreSQL
|
||||||
|
- 多用户/单用户 - 支持单用户免登录和多用户 JWT 认证两种模式
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
|
|
@ -32,37 +33,47 @@ pip install -e .
|
||||||
backend_port: 3000
|
backend_port: 3000
|
||||||
frontend_port: 4000
|
frontend_port: 4000
|
||||||
|
|
||||||
# LLM API (global defaults, can be overridden per model)
|
# Max agentic loop iterations (tool call rounds)
|
||||||
default_api_key: {{your-api-key}}
|
max_iterations: 15
|
||||||
default_api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
|
|
||||||
|
|
||||||
# Available models (each model can optionally specify its own api_key and api_url)
|
# Available models
|
||||||
|
# Each model must have its own id, name, api_url, api_key
|
||||||
models:
|
models:
|
||||||
|
- id: deepseek-chat
|
||||||
|
name: DeepSeek V3
|
||||||
|
api_url: https://api.deepseek.com/chat/completions
|
||||||
|
api_key: sk-xxx
|
||||||
- id: glm-5
|
- id: glm-5
|
||||||
name: GLM-5
|
name: GLM-5
|
||||||
# api_key: xxx # Optional, falls back to default_api_key
|
api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
|
||||||
# api_url: xxx # Optional, falls back to default_api_url
|
api_key: xxx
|
||||||
- id: glm-4-plus
|
|
||||||
name: GLM-4 Plus
|
|
||||||
|
|
||||||
default_model: glm-5
|
default_model: deepseek-chat
|
||||||
|
|
||||||
# Workspace root directory
|
|
||||||
workspace_root: ./workspaces
|
|
||||||
|
|
||||||
# Authentication
|
|
||||||
# "single": Single-user mode - no login required, auto-creates default user
|
|
||||||
# "multi": Multi-user mode - requires JWT, users must register/login
|
|
||||||
auth_mode: single
|
|
||||||
|
|
||||||
# JWT secret (only used in multi-user mode, change for production!)
|
|
||||||
jwt_secret: nano-claw-default-secret-change-in-production
|
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
|
# Supported types: mysql, sqlite, postgresql
|
||||||
db_type: sqlite
|
db_type: sqlite
|
||||||
|
|
||||||
|
# MySQL/PostgreSQL Settings (ignored for sqlite)
|
||||||
|
# db_host: localhost
|
||||||
|
# db_port: 3306
|
||||||
|
# db_user: root
|
||||||
|
# db_password: "123456"
|
||||||
|
# db_name: nano_claw
|
||||||
|
|
||||||
|
# SQLite Settings (ignored for mysql/postgresql)
|
||||||
db_sqlite_file: nano_claw.db
|
db_sqlite_file: nano_claw.db
|
||||||
|
|
||||||
|
# Workspace Configuration
|
||||||
|
workspace_root: ./workspaces
|
||||||
|
|
||||||
|
# Authentication (optional, defaults to single-user mode)
|
||||||
|
# auth_mode: single # "single" (default) or "multi"
|
||||||
|
# jwt_secret: nano-claw-default-secret-change-in-production
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **说明**:`api_key` 支持环境变量替换,例如 `api_key: ${DEEPSEEK_API_KEY}`。
|
||||||
|
|
||||||
### 3. 启动后端
|
### 3. 启动后端
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -81,30 +92,42 @@ npm run dev
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
|
├── __init__.py # 应用工厂,数据库初始化,load_config()
|
||||||
|
├── config.py # 配置加载与验证
|
||||||
├── models.py # SQLAlchemy 数据模型
|
├── models.py # SQLAlchemy 数据模型
|
||||||
|
├── run.py # 入口文件
|
||||||
├── routes/ # API 路由
|
├── routes/ # API 路由
|
||||||
│ ├── auth.py # 认证(登录/注册/JWT)
|
│ ├── auth.py # 认证(登录/注册/JWT/profile)
|
||||||
│ ├── conversations.py
|
│ ├── conversations.py # 会话 CRUD
|
||||||
│ ├── messages.py
|
│ ├── messages.py # 消息 CRUD + 聊天(SSE 流式)
|
||||||
│ ├── projects.py # 项目管理
|
│ ├── models.py # 模型列表
|
||||||
│ └── ...
|
│ ├── projects.py # 项目管理 + 文件操作
|
||||||
|
│ ├── stats.py # Token 使用统计
|
||||||
|
│ └── tools.py # 工具列表
|
||||||
├── services/ # 业务逻辑
|
├── services/ # 业务逻辑
|
||||||
│ ├── chat.py # 聊天补全服务
|
│ ├── chat.py # 聊天补全服务(SSE 流式 + 多轮工具调用)
|
||||||
│ └── glm_client.py
|
│ └── llm_client.py # OpenAI 兼容 LLM API 客户端
|
||||||
├── tools/ # 工具系统
|
├── tools/ # 工具系统
|
||||||
│ ├── core.py # 核心类
|
│ ├── core.py # 核心类(ToolDefinition/ToolRegistry)
|
||||||
│ ├── executor.py # 工具执行器
|
│ ├── factory.py # @tool 装饰器 + register_tool()
|
||||||
|
│ ├── executor.py # 工具执行器(缓存、去重、上下文注入)
|
||||||
|
│ ├── services.py # 辅助服务(搜索/抓取/计算)
|
||||||
│ └── builtin/ # 内置工具
|
│ └── builtin/ # 内置工具
|
||||||
├── utils/ # 辅助函数
|
│ ├── crawler.py # 网页搜索、抓取
|
||||||
│ ├── helpers.py
|
│ ├── data.py # 计算器、文本、JSON 处理
|
||||||
│ └── workspace.py # 工作目录工具
|
│ ├── weather.py # 天气查询(模拟)
|
||||||
└── migrations/ # 数据库迁移
|
│ ├── file_ops.py # 文件操作(6 个工具,project_id 自动注入)
|
||||||
|
│ └── code.py # Python 代码执行(沙箱)
|
||||||
|
└── utils/ # 辅助函数
|
||||||
|
├── helpers.py # 通用函数(ok/err/build_messages 等)
|
||||||
|
└── workspace.py # 工作目录工具(路径验证、项目目录管理)
|
||||||
|
|
||||||
frontend/
|
frontend/
|
||||||
└── src/
|
└── src/
|
||||||
├── api/ # API 请求层
|
├── api/ # API 请求层(request + SSE 流解析)
|
||||||
├── components/ # Vue 组件
|
├── components/ # Vue 组件(12 个)
|
||||||
└── views/ # 页面
|
├── composables/ # 组合式函数(主题/模态框/Toast)
|
||||||
|
└── utils/ # 工具模块(Markdown 渲染/代码高亮/图标)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 工作目录系统
|
## 工作目录系统
|
||||||
|
|
@ -117,28 +140,63 @@ frontend/
|
||||||
|
|
||||||
1. **创建项目** - 在侧边栏点击"新建项目"或上传文件夹
|
1. **创建项目** - 在侧边栏点击"新建项目"或上传文件夹
|
||||||
2. **选择项目** - 在对话中选择当前工作目录
|
2. **选择项目** - 在对话中选择当前工作目录
|
||||||
3. **文件操作** - AI 自动在项目目录内执行文件操作
|
3. **文件操作** - AI 自动在项目目录内执行文件操作(`project_id` 由后端自动注入,对 AI 透明)
|
||||||
|
|
||||||
### 安全机制
|
### 安全机制
|
||||||
|
|
||||||
- 所有文件操作需要 `project_id` 参数
|
- 所有文件工具的 `project_id` 由后端自动注入,AI 不可见也不可伪造
|
||||||
- 后端强制验证路径在项目目录内
|
- 后端强制验证路径在项目目录内
|
||||||
- 阻止目录遍历攻击(如 `../../../etc/passwd`)
|
- 阻止目录遍历攻击(如 `../../../etc/passwd`)
|
||||||
|
|
||||||
## API 概览
|
## API 概览
|
||||||
|
|
||||||
|
### 认证
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
|
| `GET` | `/api/auth/mode` | 获取当前认证模式 |
|
||||||
| `POST` | `/api/auth/login` | 用户登录 |
|
| `POST` | `/api/auth/login` | 用户登录 |
|
||||||
| `POST` | `/api/auth/register` | 用户注册 |
|
| `POST` | `/api/auth/register` | 用户注册 |
|
||||||
| `GET` | `/api/conversations` | 会话列表 |
|
| `GET` | `/api/auth/profile` | 获取当前用户信息 |
|
||||||
|
| `PATCH` | `/api/auth/profile` | 更新当前用户信息 |
|
||||||
|
|
||||||
|
### 会话管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET/POST` | `/api/conversations` | 会话列表 / 创建会话 |
|
||||||
|
| `GET` | `/api/conversations/:id` | 会话详情 |
|
||||||
|
| `PATCH` | `/api/conversations/:id` | 更新会话 |
|
||||||
|
| `DELETE` | `/api/conversations/:id` | 删除会话 |
|
||||||
|
|
||||||
|
### 消息管理
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
| `GET` | `/api/conversations/:id/messages` | 消息列表 |
|
| `GET` | `/api/conversations/:id/messages` | 消息列表 |
|
||||||
| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE 流式) |
|
| `POST` | `/api/conversations/:id/messages` | 发送消息(SSE 流式) |
|
||||||
| `GET` | `/api/projects` | 项目列表 |
|
| `DELETE` | `/api/conversations/:id/messages/:mid` | 删除消息 |
|
||||||
| `POST` | `/api/projects` | 创建项目 |
|
| `POST` | `/api/conversations/:id/regenerate/:mid` | 重新生成消息 |
|
||||||
| `POST` | `/api/projects/upload` | 上传文件夹 |
|
|
||||||
| `GET` | `/api/tools` | 工具列表 |
|
### 项目管理
|
||||||
| `GET` | `/api/stats/tokens` | Token 统计 |
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET/POST` | `/api/projects` | 项目列表 / 创建项目 |
|
||||||
|
| `GET/PUT/DELETE` | `/api/projects/:id` | 项目详情 / 更新 / 删除 |
|
||||||
|
| `POST` | `/api/projects/upload` | 上传文件夹作为项目 |
|
||||||
|
| `GET` | `/api/projects/:id/files` | 列出项目文件 |
|
||||||
|
| `GET/PUT/PATCH/DELETE` | `/api/projects/:id/files/:path` | 文件 CRUD |
|
||||||
|
| `POST` | `/api/projects/:id/directories` | 创建目录 |
|
||||||
|
| `POST` | `/api/projects/:id/search` | 搜索文件内容 |
|
||||||
|
|
||||||
|
### 其他
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GET` | `/api/models` | 获取可用模型列表 |
|
||||||
|
| `GET` | `/api/tools` | 获取工具列表 |
|
||||||
|
| `GET` | `/api/stats/tokens` | Token 使用统计 |
|
||||||
|
|
||||||
## 内置工具
|
## 内置工具
|
||||||
|
|
||||||
|
|
@ -147,16 +205,16 @@ frontend/
|
||||||
| **爬虫** | web_search, fetch_page, crawl_batch | 网页搜索和抓取 |
|
| **爬虫** | web_search, fetch_page, crawl_batch | 网页搜索和抓取 |
|
||||||
| **数据处理** | calculator, text_process, json_process | 数学计算和文本处理 |
|
| **数据处理** | calculator, text_process, json_process | 数学计算和文本处理 |
|
||||||
| **代码执行** | execute_python | 沙箱环境执行 Python |
|
| **代码执行** | execute_python | 沙箱环境执行 Python |
|
||||||
| **文件操作** | file_read, file_write, file_list 等 | **需要 project_id** |
|
| **文件操作** | file_read, file_write, file_delete, file_list, file_exists, file_mkdir | project_id 自动注入 |
|
||||||
| **天气** | get_weather | 天气查询(模拟) |
|
| **天气** | get_weather | 天气查询(模拟) |
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
- [后端设计](docs/Design.md) - 架构设计、数据模型、API 文档
|
- [后端设计](docs/Design.md) - 架构设计、数据模型、API 文档、SSE 事件
|
||||||
- [工具系统](docs/ToolSystemDesign.md) - 工具开发指南、安全设计
|
- [工具系统](docs/ToolSystemDesign.md) - 工具开发指南、安全设计
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- **后端**: Python 3.11+, Flask, SQLAlchemy
|
- **后端**: Python 3.10+, Flask, SQLAlchemy, requests
|
||||||
- **前端**: Vue 3, Vite, CodeMirror 6
|
- **前端**: Vue 3, Vite 6, CodeMirror 6, marked, highlight.js, KaTeX
|
||||||
- **LLM**: 支持 GLM 等大语言模型
|
- **LLM**: 支持任何 OpenAI 兼容 API(DeepSeek、GLM、OpenAI、Moonshot、Qwen 等)
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,35 @@
|
||||||
"""Configuration management"""
|
"""Configuration management"""
|
||||||
|
import sys
|
||||||
from backend import load_config
|
from backend import load_config
|
||||||
|
|
||||||
_cfg = load_config()
|
_cfg = load_config()
|
||||||
|
|
||||||
# Global defaults
|
|
||||||
DEFAULT_API_URL = _cfg.get("api_url", "") or _cfg.get("default_api_url", "")
|
|
||||||
DEFAULT_API_KEY = _cfg.get("api_key", "") or _cfg.get("default_api_key", "")
|
|
||||||
|
|
||||||
# Model list (for /api/models endpoint)
|
# Model list (for /api/models endpoint)
|
||||||
MODELS = _cfg.get("models", [])
|
MODELS = _cfg.get("models", [])
|
||||||
|
|
||||||
# Per-model config lookup: {model_id: {api_url, api_key}}
|
# Validate each model has required fields at startup
|
||||||
# Falls back to global defaults if not specified per model
|
_REQUIRED_MODEL_KEYS = {"id", "name", "api_url", "api_key"}
|
||||||
MODEL_CONFIG = {}
|
_model_ids_seen = set()
|
||||||
for _m in MODELS:
|
for _i, _m in enumerate(MODELS):
|
||||||
_mid = _m["id"]
|
_missing = _REQUIRED_MODEL_KEYS - set(_m.keys())
|
||||||
MODEL_CONFIG[_mid] = {
|
if _missing:
|
||||||
"api_url": _m.get("api_url", DEFAULT_API_URL),
|
print(f"[config] ERROR: models[{_i}] missing required fields: {_missing}", file=sys.stderr)
|
||||||
"api_key": _m.get("api_key", DEFAULT_API_KEY),
|
sys.exit(1)
|
||||||
}
|
if _m["id"] in _model_ids_seen:
|
||||||
|
print(f"[config] ERROR: duplicate model id '{_m['id']}'", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
_model_ids_seen.add(_m["id"])
|
||||||
|
|
||||||
DEFAULT_MODEL = _cfg.get("default_model", "glm-5")
|
# Per-model config lookup: {model_id: {api_url, api_key}}
|
||||||
|
MODEL_CONFIG = {m["id"]: {"api_url": m["api_url"], "api_key": m["api_key"]} for m in MODELS}
|
||||||
|
|
||||||
|
# default_model must exist in models
|
||||||
|
DEFAULT_MODEL = _cfg.get("default_model", "")
|
||||||
|
if DEFAULT_MODEL and DEFAULT_MODEL not in MODEL_CONFIG:
|
||||||
|
print(f"[config] ERROR: default_model '{DEFAULT_MODEL}' not found in models", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if MODELS and not DEFAULT_MODEL:
|
||||||
|
DEFAULT_MODEL = MODELS[0]["id"]
|
||||||
|
|
||||||
# Max agentic loop iterations (tool call rounds)
|
# Max agentic loop iterations (tool call rounds)
|
||||||
MAX_ITERATIONS = _cfg.get("max_iterations", 5)
|
MAX_ITERATIONS = _cfg.get("max_iterations", 5)
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,15 @@ from backend.routes.tools import bp as tools_bp
|
||||||
from backend.routes.stats import bp as stats_bp
|
from backend.routes.stats import bp as stats_bp
|
||||||
from backend.routes.projects import bp as projects_bp
|
from backend.routes.projects import bp as projects_bp
|
||||||
from backend.routes.auth import bp as auth_bp, init_auth
|
from backend.routes.auth import bp as auth_bp, init_auth
|
||||||
from backend.services.glm_client import GLMClient
|
from backend.services.llm_client import LLMClient
|
||||||
from backend.config import MODEL_CONFIG
|
from backend.config import MODEL_CONFIG
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app: Flask):
|
def register_routes(app: Flask):
|
||||||
"""Register all route blueprints"""
|
"""Register all route blueprints"""
|
||||||
# Initialize GLM client with per-model config
|
# Initialize LLM client with per-model config
|
||||||
glm_client = GLMClient(MODEL_CONFIG)
|
client = LLMClient(MODEL_CONFIG)
|
||||||
init_chat_service(glm_client)
|
init_chat_service(client)
|
||||||
|
|
||||||
# Initialize authentication system (reads auth_mode from config.yml)
|
# Initialize authentication system (reads auth_mode from config.yml)
|
||||||
init_auth(app)
|
init_auth(app)
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@ bp = Blueprint("messages", __name__)
|
||||||
_chat_service = None
|
_chat_service = None
|
||||||
|
|
||||||
|
|
||||||
def init_chat_service(glm_client):
|
def init_chat_service(client):
|
||||||
"""Initialize chat service with GLM client"""
|
"""Initialize chat service with LLM client"""
|
||||||
global _chat_service
|
global _chat_service
|
||||||
_chat_service = ChatService(glm_client)
|
_chat_service = ChatService(client)
|
||||||
|
|
||||||
|
|
||||||
def _get_conv(conv_id):
|
def _get_conv(conv_id):
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,15 @@ from backend.config import MODELS
|
||||||
|
|
||||||
bp = Blueprint("models", __name__)
|
bp = Blueprint("models", __name__)
|
||||||
|
|
||||||
|
# Keys that should never be exposed to the frontend
|
||||||
|
_SENSITIVE_KEYS = {"api_key", "api_url"}
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/api/models", methods=["GET"])
|
@bp.route("/api/models", methods=["GET"])
|
||||||
def list_models():
|
def list_models():
|
||||||
"""Get available model list"""
|
"""Get available model list (without sensitive fields like api_key)"""
|
||||||
return ok(MODELS)
|
safe_models = [
|
||||||
|
{k: v for k, v in m.items() if k not in _SENSITIVE_KEYS}
|
||||||
|
for m in MODELS
|
||||||
|
]
|
||||||
|
return ok(safe_models)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"""Backend services"""
|
"""Backend services"""
|
||||||
from backend.services.glm_client import GLMClient
|
from backend.services.llm_client import LLMClient
|
||||||
from backend.services.chat import ChatService
|
from backend.services.chat import ChatService
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"GLMClient",
|
"LLMClient",
|
||||||
"ChatService",
|
"ChatService",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,15 @@ from backend.utils.helpers import (
|
||||||
record_token_usage,
|
record_token_usage,
|
||||||
build_messages,
|
build_messages,
|
||||||
)
|
)
|
||||||
from backend.services.glm_client import GLMClient
|
from backend.services.llm_client import LLMClient
|
||||||
from backend.config import MAX_ITERATIONS
|
from backend.config import MAX_ITERATIONS
|
||||||
|
|
||||||
|
|
||||||
class ChatService:
|
class ChatService:
|
||||||
"""Chat completion service with tool support"""
|
"""Chat completion service with tool support"""
|
||||||
|
|
||||||
def __init__(self, glm_client: GLMClient):
|
def __init__(self, llm: LLMClient):
|
||||||
self.glm_client = glm_client
|
self.llm = llm
|
||||||
self.executor = ToolExecutor(registry=registry)
|
self.executor = ToolExecutor(registry=registry)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -76,7 +76,7 @@ class ChatService:
|
||||||
try:
|
try:
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
active_conv = db.session.get(Conversation, conv_id)
|
active_conv = db.session.get(Conversation, conv_id)
|
||||||
resp = self.glm_client.call(
|
resp = self.llm.call(
|
||||||
model=active_conv.model,
|
model=active_conv.model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
max_tokens=active_conv.max_tokens,
|
max_tokens=active_conv.max_tokens,
|
||||||
|
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
"""GLM API client"""
|
|
||||||
import requests
|
|
||||||
from typing import Optional, List
|
|
||||||
|
|
||||||
|
|
||||||
class GLMClient:
|
|
||||||
"""GLM API client for chat completions"""
|
|
||||||
|
|
||||||
def __init__(self, model_config: dict):
|
|
||||||
"""Initialize with per-model config lookup.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
model_config: {model_id: {"api_url": ..., "api_key": ...}}
|
|
||||||
"""
|
|
||||||
self.model_config = model_config
|
|
||||||
|
|
||||||
def _get_credentials(self, model: str):
|
|
||||||
"""Get api_url and api_key for a model, with fallback."""
|
|
||||||
cfg = self.model_config.get(model, {})
|
|
||||||
return cfg.get("api_url", ""), cfg.get("api_key", "")
|
|
||||||
|
|
||||||
def call(
|
|
||||||
self,
|
|
||||||
model: str,
|
|
||||||
messages: List[dict],
|
|
||||||
max_tokens: int = 65536,
|
|
||||||
temperature: float = 1.0,
|
|
||||||
thinking_enabled: bool = False,
|
|
||||||
tools: Optional[List[dict]] = None,
|
|
||||||
stream: bool = False,
|
|
||||||
timeout: int = 120,
|
|
||||||
):
|
|
||||||
"""Call GLM API"""
|
|
||||||
api_url, api_key = self._get_credentials(model)
|
|
||||||
body = {
|
|
||||||
"model": model,
|
|
||||||
"messages": messages,
|
|
||||||
"max_tokens": max_tokens,
|
|
||||||
"temperature": temperature,
|
|
||||||
}
|
|
||||||
if thinking_enabled:
|
|
||||||
body["thinking"] = {"type": "enabled"}
|
|
||||||
if tools:
|
|
||||||
body["tools"] = tools
|
|
||||||
body["tool_choice"] = "auto"
|
|
||||||
if stream:
|
|
||||||
body["stream"] = True
|
|
||||||
body["stream_options"] = {"include_usage": True}
|
|
||||||
|
|
||||||
return requests.post(
|
|
||||||
api_url,
|
|
||||||
headers={
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {api_key}"
|
|
||||||
},
|
|
||||||
json=body,
|
|
||||||
stream=stream,
|
|
||||||
timeout=timeout,
|
|
||||||
)
|
|
||||||
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""OpenAI-compatible LLM API client
|
||||||
|
|
||||||
|
Supports any provider that follows the OpenAI chat completions API format:
|
||||||
|
- Zhipu GLM (open.bigmodel.cn)
|
||||||
|
- DeepSeek (api.deepseek.com)
|
||||||
|
- OpenAI, Moonshot, Qwen, etc.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_env_vars(value: str) -> str:
|
||||||
|
"""Replace ${VAR} or $VAR with environment variable values."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
def _replace(m):
|
||||||
|
var = m.group(1) or m.group(2)
|
||||||
|
return os.environ.get(var, m.group(0))
|
||||||
|
return re.sub(r'\$\{(\w+)\}|\$(\w+)', _replace, value)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_provider(api_url: str) -> str:
|
||||||
|
"""Detect provider from api_url, returns provider name."""
|
||||||
|
if "deepseek" in api_url:
|
||||||
|
return "deepseek"
|
||||||
|
elif "bigmodel" in api_url:
|
||||||
|
return "glm"
|
||||||
|
else:
|
||||||
|
return "openai"
|
||||||
|
|
||||||
|
|
||||||
|
class LLMClient:
|
||||||
|
"""OpenAI-compatible LLM API client.
|
||||||
|
|
||||||
|
Each model must have its own api_url and api_key configured in MODEL_CONFIG.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model_config: dict):
|
||||||
|
"""Initialize with per-model config lookup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_config: {model_id: {"api_url": ..., "api_key": ...}}
|
||||||
|
"""
|
||||||
|
self.model_config = model_config
|
||||||
|
|
||||||
|
def _get_credentials(self, model: str):
|
||||||
|
"""Get api_url and api_key for a model, with env-var expansion."""
|
||||||
|
cfg = self.model_config.get(model)
|
||||||
|
if not cfg:
|
||||||
|
raise ValueError(f"Unknown model: '{model}', not found in config")
|
||||||
|
api_url = _resolve_env_vars(cfg.get("api_url", ""))
|
||||||
|
api_key = _resolve_env_vars(cfg.get("api_key", ""))
|
||||||
|
if not api_url:
|
||||||
|
raise ValueError(f"Model '{model}' has no api_url configured")
|
||||||
|
if not api_key:
|
||||||
|
raise ValueError(f"Model '{model}' has no api_key configured")
|
||||||
|
return api_url, api_key
|
||||||
|
|
||||||
|
def _build_body(self, model, messages, max_tokens, temperature, thinking_enabled, tools, stream, api_url):
|
||||||
|
"""Build request body with provider-specific parameter adaptation."""
|
||||||
|
provider = _detect_provider(api_url)
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
"temperature": temperature,
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Provider-specific: max_tokens ---
|
||||||
|
if provider == "deepseek":
|
||||||
|
body["max_tokens"] = min(max_tokens, 8192)
|
||||||
|
elif provider == "glm":
|
||||||
|
body["max_tokens"] = min(max_tokens, 65536)
|
||||||
|
else:
|
||||||
|
body["max_tokens"] = max_tokens
|
||||||
|
|
||||||
|
# --- Provider-specific: thinking ---
|
||||||
|
if thinking_enabled:
|
||||||
|
if provider == "glm":
|
||||||
|
body["thinking"] = {"type": "enabled"}
|
||||||
|
elif provider == "deepseek":
|
||||||
|
pass # deepseek-reasoner has built-in reasoning, no extra param
|
||||||
|
|
||||||
|
# --- Provider-specific: tools ---
|
||||||
|
if tools:
|
||||||
|
body["tools"] = tools
|
||||||
|
body["tool_choice"] = "auto"
|
||||||
|
|
||||||
|
# --- Provider-specific: stream ---
|
||||||
|
if stream:
|
||||||
|
body["stream"] = True
|
||||||
|
if provider == "glm":
|
||||||
|
body["stream_options"] = {"include_usage": True}
|
||||||
|
elif provider == "deepseek":
|
||||||
|
pass # DeepSeek does not support stream_options
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
def call(
|
||||||
|
self,
|
||||||
|
model: str,
|
||||||
|
messages: List[dict],
|
||||||
|
max_tokens: int = 65536,
|
||||||
|
temperature: float = 1.0,
|
||||||
|
thinking_enabled: bool = False,
|
||||||
|
tools: Optional[List[dict]] = None,
|
||||||
|
stream: bool = False,
|
||||||
|
timeout: int = 120,
|
||||||
|
max_retries: int = 3,
|
||||||
|
):
|
||||||
|
"""Call LLM API with retry on rate limit (429)"""
|
||||||
|
api_url, api_key = self._get_credentials(model)
|
||||||
|
body = self._build_body(
|
||||||
|
model, messages, max_tokens, temperature,
|
||||||
|
thinking_enabled, tools, stream, api_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
for attempt in range(max_retries + 1):
|
||||||
|
resp = requests.post(
|
||||||
|
api_url,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {api_key}"
|
||||||
|
},
|
||||||
|
json=body,
|
||||||
|
stream=stream,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 429 and attempt < max_retries:
|
||||||
|
wait = 2 ** attempt
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
||||||
|
return resp
|
||||||
|
|
@ -9,21 +9,28 @@ from pathlib import Path
|
||||||
from backend.tools.factory import tool
|
from backend.tools.factory import tool
|
||||||
|
|
||||||
|
|
||||||
# Whitelist of allowed modules
|
# Blacklist of dangerous modules - all other modules are allowed
|
||||||
ALLOWED_MODULES = {
|
BLOCKED_MODULES = {
|
||||||
# Standard library - math and data processing
|
# System-level access
|
||||||
"math", "random", "statistics", "itertools", "functools", "operator",
|
"os", "sys", "subprocess", "shutil", "signal", "ctypes",
|
||||||
"collections", "decimal", "fractions", "numbers",
|
"multiprocessing", "threading", "_thread",
|
||||||
# String processing
|
# Network access
|
||||||
"string", "re", "textwrap", "unicodedata",
|
"socket", "http", "urllib", "requests", "ftplib", "smtplib",
|
||||||
# Data formats
|
"telnetlib", "xmlrpc", "asyncio",
|
||||||
"json", "csv", "datetime", "time",
|
# File system / I/O
|
||||||
# Data structures
|
"pathlib", "io", "glob", "tempfile", "shutil", "fnmatch",
|
||||||
"heapq", "bisect", "array", "copy",
|
# Code execution / introspection
|
||||||
# Type related
|
"importlib", "pkgutil", "code", "codeop", "compileall",
|
||||||
"typing", "types", "dataclasses",
|
"runpy", "pdb", "profile", "cProfile",
|
||||||
|
# Dangerous stdlib
|
||||||
|
"webbrowser", "antigravity", "turtle",
|
||||||
|
# IPC / persistence
|
||||||
|
"pickle", "shelve", "marshal", "sqlite3", "dbm",
|
||||||
|
# Process / shell
|
||||||
|
"commands", "pipes", "pty", "posix", "posixpath",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Blacklist of dangerous builtins
|
# Blacklist of dangerous builtins
|
||||||
BLOCKED_BUILTINS = {
|
BLOCKED_BUILTINS = {
|
||||||
"eval", "exec", "compile", "open", "input",
|
"eval", "exec", "compile", "open", "input",
|
||||||
|
|
@ -35,13 +42,14 @@ BLOCKED_BUILTINS = {
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
name="execute_python",
|
name="execute_python",
|
||||||
description="Execute Python code in a sandboxed environment. Supports math, data processing, and string operations. Max execution time: 10 seconds.",
|
description="Execute Python code in a sandboxed environment. Most standard library modules are allowed, with dangerous modules (os, subprocess, socket, etc.) blocked. Max execution time: 10 seconds.",
|
||||||
parameters={
|
parameters={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": {
|
"code": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Python code to execute. Only standard library modules allowed."
|
"description": "Python code to execute. Dangerous modules (os, subprocess, socket, etc.) are blocked."
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["code"]
|
"required": ["code"]
|
||||||
|
|
@ -53,7 +61,7 @@ def execute_python(arguments: dict) -> dict:
|
||||||
Execute Python code safely with sandboxing.
|
Execute Python code safely with sandboxing.
|
||||||
|
|
||||||
Security measures:
|
Security measures:
|
||||||
1. Restricted imports (whitelist)
|
1. Blocked dangerous imports (blacklist)
|
||||||
2. Blocked dangerous builtins
|
2. Blocked dangerous builtins
|
||||||
3. Timeout limit (10s)
|
3. Timeout limit (10s)
|
||||||
4. No file system access
|
4. No file system access
|
||||||
|
|
@ -66,7 +74,7 @@ def execute_python(arguments: dict) -> dict:
|
||||||
if dangerous_imports:
|
if dangerous_imports:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": f"Blocked imports: {', '.join(dangerous_imports)}. Only standard library modules allowed: {', '.join(sorted(ALLOWED_MODULES))}"
|
"error": f"Blocked imports: {', '.join(dangerous_imports)}. These modules are not allowed for security reasons."
|
||||||
}
|
}
|
||||||
|
|
||||||
# Security check: detect dangerous function calls
|
# Security check: detect dangerous function calls
|
||||||
|
|
@ -124,7 +132,7 @@ def _build_safe_code(code: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _check_dangerous_imports(code: str) -> list:
|
def _check_dangerous_imports(code: str) -> list:
|
||||||
"""Check for disallowed imports"""
|
"""Check for blocked (blacklisted) imports"""
|
||||||
try:
|
try:
|
||||||
tree = ast.parse(code)
|
tree = ast.parse(code)
|
||||||
except SyntaxError:
|
except SyntaxError:
|
||||||
|
|
@ -135,12 +143,12 @@ def _check_dangerous_imports(code: str) -> list:
|
||||||
if isinstance(node, ast.Import):
|
if isinstance(node, ast.Import):
|
||||||
for alias in node.names:
|
for alias in node.names:
|
||||||
module = alias.name.split(".")[0]
|
module = alias.name.split(".")[0]
|
||||||
if module not in ALLOWED_MODULES:
|
if module in BLOCKED_MODULES:
|
||||||
dangerous.append(module)
|
dangerous.append(module)
|
||||||
elif isinstance(node, ast.ImportFrom):
|
elif isinstance(node, ast.ImportFrom):
|
||||||
if node.module:
|
if node.module:
|
||||||
module = node.module.split(".")[0]
|
module = node.module.split(".")[0]
|
||||||
if module not in ALLOWED_MODULES:
|
if module in BLOCKED_MODULES:
|
||||||
dangerous.append(module)
|
dangerous.append(module)
|
||||||
|
|
||||||
return dangerous
|
return dangerous
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ backend/
|
||||||
├── services/ # 业务逻辑
|
├── services/ # 业务逻辑
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── chat.py # 聊天补全服务
|
│ ├── chat.py # 聊天补全服务
|
||||||
│ └── glm_client.py # GLM API 客户端
|
│ └── llm_client.py # OpenAI 兼容 LLM API 客户端
|
||||||
│
|
│
|
||||||
├── tools/ # 工具系统
|
├── tools/ # 工具系统
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
|
|
@ -72,9 +72,6 @@ backend/
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── helpers.py # 通用函数
|
│ ├── helpers.py # 通用函数
|
||||||
│ └── workspace.py # 工作目录工具
|
│ └── workspace.py # 工作目录工具
|
||||||
│
|
|
||||||
└── migrations/ # 数据库迁移
|
|
||||||
└── add_project_support.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -250,16 +247,18 @@ classDiagram
|
||||||
direction TB
|
direction TB
|
||||||
|
|
||||||
class ChatService {
|
class ChatService {
|
||||||
-GLMClient glm_client
|
-LLMClient llm
|
||||||
-ToolExecutor executor
|
-ToolExecutor executor
|
||||||
+stream_response(conv, tools_enabled, project_id) Response
|
+stream_response(conv, tools_enabled, project_id) Response
|
||||||
-_build_tool_calls_json(calls, results) list
|
-_build_tool_calls_json(calls, results) list
|
||||||
-_process_tool_calls_delta(delta, list) list
|
-_process_tool_calls_delta(delta, list) list
|
||||||
}
|
}
|
||||||
|
|
||||||
class GLMClient {
|
class LLMClient {
|
||||||
-dict model_config
|
-dict model_config
|
||||||
+_get_credentials(model) (api_url, api_key)
|
+_get_credentials(model) (api_url, api_key)
|
||||||
|
+_detect_provider(api_url) str
|
||||||
|
+_build_body(model, messages, ...) dict
|
||||||
+call(model, messages, kwargs) Response
|
+call(model, messages, kwargs) Response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -268,11 +267,10 @@ classDiagram
|
||||||
-dict _cache
|
-dict _cache
|
||||||
-list _call_history
|
-list _call_history
|
||||||
+process_tool_calls(calls, context) list
|
+process_tool_calls(calls, context) list
|
||||||
+build_request(messages, model, tools) dict
|
|
||||||
+clear_history() void
|
+clear_history() void
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatService --> GLMClient : 使用
|
ChatService --> LLMClient : 使用
|
||||||
ChatService --> ToolExecutor : 使用
|
ChatService --> ToolExecutor : 使用
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -356,6 +354,9 @@ def delete_project_directory(project_path: str) -> bool:
|
||||||
|
|
||||||
def copy_folder_to_project(source_path: str, project_dir: Path, project_name: str) -> dict:
|
def copy_folder_to_project(source_path: str, project_dir: Path, project_name: str) -> dict:
|
||||||
"""复制文件夹到项目目录"""
|
"""复制文件夹到项目目录"""
|
||||||
|
|
||||||
|
def save_uploaded_files(files, project_dir: Path) -> dict:
|
||||||
|
"""保存上传文件到项目目录"""
|
||||||
```
|
```
|
||||||
|
|
||||||
### 安全机制
|
### 安全机制
|
||||||
|
|
@ -548,17 +549,17 @@ def process_tool_calls(self, tool_calls, context=None):
|
||||||
1. **读取**:通过 `response.body.getReader()` 获取可读流,循环 `reader.read()` 读取二进制 chunk
|
1. **读取**:通过 `response.body.getReader()` 获取可读流,循环 `reader.read()` 读取二进制 chunk
|
||||||
2. **解码拼接**:`TextDecoder` 将二进制解码为 UTF-8 字符串,追加到 `buffer`(处理跨 chunk 的不完整行)
|
2. **解码拼接**:`TextDecoder` 将二进制解码为 UTF-8 字符串,追加到 `buffer`(处理跨 chunk 的不完整行)
|
||||||
3. **切行**:按 `\n` 分割,最后一段保留在 `buffer` 中(可能是不完整的 SSE 行)
|
3. **切行**:按 `\n` 分割,最后一段保留在 `buffer` 中(可能是不完整的 SSE 行)
|
||||||
4. **解析分发**:逐行匹配 `event: xxx` 设置事件类型,`data: {...}` 解析 JSON 后分发到对应回调(`onThinking` / `onMessage` / `onProcessStep` / `onDone` / `onError`)
|
4. **解析分发**:逐行匹配 `event: xxx` 设置事件类型,`data: {...}` 解析 JSON 后分发到对应回调(`onProcessStep` / `onDone` / `onError`)
|
||||||
|
|
||||||
```
|
```
|
||||||
后端 yield: event: thinking\ndata: {"content":"..."}\n\n
|
后端 yield: event: process_step\ndata: {"id":"step-0","type":"thinking","content":"..."}\n\n
|
||||||
↓ TCP(可能跨多个网络包)
|
↓ TCP(可能跨多个网络包)
|
||||||
reader.read(): [二进制片段1] → [二进制片段2] → ...
|
reader.read(): [二进制片段1] → [二进制片段2] → ...
|
||||||
↓
|
↓
|
||||||
buffer 拼接: "event: thinking\ndata: {\"content\":\"...\"}\n\n"
|
buffer 拼接: "event: process_step\ndata: {\"id\":\"step-0\",...}\n\n"
|
||||||
↓ split('\n')
|
↓ split('\n')
|
||||||
逐行解析: event: → "thinking"
|
逐行解析: event: → "process_step"
|
||||||
data: → JSON.parse → onThinking(data.content)
|
data: → JSON.parse → onProcessStep(data)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -923,41 +924,50 @@ if name.startswith("file_") and context and "project_id" in context:
|
||||||
backend_port: 3000
|
backend_port: 3000
|
||||||
frontend_port: 4000
|
frontend_port: 4000
|
||||||
|
|
||||||
# LLM API(全局默认值,每个 model 可单独覆盖)
|
# 智能体循环最大迭代次数(工具调用轮次上限,默认 5)
|
||||||
default_api_key: your-api-key
|
max_iterations: 15
|
||||||
default_api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
|
|
||||||
|
|
||||||
# 可用模型列表
|
# 可用模型列表(每个模型必须指定 api_url 和 api_key)
|
||||||
|
# 支持任何 OpenAI 兼容 API(DeepSeek、GLM、OpenAI、Moonshot、Qwen 等)
|
||||||
models:
|
models:
|
||||||
|
- id: deepseek-chat
|
||||||
|
name: DeepSeek V3
|
||||||
|
api_url: https://api.deepseek.com/chat/completions
|
||||||
|
api_key: sk-xxx
|
||||||
- id: glm-5
|
- id: glm-5
|
||||||
name: GLM-5
|
name: GLM-5
|
||||||
# api_key: ... # 可选,不指定则用 default_api_key
|
api_url: https://open.bigmodel.cn/api/paas/v4/chat/completions
|
||||||
# api_url: ... # 可选,不指定则用 default_api_url
|
api_key: xxx
|
||||||
- id: glm-5-turbo
|
|
||||||
name: GLM-5 Turbo
|
|
||||||
api_key: another-key # 该模型使用独立凭证
|
|
||||||
api_url: https://other.api.com/chat/completions
|
|
||||||
|
|
||||||
# 默认模型
|
# 默认模型(必须存在于 models 列表中)
|
||||||
default_model: glm-5
|
default_model: deepseek-chat
|
||||||
|
|
||||||
# 智能体循环最大迭代次数(工具调用轮次上限,默认 5)
|
|
||||||
max_iterations: 5
|
|
||||||
|
|
||||||
# 工作区根目录
|
# 工作区根目录
|
||||||
workspace_root: ./workspaces
|
workspace_root: ./workspaces
|
||||||
|
|
||||||
# 认证模式:single(单用户,无需登录) / multi(多用户,需要 JWT)
|
# 认证模式(可选,默认 single)
|
||||||
|
# single: 单用户模式,无需登录,自动创建默认用户
|
||||||
|
# multi: 多用户模式,需要 JWT 认证
|
||||||
auth_mode: single
|
auth_mode: single
|
||||||
# JWT 密钥(仅多用户模式使用,生产环境请替换为随机值)
|
# JWT 密钥(仅多用户模式使用,生产环境请替换为随机值)
|
||||||
jwt_secret: nano-claw-default-secret-change-in-production
|
jwt_secret: nano-claw-default-secret-change-in-production
|
||||||
|
|
||||||
# 数据库
|
# 数据库(支持 mysql, sqlite, postgresql)
|
||||||
db_type: mysql # mysql, sqlite, postgresql
|
db_type: sqlite
|
||||||
|
|
||||||
|
# MySQL/PostgreSQL 配置(sqlite 模式下忽略)
|
||||||
db_host: localhost
|
db_host: localhost
|
||||||
db_port: 3306
|
db_port: 3306
|
||||||
db_user: root
|
db_user: root
|
||||||
db_password: ""
|
db_password: "123456"
|
||||||
db_name: nano_claw
|
db_name: nano_claw
|
||||||
db_sqlite_file: app.db # SQLite 时使用
|
|
||||||
|
# SQLite 配置(mysql/postgresql 模式下忽略)
|
||||||
|
db_sqlite_file: nano_claw.db
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **说明**:
|
||||||
|
> - `api_key` 和 `api_url` 支持环境变量替换,例如 `api_key: ${DEEPSEEK_API_KEY}`
|
||||||
|
> - 不配置 `auth_mode` 时默认为 `single` 模式
|
||||||
|
> - `LLMClient` 会根据 `api_url` 自动检测提供商(DeepSeek / GLM / OpenAI),并适配不同的参数(max_tokens 上限、thinking 参数、stream_options 等)
|
||||||
|
> - 遇到 429 限流时自动重试(最多 3 次,指数退避)
|
||||||
Loading…
Reference in New Issue