llm-toolforge 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ """
2
+ llm-toolforge
3
+
4
+ 工具开发只需从这里导入:
5
+
6
+ from llm_toolforge import (
7
+ Plugin, InputField, BaseGenerator,
8
+ GenerateResult, GenerateProgress, ResultStatus,
9
+ LLMServiceError, UploadError,
10
+ AIClient, OSSClient,
11
+ load_tool_config,
12
+ )
13
+ """
14
+
15
+ from .core.base import (
16
+ BaseGenerator,
17
+ GenerateProgress,
18
+ GenerateResult,
19
+ GeneratorError,
20
+ InputField,
21
+ LLMServiceError,
22
+ Plugin,
23
+ ResultStatus,
24
+ UploadError,
25
+ )
26
+ from .core.ai_client import AIClient
27
+ from .core.oss_client import OSSClient
28
+ from .config import load_tool_config, load_config, find_config
29
+ from .discovery import discover_plugins
30
+
31
+ __all__ = [
32
+ "Plugin",
33
+ "BaseGenerator",
34
+ "InputField",
35
+ "GenerateResult",
36
+ "GenerateProgress",
37
+ "ResultStatus",
38
+ "GeneratorError",
39
+ "LLMServiceError",
40
+ "UploadError",
41
+ "AIClient",
42
+ "OSSClient",
43
+ "load_tool_config",
44
+ "load_config",
45
+ "find_config",
46
+ "discover_plugins",
47
+ ]
llm_toolforge/cli.py ADDED
@@ -0,0 +1,403 @@
1
+ """
2
+ toolforge CLI 入口
3
+
4
+ 命令:
5
+ toolforge run [--config FILE] [--api-only | --mcp-only]
6
+ toolforge list [--config FILE]
7
+ toolforge new project <dest>
8
+ toolforge new tool <name>
9
+ """
10
+
11
+ import argparse
12
+ import sys
13
+ from pathlib import Path
14
+
15
+
16
+ # ── 模板内容 ────────────────────────────────────────────────────────────────────
17
+
18
+ _CONFIG_TEMPLATE = """\
19
+ # ============================================================
20
+ # llm-toolforge 配置
21
+ # ============================================================
22
+
23
+ # 扫描哪些目录下的工具子包(相对于本文件所在目录)
24
+ plugins_dirs: [tools]
25
+
26
+ # ── 服务标识 ──────────────────────────────────────────────────
27
+ # name 用于日志/心跳/统计的 service_name 隔离,多部署时必须唯一
28
+ service:
29
+ name: "my-toolset"
30
+ version: "v1.0.0"
31
+
32
+ # ── HTTP / MCP 服务 ───────────────────────────────────────────
33
+ server:
34
+ host: "0.0.0.0"
35
+ port: 12345
36
+ mcp_port: 12346
37
+
38
+ # Web 测试台(访问 http://host:port/)
39
+ enable_web_ui: true
40
+
41
+ # API 鉴权:enable_auth=true 时所有接口需要 Authorization 头
42
+ enable_auth: false
43
+ authorization: "Bearer change-me"
44
+
45
+ # Worker 数量:auto = min(cpu_count * worker_multiplier, max_api_workers)
46
+ api_workers: auto
47
+ mcp_workers: 1
48
+ # max_api_workers: 8 # auto 模式的上限(默认 8)
49
+ # max_mcp_workers: 4 # MCP auto 模式的上限(默认 4)
50
+ # worker_multiplier: 1 # auto 模式的 CPU 倍数(默认 1)
51
+
52
+ # 单请求并发限制(0 = 不限制)
53
+ max_concurrency: 4
54
+ # overload_timeout_ms: 1000 # 等待并发许可的超时,超时返回 503(默认 1000ms)
55
+
56
+ # 单次请求超时(秒,0 = 不限制)
57
+ request_timeout_s: 180
58
+
59
+ # 文件上传大小限制(MB,0 = 不限制)
60
+ max_upload_mb: 50
61
+
62
+ # 多 Pod 部署时手动指定实例 ID(不填则自动取 POD_NAME / HOSTNAME / gethostname)
63
+ # instance_id: "pod-0"
64
+
65
+ # 跨 service 全局视图的管理员 Token(不填则禁用 scope=global 接口)
66
+ # global_admin_token: "your-admin-token"
67
+
68
+ # ── 调用日志 ──────────────────────────────────────────────────
69
+ logging:
70
+ # storage: mysql | sqlite | disabled
71
+ storage: sqlite
72
+ sqlite_path: logs.db
73
+
74
+ # 日志保留天数(0 = 永久保留)
75
+ retention_days: 90
76
+
77
+ # 心跳配置(用于 /load 接口的 worker 存活检测,依赖 Redis)
78
+ heartbeat_interval_s: 5 # 写心跳的间隔(秒)
79
+ heartbeat_ttl_s: 15 # 心跳在 Redis 中的过期时间(秒)
80
+
81
+ # 日志批量写入配置
82
+ # batch_size: 100 # 攒够多少条触发一次批量写(默认 100)
83
+ # batch_flush_ms: 1000 # 最长等待多少毫秒强制 flush(默认 1000)
84
+
85
+ # 单条日志 output 字段的最大字节数(0 = 不截断)
86
+ # max_output_size: 0
87
+
88
+ # ── MySQL 日志后端(logging.storage: mysql 时填写)──────────────
89
+ # mysql:
90
+ # host: "127.0.0.1"
91
+ # port: 3306
92
+ # username: "root"
93
+ # password: "your-password"
94
+ # database: "toolforge"
95
+ # pool_max: 10 # 连接池最大连接数(默认 10)
96
+ # events_table: "tool_call_events" # 日志表名(默认 tool_call_events)
97
+
98
+ # ── Redis(可选,用于心跳/实时流/统计缓存)──────────────────────────
99
+ # 不配置时对应功能自动降级:/load 心跳检测、/logs/stream 实时流、统计缓存不可用,
100
+ # 其余功能(工具调用、日志落库)不受影响。
101
+ # redis_config:
102
+ # host: "127.0.0.1"
103
+ # port: 6379
104
+ # password: ""
105
+ # database: 0
106
+ # key_prefix: "toolforge" # Redis key 前缀,多部署共用同一 Redis 时用于隔离
107
+
108
+ # ── 大模型渠道(按需填写,工具内通过 provider 名称引用)────────
109
+ # 支持 alibaba / doubao / openai / gemini,按需取消注释并填入密钥
110
+ ai_providers:
111
+ openai:
112
+ key: "your-api-key"
113
+ base_url: "https://api.openai.com/v1"
114
+ timeout_s: 120
115
+ # alibaba:
116
+ # key: "your-dashscope-api-key"
117
+ # base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1"
118
+ # timeout_s: 120
119
+ # doubao:
120
+ # key: "your-ark-api-key"
121
+ # base_url: "https://ark.cn-beijing.volces.com/api/v3"
122
+ # timeout_s: 120
123
+ # gemini:
124
+ # key: "your-gemini-api-key"
125
+ # base_url: "https://generativelanguage.googleapis.com"
126
+ # timeout_s: 120
127
+
128
+ # ── 对象存储(文件上传 / 产物托管,按需启用)────────────────────
129
+ # storage:
130
+ # provider: aliyun # aliyun | tencent(默认 aliyun)
131
+ #
132
+ # # ── 阿里云 OSS(provider: aliyun)──
133
+ # access_key_id: ""
134
+ # access_key_secret: ""
135
+ # endpoint: "http://oss-cn-hangzhou.aliyuncs.com"
136
+ # bucket_host: "https://your-bucket.oss-cn-hangzhou.aliyuncs.com"
137
+ # bucket: "your-bucket"
138
+ # region: "cn-hangzhou"
139
+ # path_prefix: "toolforge"
140
+ #
141
+ # # ── 腾讯云 COS(provider: tencent)──
142
+ # secret_id: "your-secret-id"
143
+ # secret_key: "your-secret-key"
144
+ # bucket: "your-bucket-1234567890"
145
+ # region: "ap-guangzhou"
146
+ # path_prefix: "toolforge"
147
+ # cdn_endpoint: "" # 留空则使用默认 COS 域名
148
+
149
+ # ── 工具私有配置(key 与 tools/ 下子目录同名)────────────────────
150
+ tools: {}
151
+ """
152
+
153
+ _TOOL_INIT_TEMPLATE = '''\
154
+ from pathlib import Path
155
+ from llm_toolforge import Plugin, InputField, load_tool_config
156
+ from .generator import {ClassName}Generator
157
+
158
+ _DIR = Path(__file__).parent
159
+ _CFG = load_tool_config("{tool_name}")
160
+
161
+ plugin = Plugin(
162
+ name="{display_name}",
163
+ description="TODO: 描述这个工具的功能",
164
+ generator={ClassName}Generator(),
165
+ route_path="/{route_path}",
166
+ operation_id="{operation_id}",
167
+ route_summary="TODO: 一句话接口摘要",
168
+ inputs=[
169
+ InputField(
170
+ name="input_text",
171
+ description="输入内容",
172
+ examples=["示例输入"],
173
+ ),
174
+ ],
175
+ )
176
+ '''
177
+
178
+ _TOOL_GENERATOR_TEMPLATE = '''\
179
+ from llm_toolforge import BaseGenerator, GenerateResult, ResultStatus
180
+
181
+
182
+ class {ClassName}Generator(BaseGenerator):
183
+ """TODO: 工具的业务实现"""
184
+
185
+ def generate(self, **kwargs) -> GenerateResult:
186
+ input_text = kwargs.get("input_text", "")
187
+
188
+ # TODO: 实现你的业务逻辑
189
+ result = f"处理结果:{{input_text}}"
190
+
191
+ return GenerateResult(
192
+ status=ResultStatus.SUCCESS,
193
+ outputs={{"result": result}},
194
+ )
195
+ '''
196
+
197
+ _REQUIREMENTS_TEMPLATE = """\
198
+ llm-toolforge
199
+ """
200
+
201
+ _DOCKERFILE_TEMPLATE = """\
202
+ FROM python:3.10-slim
203
+
204
+ WORKDIR /app
205
+
206
+ # 按需取消注释(qr_recognize 需要 libzbar0 / libgl1)
207
+ # RUN apt-get update && \\
208
+ # apt-get install -y --no-install-recommends libglib2.0-0 libgl1 libzbar0 && \\
209
+ # rm -rf /var/lib/apt/lists/*
210
+
211
+ COPY requirements.txt .
212
+ RUN pip install --no-cache-dir -r requirements.txt
213
+
214
+ COPY . .
215
+
216
+ EXPOSE 12345 12346
217
+
218
+ CMD ["toolforge", "run"]
219
+ """
220
+
221
+ _README_TEMPLATE = """\
222
+ # {project_name}
223
+
224
+ 基于 [llm-toolforge](https://github.com/xxx/llm-toolforge) 构建的工具集。
225
+
226
+ ## 快速开始
227
+
228
+ ```bash
229
+ pip install -r requirements.txt
230
+ toolforge run
231
+ ```
232
+
233
+ 访问 http://localhost:12345 查看 Web 测试台。
234
+
235
+ ## 添加新工具
236
+
237
+ ```bash
238
+ toolforge new tool my_tool
239
+ ```
240
+
241
+ 然后编辑 `tools/my_tool/generator.py` 实现业务逻辑。
242
+ """
243
+
244
+
245
+ # ── 子命令实现 ──────────────────────────────────────────────────────────────────
246
+
247
+ def cmd_run(args) -> int:
248
+ from .server.runner import run
249
+ return run(
250
+ config_path=args.config,
251
+ api_only=args.api_only,
252
+ mcp_only=args.mcp_only,
253
+ )
254
+
255
+
256
+ def cmd_list(args) -> int:
257
+ from .config import load_config
258
+ from .discovery import discover_plugins
259
+ try:
260
+ config = load_config(args.config)
261
+ plugins = discover_plugins(config)
262
+ except Exception as e:
263
+ print(f"错误:{e}", file=sys.stderr)
264
+ return 1
265
+ if not plugins:
266
+ print("(没有发现任何插件)")
267
+ return 0
268
+ print(f"发现 {len(plugins)} 个插件:\n")
269
+ for p in plugins:
270
+ print(f" [{p.operation_id}] {p.name}")
271
+ print(f" 路由: POST {p.route_path}")
272
+ print(f" 描述: {p.description[:80]}{'...' if len(p.description) > 80 else ''}")
273
+ print()
274
+ return 0
275
+
276
+
277
+ def cmd_new_project(args) -> int:
278
+ dest = Path(args.dest).resolve()
279
+ dest.mkdir(parents=True, exist_ok=True)
280
+
281
+ cfg = dest / "config.yaml"
282
+ if cfg.exists():
283
+ print(f"config.yaml 已存在,跳过:{cfg}")
284
+ else:
285
+ cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
286
+ print(f"已创建:{cfg}")
287
+
288
+ tools_dir = dest / "tools"
289
+ tools_dir.mkdir(exist_ok=True)
290
+ (tools_dir / ".gitkeep").touch()
291
+ print(f"已创建:{tools_dir}/")
292
+
293
+ req = dest / "requirements.txt"
294
+ if not req.exists():
295
+ req.write_text(_REQUIREMENTS_TEMPLATE, encoding="utf-8")
296
+ print(f"已创建:{req}")
297
+
298
+ readme = dest / "README.md"
299
+ if not readme.exists():
300
+ project_name = dest.name
301
+ readme.write_text(_README_TEMPLATE.format(project_name=project_name), encoding="utf-8")
302
+ print(f"已创建:{readme}")
303
+
304
+ dockerfile = dest / "Dockerfile"
305
+ if not dockerfile.exists():
306
+ dockerfile.write_text(_DOCKERFILE_TEMPLATE, encoding="utf-8")
307
+ print(f"已创建:{dockerfile}")
308
+
309
+ print(f"\n项目初始化完成!下一步:")
310
+ print(f" 1. 编辑 {dest}/config.yaml,填入 Redis / AI provider 等配置")
311
+ print(f" 2. toolforge new tool <name> 添加第一个工具")
312
+ print(f" 3. toolforge run 启动服务")
313
+ return 0
314
+
315
+
316
+ def cmd_new_tool(args) -> int:
317
+ tool_name: str = args.name.strip().replace("-", "_").lower()
318
+ class_name = "".join(w.capitalize() for w in tool_name.split("_"))
319
+ display_name = tool_name.replace("_", " ").title()
320
+ route_path = tool_name.replace("_", "-")
321
+ operation_id = "".join(
322
+ (w[0].upper() + w[1:] if i > 0 else w) for i, w in enumerate(tool_name.split("_"))
323
+ )
324
+
325
+ # 目标目录:优先放在 ./tools/,其次 CWD/tool_name
326
+ tools_root = Path.cwd() / "tools"
327
+ if tools_root.exists():
328
+ tool_dir = tools_root / tool_name
329
+ else:
330
+ tool_dir = Path.cwd() / tool_name
331
+
332
+ if tool_dir.exists():
333
+ print(f"目录已存在:{tool_dir},请手动检查是否需要覆盖。", file=sys.stderr)
334
+ return 1
335
+
336
+ tool_dir.mkdir(parents=True)
337
+ (tool_dir / "__init__.py").write_text(
338
+ _TOOL_INIT_TEMPLATE.format(
339
+ ClassName=class_name,
340
+ tool_name=tool_name,
341
+ display_name=display_name,
342
+ route_path=route_path,
343
+ operation_id=operation_id,
344
+ ),
345
+ encoding="utf-8",
346
+ )
347
+ (tool_dir / "generator.py").write_text(
348
+ _TOOL_GENERATOR_TEMPLATE.format(ClassName=class_name),
349
+ encoding="utf-8",
350
+ )
351
+ print(f"已创建工具骨架:{tool_dir}/")
352
+ print(f" {tool_dir}/__init__.py — 插件声明(Plugin)")
353
+ print(f" {tool_dir}/generator.py — 业务逻辑({class_name}Generator)")
354
+ print(f"\n下一步:")
355
+ print(f" 1. 编辑 {tool_dir}/generator.py 实现业务逻辑")
356
+ print(f" 2. 运行 toolforge list 确认工具已被发现")
357
+ print(f" 3. 运行 toolforge run 启动服务并在 Web 测试台验证")
358
+ return 0
359
+
360
+
361
+ # ── 参数解析 ────────────────────────────────────────────────────────────────────
362
+
363
+ def _build_parser() -> argparse.ArgumentParser:
364
+ parser = argparse.ArgumentParser(
365
+ prog="toolforge",
366
+ description="llm-toolforge 命令行工具",
367
+ )
368
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
369
+ sub.required = True
370
+
371
+ # run
372
+ p_run = sub.add_parser("run", help="启动 API + MCP 服务")
373
+ p_run.add_argument("--config", "-c", metavar="FILE", help="config.yaml 路径(默认:自动查找)")
374
+ g = p_run.add_mutually_exclusive_group()
375
+ g.add_argument("--api-only", action="store_true", help="只启动 HTTP API")
376
+ g.add_argument("--mcp-only", action="store_true", help="只启动 MCP SSE 服务")
377
+ p_run.set_defaults(func=cmd_run)
378
+
379
+ # list
380
+ p_list = sub.add_parser("list", help="列出已发现的工具")
381
+ p_list.add_argument("--config", "-c", metavar="FILE")
382
+ p_list.set_defaults(func=cmd_list)
383
+
384
+ # new
385
+ p_new = sub.add_parser("new", help="生成脚手架")
386
+ new_sub = p_new.add_subparsers(dest="new_type", metavar="<type>")
387
+ new_sub.required = True
388
+
389
+ p_new_project = new_sub.add_parser("project", help="初始化新项目")
390
+ p_new_project.add_argument("dest", nargs="?", default=".", help="目标目录(默认:当前目录)")
391
+ p_new_project.set_defaults(func=cmd_new_project)
392
+
393
+ p_new_tool = new_sub.add_parser("tool", help="在 tools/ 目录下生成工具骨架")
394
+ p_new_tool.add_argument("name", help="工具目录名(如 my_tool)")
395
+ p_new_tool.set_defaults(func=cmd_new_tool)
396
+
397
+ return parser
398
+
399
+
400
+ def main() -> None:
401
+ parser = _build_parser()
402
+ args = parser.parse_args()
403
+ sys.exit(args.func(args))
@@ -0,0 +1,88 @@
1
+ """
2
+ 配置加载
3
+
4
+ 所有 config.yaml 相关的 I/O 都在这个模块,框架其余部分不直接读文件。
5
+
6
+ 查找优先级(find_config):
7
+ 1. 环境变量 TOOLFORGE_CONFIG(绝对路径或相对于 CWD)
8
+ 2. CWD/config.yaml
9
+ 3. 从 CWD 向上逐级搜索(兼容 cd 到子目录运行的场景)
10
+ """
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import yaml
17
+
18
+
19
+ def find_config(path: Optional[str] = None) -> Path:
20
+ """定位 config.yaml 并返回其绝对路径。
21
+
22
+ Args:
23
+ path: 明确指定的路径(优先级最高),None 则自动查找。
24
+
25
+ Raises:
26
+ FileNotFoundError: 找不到配置文件时抛出,消息包含修复建议。
27
+ """
28
+ if path:
29
+ p = Path(path)
30
+ if not p.is_absolute():
31
+ p = Path.cwd() / p
32
+ if not p.exists():
33
+ raise FileNotFoundError(f"指定的配置文件不存在:{p}")
34
+ return p.resolve()
35
+
36
+ env = os.environ.get("TOOLFORGE_CONFIG")
37
+ if env:
38
+ p = Path(env)
39
+ if not p.is_absolute():
40
+ p = Path.cwd() / p
41
+ if p.exists():
42
+ return p.resolve()
43
+ raise FileNotFoundError(
44
+ f"环境变量 TOOLFORGE_CONFIG 指向的文件不存在:{p}\n"
45
+ "请检查路径是否正确。"
46
+ )
47
+
48
+ cwd_cfg = Path.cwd() / "config.yaml"
49
+ if cwd_cfg.exists():
50
+ return cwd_cfg
51
+
52
+ d = Path.cwd()
53
+ for _ in range(4):
54
+ d = d.parent
55
+ cfg = d / "config.yaml"
56
+ if cfg.exists():
57
+ return cfg
58
+
59
+ raise FileNotFoundError(
60
+ "找不到 config.yaml。\n"
61
+ "请在项目根目录创建配置文件,或通过 TOOLFORGE_CONFIG 环境变量指定路径。\n"
62
+ "可以用 `toolforge new project .` 生成配置模板。"
63
+ )
64
+
65
+
66
+ def load_config(path: Optional[str] = None) -> dict:
67
+ """加载并返回 config.yaml 的内容(dict)。"""
68
+ return yaml.safe_load(find_config(path).read_text(encoding="utf-8")) or {}
69
+
70
+
71
+ def load_tool_config(name: str, config: Optional[dict] = None) -> dict:
72
+ """加载工具私有配置段 tools.<name>。
73
+
74
+ 工具在 __init__.py 中调用(不需要知道文件路径):
75
+
76
+ from llm_toolforge import load_tool_config
77
+ _CFG = load_tool_config("my_tool")
78
+
79
+ Args:
80
+ name: 工具注册名(与 tools/ 下子目录名一致)。
81
+ config: 已加载的 config dict,传入可避免重复读文件。
82
+
83
+ Returns:
84
+ dict,没有对应配置段时返回 {}。
85
+ """
86
+ if config is None:
87
+ config = load_config()
88
+ return (config.get("tools") or {}).get(name) or {}
@@ -0,0 +1,11 @@
1
+ from .base import (
2
+ BaseGenerator,
3
+ GenerateProgress,
4
+ GenerateResult,
5
+ GeneratorError,
6
+ InputField,
7
+ LLMServiceError,
8
+ Plugin,
9
+ ResultStatus,
10
+ UploadError,
11
+ )