modlink-agent 1.1.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.
- modlink_agent/__init__.py +58 -0
- modlink_agent/cli.py +287 -0
- modlink_agent/client.py +816 -0
- modlink_agent/config.py +195 -0
- modlink_agent/mcp_server.py +478 -0
- modlink_agent/version.py +21 -0
- modlink_agent-1.1.0.dist-info/METADATA +338 -0
- modlink_agent-1.1.0.dist-info/RECORD +11 -0
- modlink_agent-1.1.0.dist-info/WHEEL +5 -0
- modlink_agent-1.1.0.dist-info/entry_points.txt +3 -0
- modlink_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# 文件名: __init__.py
|
|
2
|
+
# 作 者: AI自动生成
|
|
3
|
+
# 创建时间: 2026-10-06
|
|
4
|
+
# 功能描述: 模联 Agent 工具包入口——对外暴露配置、客户端与主要方法
|
|
5
|
+
# 作用域: modlink-agent SDK
|
|
6
|
+
# 依赖关系: requests
|
|
7
|
+
#
|
|
8
|
+
# 中文注释:本包解决 Agent 调用模联的三个核心问题
|
|
9
|
+
# ① **二进制不进上下文**:ASR 要传音频、TTS/图像返回音频与图片。
|
|
10
|
+
# Agent 的上下文按 token 计费,一张 1024×1024 图 base64 约 1.4MB token,
|
|
11
|
+
# 直接塞进工具返回会把上下文撑爆。本包统一「落盘 + 只回传路径」。
|
|
12
|
+
# ② **长任务不超时**:图像生成实测数十秒到数分钟,MCP 客户端默认超时常 30~60 秒。
|
|
13
|
+
# 本包统一走「异步任务 + 轮询」或「长超时 + 明确提示」。
|
|
14
|
+
# ③ **429 自动退避**:图像模型互斥(同卡放不下两个 20B 模型),
|
|
15
|
+
# Agent 并发发起必然吃 429。本包统一读 Retry-After 并指数退避,
|
|
16
|
+
# 重试耗尽后返回**中文可执行的提示**,而不是把 429 原样抛出去让 Agent 自己猜。
|
|
17
|
+
#
|
|
18
|
+
# 中文注释:按「族」而不是按「模型」组织工具
|
|
19
|
+
# 平台有 20+ 个模型,一模型一工具会让 Agent 选择困难(工具列表过长、
|
|
20
|
+
# 描述互相干扰)。因此对外只暴露 6 个族级方法,模型作为参数传入。
|
|
21
|
+
#
|
|
22
|
+
# 中文注释:为什么**不**封装 LLM 对话模型
|
|
23
|
+
# 平台只有对话模型(qwen3-coder-next)同时具备「接受任意提示词」与
|
|
24
|
+
# 「能调用工具」两个特性——其余模型都是「输入固定格式、输出固定格式」。
|
|
25
|
+
# 把它做成 Agent 可调用的工具,等于把 Agent 的控制面交给不可信输入,
|
|
26
|
+
# 提示词注入的后果是任意的(诱导读文件/跑命令/组合越权调用链)。
|
|
27
|
+
# 详见 ModLinkClient 类内「族 5」处的完整说明。
|
|
28
|
+
|
|
29
|
+
"""模联 Agent 工具包。"""
|
|
30
|
+
|
|
31
|
+
from .version import __version__
|
|
32
|
+
from .config import ModLinkConfig, load_config
|
|
33
|
+
from .client import (
|
|
34
|
+
ModLinkClient,
|
|
35
|
+
ModLinkError,
|
|
36
|
+
AuthenticationError,
|
|
37
|
+
ForbiddenError,
|
|
38
|
+
InsufficientCredits,
|
|
39
|
+
RateLimitError,
|
|
40
|
+
ModelNotFound,
|
|
41
|
+
UpstreamError,
|
|
42
|
+
NetworkError,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"ModLinkConfig",
|
|
47
|
+
"load_config",
|
|
48
|
+
"ModLinkClient",
|
|
49
|
+
"ModLinkError",
|
|
50
|
+
"AuthenticationError",
|
|
51
|
+
"ForbiddenError",
|
|
52
|
+
"InsufficientCredits",
|
|
53
|
+
"RateLimitError",
|
|
54
|
+
"ModelNotFound",
|
|
55
|
+
"UpstreamError",
|
|
56
|
+
"NetworkError",
|
|
57
|
+
"__version__",
|
|
58
|
+
]
|
modlink_agent/cli.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# 文件名: cli.py
|
|
2
|
+
# 作 者: AI自动生成
|
|
3
|
+
# 创建时间: 2026-10-06
|
|
4
|
+
# 功能描述: 模联 Agent 工具包的命令行入口——按族聚合的子命令,管道友好
|
|
5
|
+
# 作用域: modlink-agent SDK
|
|
6
|
+
# 依赖关系: argparse;依赖本包的 client.py / config.py
|
|
7
|
+
#
|
|
8
|
+
# 中文注释:为什么 CLI 也是一等公民而不是「调试用的小玩意」
|
|
9
|
+
# ① 它是 MCP 的**兜底通道**——部分 Agent 宿主挂载 MCP 不稳,
|
|
10
|
+
# 但只要能执行 shell,CLI 就能用。
|
|
11
|
+
# ② 排障效率差一个量级:MCP 出错时你看到的是「工具调用失败」,
|
|
12
|
+
# CLI 出错时你看到的是完整的 HTTP 状态与平台提示。
|
|
13
|
+
# ③ 二进制落盘路径、429 等待、异步轮询这些行为在 CLI 里**完全一致**——
|
|
14
|
+
# 因为两者共用同一个 client.py,不存在「两边行为不同」的坑。
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from typing import List, Optional
|
|
20
|
+
|
|
21
|
+
from .client import ModLinkClient, ModLinkError
|
|
22
|
+
from .config import ConfigError, load_config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
26
|
+
"""中文注释:全局参数放在子命令之前(argparse 的 parent 机制),
|
|
27
|
+
但用 `--key` 这类名字刻意避免与子命令参数冲突。"""
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="modlink",
|
|
30
|
+
description="模联 ModLink 命令行工具——把平台模型暴露给脚本与 AI Agent",
|
|
31
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
32
|
+
epilog="""环境变量:
|
|
33
|
+
MODLINK_API_KEY API Key(推荐方式,等价于 --key 但不进 shell history)
|
|
34
|
+
MODLINK_BASE_URL 网关地址,默认 https://asr.syncmeet.tech:9443
|
|
35
|
+
MODLINK_WORK_DIR 音频/图片落盘目录,默认 ./modlink-out
|
|
36
|
+
|
|
37
|
+
示例:
|
|
38
|
+
export MODLINK_API_KEY=ml_live_xxxxxxxx
|
|
39
|
+
modlink models
|
|
40
|
+
modlink translate "你好" --to 英语
|
|
41
|
+
modlink chat "用 Python 写一个快排"
|
|
42
|
+
modlink synthesize "欢迎使用模联" --model cosyvoice
|
|
43
|
+
modlink embed "模联平台" --dimensions 1024
|
|
44
|
+
""",
|
|
45
|
+
)
|
|
46
|
+
# 中文注释:--key 优先级低于同名环境变量,见 config.load_config 的说明
|
|
47
|
+
parser.add_argument("--key", help="API Key(优先级低于环境变量 MODLINK_API_KEY)")
|
|
48
|
+
parser.add_argument("--base-url", help="网关地址")
|
|
49
|
+
parser.add_argument("--work-dir", help="二进制落盘目录")
|
|
50
|
+
parser.add_argument("--json", action="store_true",
|
|
51
|
+
help="以 JSON 输出结果(供 Agent 解析)")
|
|
52
|
+
|
|
53
|
+
sub = parser.add_subparsers(dest="command", metavar="<命令>")
|
|
54
|
+
|
|
55
|
+
# ---- 能力自查 ----
|
|
56
|
+
sub.add_parser("models", help="列出当前 Key 可调用的模型")
|
|
57
|
+
sub.add_parser("health", help="检查平台与引擎可达性")
|
|
58
|
+
|
|
59
|
+
# ---- 族 1:转写 ----
|
|
60
|
+
p_asr = sub.add_parser("transcribe", help="语音转写")
|
|
61
|
+
p_asr.add_argument("audio", help="音频文件路径")
|
|
62
|
+
p_asr.add_argument("--model", default="funasr",
|
|
63
|
+
help="funasr / sensevoice / paraformer-bilingual / zipformer-bilingual")
|
|
64
|
+
p_asr.add_argument("--language", help="语言代码,如 zh / en,留空自动识别")
|
|
65
|
+
p_asr.add_argument("--diarization", action="store_true", help="开启说话人分离(仅 funasr)")
|
|
66
|
+
p_asr.add_argument("--speaker-count", type=int, help="预估说话人数")
|
|
67
|
+
p_asr.add_argument("--hotwords", help="自定义热词,换行或空格分隔")
|
|
68
|
+
p_asr.add_argument("--async", dest="async_mode", action="store_true",
|
|
69
|
+
help="强制走异步任务(长音频推荐)")
|
|
70
|
+
|
|
71
|
+
# ---- 族 2:合成 ----
|
|
72
|
+
p_tts = sub.add_parser("synthesize", help="语音合成")
|
|
73
|
+
p_tts.add_argument("text", help="要合成的文本")
|
|
74
|
+
p_tts.add_argument("--model", default="cosyvoice", help="cosyvoice / indextts")
|
|
75
|
+
p_tts.add_argument("--voice", help="音色")
|
|
76
|
+
p_tts.add_argument("--speed", type=float, help="语速")
|
|
77
|
+
p_tts.add_argument("--lang", help="合成语言,如 ZH / EN")
|
|
78
|
+
p_tts.add_argument("--prompt-audio", help="参考音频路径(提供则走音色克隆)")
|
|
79
|
+
p_tts.add_argument("--prompt-text", help="参考音频对应文本,提升克隆相似度")
|
|
80
|
+
p_tts.add_argument("--output", help="输出文件路径(默认自动命名落盘)")
|
|
81
|
+
|
|
82
|
+
# ---- 族 3:翻译 ----
|
|
83
|
+
p_tr = sub.add_parser("translate", help="文本翻译(38 语种)")
|
|
84
|
+
p_tr.add_argument("text", help="待译文本(多行按行批量)")
|
|
85
|
+
p_tr.add_argument("--to", dest="target_lang", default="英语", help="目标语言名,如 英语")
|
|
86
|
+
p_tr.add_argument("--from", dest="source_lang", help="源语言,留空自动识别")
|
|
87
|
+
p_tr.add_argument("--style", help="风格要求,如「正式书面语」")
|
|
88
|
+
p_tr.add_argument("--terminology", help="术语表,换行分隔「源文=译文」")
|
|
89
|
+
p_tr.add_argument("--summary", action="store_true", help="只输出摘要")
|
|
90
|
+
|
|
91
|
+
# ---- 族 4:向量化 ----
|
|
92
|
+
p_emb = sub.add_parser("embed", help="文本向量化")
|
|
93
|
+
p_emb.add_argument("texts", nargs="+", help="一个或多个文本")
|
|
94
|
+
p_emb.add_argument("--model", default="qwen3-embedding")
|
|
95
|
+
p_emb.add_argument("--dimensions", type=int, help="向量维度(32~2560,留空为原生维度)")
|
|
96
|
+
|
|
97
|
+
# ---- 族 5:LLM 对话(刻意不提供,见下方说明) ----
|
|
98
|
+
# 中文注释:这里**刻意没有** chat 子命令。
|
|
99
|
+
# 平台只有对话模型接受任意提示词且能调用工具,
|
|
100
|
+
# 把它做成 Agent 可调用的命令等于把 Agent 控制面交给不可信输入——
|
|
101
|
+
# 提示词注入的后果是任意的(诱导读文件、跑命令、组合越权链)。
|
|
102
|
+
# 需要用对话模型请直接调平台的 /v1/chat/completions。
|
|
103
|
+
|
|
104
|
+
# ---- 族 6:图像 ----
|
|
105
|
+
p_img = sub.add_parser("image", help="图像处理")
|
|
106
|
+
p_img.add_argument("image", help="输入图片路径")
|
|
107
|
+
p_img.add_argument("--task", default="remove_background",
|
|
108
|
+
help="remove_background / erase / upscale / face_restore / face_swap")
|
|
109
|
+
p_img.add_argument("--model", help="模型标识,留空按任务推断")
|
|
110
|
+
p_img.add_argument("--mask", help="掩膜图路径(erase 任务必填)")
|
|
111
|
+
p_img.add_argument("--source-image", help="人脸来源图路径(face_swap 任务必填)")
|
|
112
|
+
p_img.add_argument("--options", help="引擎专属参数(JSON 字符串)")
|
|
113
|
+
p_img.add_argument("--output", help="输出图片路径")
|
|
114
|
+
|
|
115
|
+
p_gen = sub.add_parser("generate", help="图像生成 / 图生图")
|
|
116
|
+
p_gen.add_argument("prompt", help="提示词")
|
|
117
|
+
p_gen.add_argument("--model", default="flux2-generate", help="flux2-generate / qwen-image-2.1")
|
|
118
|
+
p_gen.add_argument("--width", type=int, default=1024)
|
|
119
|
+
p_gen.add_argument("--height", type=int, default=1024)
|
|
120
|
+
p_gen.add_argument("--steps", type=int, help="采样步数")
|
|
121
|
+
p_gen.add_argument("--seed", type=int, help="随机种子(固定值可复现)")
|
|
122
|
+
p_gen.add_argument("--negative-prompt", help="负向提示词")
|
|
123
|
+
p_gen.add_argument("--image", help="输入图片路径(提供则走图生图/指令编辑)")
|
|
124
|
+
p_gen.add_argument("--output", help="输出图片路径")
|
|
125
|
+
|
|
126
|
+
return parser
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _emit(args: argparse.Namespace, payload, human_text: str) -> None:
|
|
130
|
+
"""中文注释:双输出模式——`--json` 给 Agent 解析,人读时给可读文本。
|
|
131
|
+
|
|
132
|
+
为什么不做成只有一种:Agent 需要结构化输出做后续决策,
|
|
133
|
+
人需要可读输出判断结果对不对。强制二选一会让其中一边难受。
|
|
134
|
+
"""
|
|
135
|
+
if getattr(args, "json", False):
|
|
136
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
137
|
+
else:
|
|
138
|
+
print(human_text)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
142
|
+
"""CLI 入口
|
|
143
|
+
|
|
144
|
+
中文注释:返回退出码而不是 sys.exit——便于被其他程序当函数调用,
|
|
145
|
+
也便于单测直接断言返回值。
|
|
146
|
+
"""
|
|
147
|
+
parser = _build_parser()
|
|
148
|
+
args = parser.parse_args(argv)
|
|
149
|
+
|
|
150
|
+
if not args.command:
|
|
151
|
+
parser.print_help()
|
|
152
|
+
return 1
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
# 中文注释:CLI 允许交互式输入 Key(首次使用友好),
|
|
156
|
+
# MCP server 侧则不传 interactive=True(见 mcp_server.py)。
|
|
157
|
+
config = load_config(
|
|
158
|
+
api_key=args.key,
|
|
159
|
+
base_url=args.base_url,
|
|
160
|
+
work_dir=args.work_dir,
|
|
161
|
+
interactive=True,
|
|
162
|
+
)
|
|
163
|
+
client = ModLinkClient(config=config)
|
|
164
|
+
except ConfigError as exc:
|
|
165
|
+
print(f"配置错误:{exc}", file=sys.stderr)
|
|
166
|
+
return 2
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
return _dispatch(args, client)
|
|
170
|
+
except ModLinkError as exc:
|
|
171
|
+
# 中文注释:错误打 stderr 且退出码非 0——
|
|
172
|
+
# Agent 通过 shell 调 CLI 时能靠退出码判断成败,不只看 stdout。
|
|
173
|
+
print(f"调用失败:{exc}", file=sys.stderr)
|
|
174
|
+
return 1
|
|
175
|
+
except KeyboardInterrupt:
|
|
176
|
+
print("已中断", file=sys.stderr)
|
|
177
|
+
return 130
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _dispatch(args: argparse.Namespace, client: ModLinkClient) -> int:
|
|
181
|
+
"""按子命令分发"""
|
|
182
|
+
command = args.command
|
|
183
|
+
|
|
184
|
+
# ---- 能力自查 ----
|
|
185
|
+
if command == "models":
|
|
186
|
+
models = client.list_models()
|
|
187
|
+
if args.json:
|
|
188
|
+
_emit(args, models, "")
|
|
189
|
+
else:
|
|
190
|
+
print(f"当前 Key 可调用 {len(models)} 个模型:")
|
|
191
|
+
for item in models:
|
|
192
|
+
flag = "就绪" if item.get("ready") else "未就绪"
|
|
193
|
+
caps = ",".join(item.get("capabilities") or [])
|
|
194
|
+
print(f" {item['id']:<26} {flag:<6} {caps}")
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
if command == "health":
|
|
198
|
+
ok = client.health()
|
|
199
|
+
_emit(args, {"healthy": ok}, "平台可达" if ok else "平台不可达")
|
|
200
|
+
return 0 if ok else 1
|
|
201
|
+
|
|
202
|
+
# ---- 族 1:转写 ----
|
|
203
|
+
if command == "transcribe":
|
|
204
|
+
# 中文注释:按音频大小启发式决定是否走异步——
|
|
205
|
+
# 经验阈值 20MB(约 30 分钟以上的 wav/mp3)。
|
|
206
|
+
# 调用方可用 --async 强制覆盖。
|
|
207
|
+
import os
|
|
208
|
+
|
|
209
|
+
size_mb = os.path.getsize(args.audio) / 1024 / 1024
|
|
210
|
+
async_mode = args.async_mode or size_mb > 20
|
|
211
|
+
result = client.transcribe(
|
|
212
|
+
args.audio, model=args.model, language=args.language,
|
|
213
|
+
diarization=args.diarization, speaker_count=args.speaker_count,
|
|
214
|
+
hotwords=args.hotwords, async_mode=async_mode,
|
|
215
|
+
)
|
|
216
|
+
_emit(args, result, result.get("text", ""))
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
# ---- 族 2:合成 ----
|
|
220
|
+
if command == "synthesize":
|
|
221
|
+
result = client.synthesize(
|
|
222
|
+
args.text, model=args.model, voice=args.voice, speed=args.speed,
|
|
223
|
+
lang=args.lang, prompt_audio=args.prompt_audio,
|
|
224
|
+
prompt_text=args.prompt_text, output_path=args.output,
|
|
225
|
+
)
|
|
226
|
+
_emit(args, {"path": str(result.path), "size_bytes": result.size_bytes,
|
|
227
|
+
"meta": result.meta},
|
|
228
|
+
result.describe())
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
# ---- 族 3:翻译 ----
|
|
232
|
+
if command == "translate":
|
|
233
|
+
terminology = None
|
|
234
|
+
if args.terminology:
|
|
235
|
+
terminology = [line.strip() for line in args.terminology.split("\n") if line.strip()]
|
|
236
|
+
results = client.translate(
|
|
237
|
+
args.text, target_lang=args.target_lang, source_lang=args.source_lang,
|
|
238
|
+
terminology=terminology, style=args.style, summary=args.summary,
|
|
239
|
+
)
|
|
240
|
+
_emit(args, {"translations": results}, "\n".join(results))
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
# ---- 族 4:向量化 ----
|
|
244
|
+
if command == "embed":
|
|
245
|
+
vectors = client.embed(args.texts, model=args.model, dimensions=args.dimensions)
|
|
246
|
+
# 中文注释:默认**不**打印向量本身——2560 维浮点数会把终端刷满,
|
|
247
|
+
# 对 Agent 也没有意义(它要用这个向量去做检索,不是读数字)。
|
|
248
|
+
# 需要原始向量时用 --json。
|
|
249
|
+
if args.json:
|
|
250
|
+
_emit(args, {"vectors": vectors, "count": len(vectors)}, "")
|
|
251
|
+
else:
|
|
252
|
+
dims = len(vectors[0]) if vectors else 0
|
|
253
|
+
print(f"已生成 {len(vectors)} 个向量,维度 {dims}")
|
|
254
|
+
if dims:
|
|
255
|
+
preview = [round(v, 4) for v in vectors[0][:5]]
|
|
256
|
+
print(f" 首个向量前 5 维:{preview} ...")
|
|
257
|
+
return 0
|
|
258
|
+
|
|
259
|
+
# ---- 族 6:图像 ----
|
|
260
|
+
if command == "image":
|
|
261
|
+
options = json.loads(args.options) if args.options else None
|
|
262
|
+
result = client.process_image(
|
|
263
|
+
args.image, task_type=args.task, model=args.model,
|
|
264
|
+
mask=args.mask, source_image=args.source_image,
|
|
265
|
+
options=options, output_path=args.output,
|
|
266
|
+
)
|
|
267
|
+
_emit(args, {"path": str(result.path), "size_bytes": result.size_bytes},
|
|
268
|
+
result.describe())
|
|
269
|
+
return 0
|
|
270
|
+
|
|
271
|
+
if command == "generate":
|
|
272
|
+
result = client.generate_image(
|
|
273
|
+
args.prompt, model=args.model, width=args.width, height=args.height,
|
|
274
|
+
steps=args.steps, seed=args.seed, negative_prompt=args.negative_prompt,
|
|
275
|
+
image=args.image, output_path=args.output,
|
|
276
|
+
)
|
|
277
|
+
_emit(args, {"path": str(result.path), "size_bytes": result.size_bytes,
|
|
278
|
+
"meta": result.meta},
|
|
279
|
+
result.describe())
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
parser.print_help()
|
|
283
|
+
return 1
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
sys.exit(main())
|