mini-agent-cli 0.2.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.
mini_agent/llm.py ADDED
@@ -0,0 +1,339 @@
1
+ """三种协议:openai-chat / openai-responses / anthropic。流式,只用标准库发 HTTP。
2
+
3
+ 内部历史用一种中立格式,每种协议各自负责「翻译过去、解析回来」:
4
+
5
+ {"role": "user", "content": "文字" 或 [{"type": "text", "text"}, {"type": "image", "url": data_url}]}
6
+ {"role": "assistant", "text", "thinking", "tool_calls": [{"id", "name", "arguments": JSON 字符串}],
7
+ "raw": 这一轮的协议原文, "api", "ref": 哪个协议、哪个模型产生的}
8
+ {"role": "tool", "id": 调用 id, "content": "结果"}
9
+
10
+ 中途换模型(参考 pi-ai 的 cross-provider handoff):
11
+ 同一个模型产生的 assistant 消息 → 回放 raw,thinking 签名、reasoning 项都不丢
12
+ 别的模型产生的 → 只用中立字段重建:thinking 变成普通文本,签名丢掉,
13
+ 工具调用 id 规范成各家都接受的字符集
14
+ """
15
+
16
+ import asyncio
17
+ import json
18
+ import re
19
+ import threading
20
+ import urllib.error
21
+ import urllib.request
22
+
23
+ from .config import MissingEnv, expand
24
+
25
+ TIMEOUT = 600
26
+
27
+
28
+ class LLMError(Exception):
29
+ pass
30
+
31
+
32
+ class LLM:
33
+ def __init__(self, ref, api, base_url, api_key, model, options):
34
+ self.ref, self.api, self.base_url = ref, api, base_url.rstrip("/")
35
+ self.api_key, self.model, self.options = api_key, model, options
36
+ self._stop, self._response = threading.Event(), None
37
+
38
+ async def complete(self, system, messages, tools, emit):
39
+ """emit(kind, 增量文本),kind 是 text / thinking。返回中立格式的 assistant 消息。"""
40
+ build, path, stream = PROTOCOLS[self.api]
41
+ body = {**build(self, system, messages, tools), "stream": True}
42
+ self._stop.clear()
43
+ reply = await asyncio.to_thread(self._run, path, body, stream(emit))
44
+ return {**reply, "api": self.api, "ref": self.ref}
45
+
46
+ def abort(self):
47
+ """Ctrl+C 时从主线程调:让读流的线程尽快停下。"""
48
+ self._stop.set()
49
+ if self._response:
50
+ try:
51
+ self._response.close()
52
+ except Exception:
53
+ pass
54
+
55
+ def _run(self, path, body, acc):
56
+ headers = {"content-type": "application/json", "accept": "text/event-stream",
57
+ "authorization": f"Bearer {self.api_key}"}
58
+ if self.api == "anthropic":
59
+ headers |= {"x-api-key": self.api_key, "anthropic-version": "2023-06-01"}
60
+ request = urllib.request.Request(
61
+ self.base_url + path, data=json.dumps(body).encode(), headers=headers, method="POST")
62
+ try:
63
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as response:
64
+ self._response = response
65
+ for raw in response:
66
+ if self._stop.is_set():
67
+ raise LLMError("已中断")
68
+ line = raw.decode("utf-8", errors="replace").strip()
69
+ if not line.startswith("data:"):
70
+ continue
71
+ data = line[5:].strip()
72
+ if data == "[DONE]":
73
+ break
74
+ event = json.loads(data)
75
+ if isinstance(event.get("error"), dict) or event.get("type") == "error":
76
+ raise LLMError(json.dumps(event.get("error") or event, ensure_ascii=False)[:800])
77
+ acc.add(event)
78
+ except urllib.error.HTTPError as error:
79
+ raise LLMError(f"{error.code} {error.read().decode(errors='replace')[:800]}") from None
80
+ except urllib.error.URLError as error:
81
+ raise LLMError(f"连不上 {self.base_url}:{error.reason}") from None
82
+ except (OSError, ValueError) as error:
83
+ if self._stop.is_set():
84
+ raise LLMError("已中断") from None
85
+ raise
86
+ finally:
87
+ self._response = None
88
+ return acc.result()
89
+
90
+
91
+ def _native(llm, m):
92
+ """这条 assistant 消息是不是当前这个模型产生的 —— 是的话可以原样回放。"""
93
+ return m.get("api") == llm.api and m.get("ref") == llm.ref and m.get("raw") is not None
94
+
95
+
96
+ def _plain(m):
97
+ """别家模型的回复:thinking 变普通文本(签名只对原模型有效,丢掉)。"""
98
+ thinking = m.get("thinking") or ""
99
+ return (f"<thinking>\n{thinking}\n</thinking>\n\n" if thinking.strip() else "") + (m["text"] or "")
100
+
101
+
102
+ def _parts(content, text_type, image):
103
+ if isinstance(content, str):
104
+ return [{"type": text_type, "text": content}]
105
+ return [{"type": text_type, "text": p["text"]} if p["type"] == "text" else image(p["url"]) for p in content]
106
+
107
+
108
+ # ---------------- openai-chat ----------------
109
+
110
+ def _chat_build(llm, system, messages, tools):
111
+ out = [{"role": "system", "content": system}]
112
+ for m in messages:
113
+ if m["role"] == "user":
114
+ content = m["content"] if isinstance(m["content"], str) else _parts(
115
+ m["content"], "text", lambda url: {"type": "image_url", "image_url": {"url": url}})
116
+ out.append({"role": "user", "content": content})
117
+ elif m["role"] == "assistant":
118
+ native = _native(llm, m)
119
+ msg = {"role": "assistant", "content": (m["text"] if native else _plain(m)) or None}
120
+ if native:
121
+ msg |= m["raw"] # DeepSeek / qwen 的 reasoning_content,工具调用中间轮要带回去
122
+ if m["tool_calls"]:
123
+ msg["tool_calls"] = [{"id": t["id"], "type": "function", "function": {
124
+ "name": t["name"], "arguments": t["arguments"]}} for t in m["tool_calls"]]
125
+ out.append(msg)
126
+ else:
127
+ out.append({"role": "tool", "tool_call_id": m["id"], "content": m["content"]})
128
+ body = {"model": llm.model, "messages": out, **llm.options}
129
+ if tools:
130
+ body["tools"] = [{"type": "function", "function": t} for t in tools]
131
+ return body
132
+
133
+
134
+ class _ChatStream:
135
+ def __init__(self, emit):
136
+ self.emit, self.text, self.thinking, self.calls = emit, [], [], {}
137
+
138
+ def add(self, event):
139
+ for choice in event.get("choices") or []:
140
+ delta = choice.get("delta") or {}
141
+ if delta.get("reasoning_content"):
142
+ self.thinking.append(delta["reasoning_content"])
143
+ self.emit("thinking", delta["reasoning_content"])
144
+ if delta.get("content"):
145
+ self.text.append(delta["content"])
146
+ self.emit("text", delta["content"])
147
+ for t in delta.get("tool_calls") or []:
148
+ call = self.calls.setdefault(t.get("index", 0), {"id": "", "name": "", "arguments": ""})
149
+ function = t.get("function") or {}
150
+ call["id"] = t.get("id") or call["id"]
151
+ call["name"] += function.get("name") or ""
152
+ call["arguments"] += function.get("arguments") or ""
153
+
154
+ def result(self):
155
+ thinking = "".join(self.thinking)
156
+ return {"role": "assistant", "text": "".join(self.text), "thinking": thinking,
157
+ "tool_calls": [{**c, "arguments": c["arguments"] or "{}"} for _, c in sorted(self.calls.items())],
158
+ "raw": {"reasoning_content": thinking} if thinking else {}}
159
+
160
+
161
+ # ---------------- openai-responses ----------------
162
+
163
+ def _responses_build(llm, system, messages, tools):
164
+ items = []
165
+ for m in messages:
166
+ if m["role"] == "user":
167
+ items.append({"role": "user", "content": _parts(
168
+ m["content"], "input_text", lambda url: {"type": "input_image", "image_url": url})})
169
+ elif m["role"] == "assistant":
170
+ if _native(llm, m):
171
+ items += m["raw"]
172
+ continue
173
+ if _plain(m):
174
+ items.append({"role": "assistant", "content": _plain(m)})
175
+ items += [{"type": "function_call", "call_id": t["id"], "name": t["name"], "arguments": t["arguments"]}
176
+ for t in m["tool_calls"]]
177
+ else:
178
+ items.append({"type": "function_call_output", "call_id": m["id"], "output": m["content"]})
179
+ body = {"model": llm.model, "instructions": system, "input": items, "store": False, **llm.options}
180
+ if tools:
181
+ body["tools"] = [{"type": "function", **t} for t in tools]
182
+ return body
183
+
184
+
185
+ class _ResponsesStream:
186
+ """增量只用来显示;最终结果取 response.completed 里的完整 response。"""
187
+
188
+ def __init__(self, emit):
189
+ self.emit, self.final = emit, None
190
+
191
+ def add(self, event):
192
+ kind = event.get("type", "")
193
+ if kind == "response.output_text.delta":
194
+ self.emit("text", event["delta"])
195
+ elif kind in ("response.reasoning_summary_text.delta", "response.reasoning_text.delta"):
196
+ self.emit("thinking", event["delta"])
197
+ elif kind in ("response.completed", "response.incomplete"):
198
+ self.final = event["response"]
199
+ elif kind == "response.failed":
200
+ raise LLMError(json.dumps(event["response"].get("error"), ensure_ascii=False)[:800])
201
+
202
+ def result(self):
203
+ if not self.final:
204
+ raise LLMError("流提前结束,没收到 response.completed")
205
+ output = self.final.get("output") or []
206
+ text = "".join(c.get("text", "") for i in output if i["type"] == "message"
207
+ for c in i.get("content") or [] if c["type"] == "output_text")
208
+ thinking = "".join(s.get("text", "") for i in output if i["type"] == "reasoning"
209
+ for s in (i.get("summary") or []) + (i.get("content") or []))
210
+ calls = [{"id": i["call_id"], "name": i["name"], "arguments": i.get("arguments") or "{}"}
211
+ for i in output if i["type"] == "function_call"]
212
+ # store=false 时服务端不记 reasoning,没有加密内容的 reasoning 项回放会报错,丢掉
213
+ raw = [i for i in output if i["type"] != "reasoning" or i.get("encrypted_content")]
214
+ return {"role": "assistant", "text": text, "thinking": thinking, "tool_calls": calls, "raw": raw}
215
+
216
+
217
+ # ---------------- anthropic ----------------
218
+
219
+ def _safe_id(value):
220
+ """Anthropic 要求工具调用 id 匹配 ^[a-zA-Z0-9_-]{1,64}$,别家的 id 不一定满足。"""
221
+ return re.sub(r"[^A-Za-z0-9_-]", "_", value)[:64] or "call"
222
+
223
+
224
+ def _image_block(url):
225
+ head, _, data = url.partition(",")
226
+ return {"type": "image", "source": {
227
+ "type": "base64", "media_type": head.removeprefix("data:").split(";")[0], "data": data}}
228
+
229
+
230
+ def _anthropic_build(llm, system, messages, tools):
231
+ out = []
232
+
233
+ def push(role, blocks):
234
+ # 要求 user / assistant 交替:连续的 tool 结果并进同一条 user
235
+ if not blocks:
236
+ return
237
+ if out and out[-1]["role"] == role:
238
+ out[-1]["content"] += blocks
239
+ else:
240
+ out.append({"role": role, "content": list(blocks)})
241
+
242
+ for m in messages:
243
+ if m["role"] == "user":
244
+ push("user", [b for b in _parts(m["content"], "text", _image_block)
245
+ if b["type"] != "text" or b["text"].strip()] or [{"type": "text", "text": "…"}])
246
+ elif m["role"] == "assistant":
247
+ if _native(llm, m):
248
+ push("assistant", m["raw"])
249
+ continue
250
+ blocks = [{"type": "text", "text": _plain(m)}] if _plain(m).strip() else []
251
+ blocks += [{"type": "tool_use", "id": _safe_id(t["id"]), "name": t["name"],
252
+ "input": _json_or_empty(t["arguments"])} for t in m["tool_calls"]]
253
+ push("assistant", blocks)
254
+ else:
255
+ push("user", [{"type": "tool_result", "tool_use_id": _safe_id(m["id"]), "content": m["content"]}])
256
+
257
+ body = {"model": llm.model, "system": system, "messages": out, "max_tokens": 8192, **llm.options}
258
+ if tools:
259
+ body["tools"] = [{"name": t["name"], "description": t["description"], "input_schema": t["parameters"]}
260
+ for t in tools]
261
+ return body
262
+
263
+
264
+ def _json_or_empty(text):
265
+ try:
266
+ value = json.loads(text or "{}")
267
+ return value if isinstance(value, dict) else {}
268
+ except json.JSONDecodeError:
269
+ return {}
270
+
271
+
272
+ class _AnthropicStream:
273
+ def __init__(self, emit):
274
+ self.emit, self.blocks = emit, {}
275
+
276
+ def add(self, event):
277
+ kind = event.get("type")
278
+ if kind == "content_block_start":
279
+ block = dict(event["content_block"])
280
+ if block["type"] == "tool_use":
281
+ block["input"], block["_json"] = {}, ""
282
+ self.blocks[event["index"]] = block
283
+ elif kind == "content_block_delta":
284
+ block, delta = self.blocks[event["index"]], event["delta"]
285
+ if delta["type"] == "text_delta":
286
+ block["text"] = block.get("text", "") + delta["text"]
287
+ self.emit("text", delta["text"])
288
+ elif delta["type"] == "thinking_delta":
289
+ block["thinking"] = block.get("thinking", "") + delta["thinking"]
290
+ self.emit("thinking", delta["thinking"])
291
+ elif delta["type"] == "signature_delta":
292
+ block["signature"] = block.get("signature", "") + delta["signature"]
293
+ elif delta["type"] == "input_json_delta":
294
+ block["_json"] += delta["partial_json"]
295
+
296
+ def result(self):
297
+ content = []
298
+ for _, block in sorted(self.blocks.items()):
299
+ if "_json" in block:
300
+ block["input"] = _json_or_empty(block.pop("_json"))
301
+ content.append(block)
302
+ return {
303
+ "role": "assistant",
304
+ "text": "".join(b["text"] for b in content if b["type"] == "text"),
305
+ "thinking": "".join(b.get("thinking", "") for b in content if b["type"] == "thinking"),
306
+ "tool_calls": [{"id": b["id"], "name": b["name"], "arguments": json.dumps(b["input"], ensure_ascii=False)}
307
+ for b in content if b["type"] == "tool_use"],
308
+ "raw": content,
309
+ }
310
+
311
+
312
+ PROTOCOLS = {
313
+ "openai-chat": (_chat_build, "/chat/completions", _ChatStream),
314
+ "openai-responses": (_responses_build, "/responses", _ResponsesStream),
315
+ "anthropic": (_anthropic_build, "/v1/messages", _AnthropicStream),
316
+ }
317
+
318
+
319
+ def make_llm(config, ref=None):
320
+ """ref 形如 deepseek/deepseek-flash。配置有问题直接抛 SystemExit,说清楚哪儿不对。"""
321
+ ref = ref or config.get("model")
322
+ if not ref or "/" not in ref:
323
+ raise SystemExit(f"模型要写成 <provider>/<model>,现在是:{ref!r}")
324
+ name, _, model = ref.partition("/")
325
+ provider = config.get("provider", {}).get(name)
326
+ if not provider:
327
+ raise SystemExit(f"配置里没有 provider {name!r},已有:{'、'.join(config.get('provider', {}))}")
328
+ api = provider.get("api", "openai-chat")
329
+ if api not in PROTOCOLS:
330
+ raise SystemExit(f"{name}.api 只能是 {'、'.join(PROTOCOLS)},现在是 {api!r}")
331
+ try:
332
+ base_url, api_key = expand(provider["baseURL"]), expand(provider.get("apiKey", ""))
333
+ options = expand({**provider.get("options", {}),
334
+ **provider.get("models", {}).get(model, {}).get("options", {})})
335
+ except MissingEnv as error:
336
+ raise SystemExit(f"{name} 需要环境变量 {error}")
337
+ except KeyError:
338
+ raise SystemExit(f"{name} 没写 baseURL")
339
+ return LLM(ref, api, base_url, api_key, model, options)
mini_agent/mcp.py ADDED
@@ -0,0 +1,83 @@
1
+ """mini-agent.json 里的 mcp 段 -> 该连哪些 server,以及怎么连。
2
+
3
+ "mcp": {
4
+ "名字": {"type": "local", "command": ["npx", "-y", "..."], "environment": {...}, "enabled": true},
5
+ "名字": {"type": "remote", "url": "https://...", "headers": {...}}
6
+ }
7
+
8
+ 单独跑可以先看配置:uv run python -m mini_agent.mcp
9
+ """
10
+
11
+ import re
12
+
13
+ from .config import MissingEnv, expand
14
+
15
+
16
+ def load_servers(config):
17
+ """返回 (生效的 server 列表, 跳过原因)。一个 server 有问题只是少一批工具。"""
18
+ servers, notes = [], []
19
+ for name, raw in (config.get("mcp") or {}).items():
20
+ if raw.get("enabled") is False:
21
+ notes.append(f"{name}:enabled 是 false")
22
+ continue
23
+ try:
24
+ server = {"name": name, **expand(raw)}
25
+ except MissingEnv as error:
26
+ notes.append(f"{name}:环境变量 {error} 没设置")
27
+ continue
28
+ kind = server.get("type") or ("remote" if server.get("url") else "local")
29
+ if kind == "remote" and server.get("url"):
30
+ servers.append({**server, "type": "remote"})
31
+ elif kind == "local" and server.get("command"):
32
+ servers.append({**server, "type": "local"})
33
+ else:
34
+ notes.append(f"{name}:local 要写 command(数组),remote 要写 url")
35
+ return servers, notes
36
+
37
+
38
+ async def open_session(stack, server):
39
+ """连上一个 server,生命周期挂到 AsyncExitStack 上,退出时统一关掉。"""
40
+ from mcp import ClientSession, StdioServerParameters
41
+ from mcp.client.stdio import stdio_client
42
+ from mcp.client.streamable_http import streamable_http_client
43
+
44
+ if server["type"] == "remote":
45
+ client = None
46
+ if server.get("headers"):
47
+ import httpx2 # mcp 自己的依赖
48
+
49
+ client = await stack.enter_async_context(httpx2.AsyncClient(headers=server["headers"], timeout=30))
50
+ transport = streamable_http_client(server["url"], http_client=client)
51
+ else:
52
+ command, *args = server["command"]
53
+ transport = stdio_client(StdioServerParameters(command=command, args=args, env=server.get("environment")))
54
+ read, write = await stack.enter_async_context(transport)
55
+ session = await stack.enter_async_context(ClientSession(read, write))
56
+ await session.initialize()
57
+ return session
58
+
59
+
60
+ def describe(server):
61
+ """打印用。url 里常带 key,别原样打出来。"""
62
+ if server["type"] == "remote":
63
+ return "remote " + re.sub(r"((?:key|token)=)[^&]+", r"\1***", server["url"], flags=re.I)
64
+ return "local " + " ".join(server["command"])
65
+
66
+
67
+ def main():
68
+ from .config import load
69
+
70
+ config, sources = load()
71
+ servers, notes = load_servers(config)
72
+ print("配置:" + "、".join(map(str, sources)) + "\n")
73
+ print(f"生效的 server({len(servers)} 个):")
74
+ for s in servers:
75
+ print(f" {s['name']:<20} {describe(s)}")
76
+ if notes:
77
+ print(f"\n跳过的({len(notes)} 个):")
78
+ for note in notes:
79
+ print(f" {note}")
80
+
81
+
82
+ if __name__ == "__main__":
83
+ main()
@@ -0,0 +1,81 @@
1
+ // mini-agent 配置示例。首次运行会复制到 ~/.config/mini-agent/mini-agent.json。
2
+ // 项目里放一个 mini-agent.json,会深度合并覆盖全局配置(从当前目录往上找到 git 根目录)。
3
+ // 支持整行 // 注释。密钥写成 {env:变量名},不要明文写进来。
4
+ {
5
+ // 默认模型:<provider>/<model>。运行时可以用 --model 或 /model 切换
6
+ "model": "deepseek/deepseek-flash",
7
+
8
+ "provider": {
9
+ // api 三选一:
10
+ // openai-chat POST {baseURL}/chat/completions 绝大多数厂商都兼容
11
+ // openai-responses POST {baseURL}/responses
12
+ // anthropic POST {baseURL}/v1/messages
13
+ // options 原样并进请求体;models.<id>.options 只对那个模型生效,优先级更高
14
+ "deepseek": {
15
+ "api": "openai-chat",
16
+ "baseURL": "https://api.deepseek.com",
17
+ "apiKey": "{env:DEEPSEEK_API_KEY}"
18
+ },
19
+ "bailian": {
20
+ "api": "openai-chat",
21
+ "baseURL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
22
+ "apiKey": "{env:DASHSCOPE_API_KEY}",
23
+ // qwen3.8-flash 开思考时偶尔会忽略用户这一轮的问题(实测约 1/3),默认关掉;
24
+ // 换 plus / max 或想看思考过程(灰色显示)再打开
25
+ "options": { "enable_thinking": false }
26
+ },
27
+ "bailian-responses": {
28
+ "api": "openai-responses",
29
+ "baseURL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
30
+ "apiKey": "{env:DASHSCOPE_API_KEY}"
31
+ },
32
+ "bailian-anthropic": {
33
+ "api": "anthropic",
34
+ "baseURL": "https://dashscope.aliyuncs.com/apps/anthropic",
35
+ "apiKey": "{env:DASHSCOPE_API_KEY}",
36
+ "options": { "max_tokens": 8192 }
37
+ },
38
+ "openai": {
39
+ "api": "openai-responses",
40
+ "baseURL": "https://api.openai.com/v1",
41
+ "apiKey": "{env:OPENAI_API_KEY}"
42
+ },
43
+ "anthropic": {
44
+ "api": "anthropic",
45
+ "baseURL": "https://api.anthropic.com",
46
+ "apiKey": "{env:ANTHROPIC_API_KEY}",
47
+ "options": { "max_tokens": 16000 }
48
+ }
49
+ },
50
+
51
+ // type: local(启动子进程,stdio)或 remote(streamable HTTP)
52
+ // 缺 {env:} 变量或 enabled 为 false 的会被跳过,不影响启动
53
+ "mcp": {
54
+ "sequential-thinking": {
55
+ "type": "local",
56
+ "command": ["npx", "-y", "@modelcontextprotocol/server-sequential-thinking"]
57
+ },
58
+ "brave-search": {
59
+ "type": "local",
60
+ "command": ["npx", "-y", "@modelcontextprotocol/server-brave-search"],
61
+ "environment": { "BRAVE_API_KEY": "{env:BRAVE_API_KEY}" }
62
+ },
63
+ "amap": {
64
+ "type": "remote",
65
+ "url": "https://mcp.amap.com/mcp?key={env:AMAP_MAPS_API_KEY}",
66
+ "enabled": true
67
+ }
68
+ },
69
+
70
+ // 每类操作:ask(先问)/ allow(直接放行)/ deny(禁止)。--yes 会把 ask 全部当 allow
71
+ // bash 执行命令
72
+ // edit write / edit 改文件
73
+ // external 读工作目录和 skill 目录以外的路径
74
+ // mcp 名字以 create / delete / send / run 等动词开头的 MCP 工具
75
+ "permission": {
76
+ "bash": "ask",
77
+ "edit": "ask",
78
+ "external": "ask",
79
+ "mcp": "ask"
80
+ }
81
+ }
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: mini-agent-config
3
+ description: 当用户要添加、修改、删除、开关 MCP server,或者切换 / 新增模型厂商、调整权限时使用。负责改 mini-agent.json
4
+ ---
5
+
6
+ # 修改 mini-agent 配置
7
+
8
+ ## 改哪个文件
9
+
10
+ - 全局:`~/.config/mini-agent/mini-agent.json`(环境变量 `MINI_AGENT_CONFIG` 可能改了路径,先 `echo $MINI_AGENT_CONFIG` 确认)
11
+ - 项目:git 根目录或当前目录下的 `mini-agent.json`,深度合并覆盖全局
12
+
13
+ 用户没说改哪个,就问一句:只在这个项目用,还是所有地方都用。完整格式见 `../../mini-agent.example.json`(相对本 skill 目录)。
14
+
15
+ ## 步骤
16
+
17
+ 1. 先 read 目标文件。文件是 JSON,允许整行 `//` 注释
18
+ 2. 用 edit 做最小改动,不要整体重写,保留用户原有的注释和顺序
19
+ 3. 密钥一律写成 `{env:变量名}`。用户把 key 直接贴给你时,不要写进文件,告诉用户用 `export 变量名=...` 加到 shell 配置里
20
+ 4. 改完用 bash 跑 `python3 -c "import re,json,sys; json.loads(re.sub(r'(?m)^\s*//.*$','',open(sys.argv[1]).read()))" <文件>` 确认还是合法 JSON
21
+ 5. 告诉用户输入 `/reload` 生效
22
+
23
+ ## MCP 格式
24
+
25
+ ```json
26
+ "mcp": {
27
+ "名字": {"type": "local", "command": ["npx", "-y", "包名"], "environment": {"KEY": "{env:KEY}"}},
28
+ "名字": {"type": "remote", "url": "https://...", "headers": {"Authorization": "Bearer {env:TOKEN}"}},
29
+ "名字": {"...": "...", "enabled": false}
30
+ }
31
+ ```
32
+
33
+ - 不确定一个 MCP 包叫什么、要哪些环境变量时,先用 bash 查(`npm view <包名>`,或看它的 README),不要编
34
+ - 关掉一个 server 用 `"enabled": false`,不要删掉整段
35
+
36
+ ## 模型格式
37
+
38
+ ```json
39
+ "model": "provider名/模型id",
40
+ "provider": {
41
+ "名字": {"api": "openai-chat | openai-responses | anthropic", "baseURL": "...", "apiKey": "{env:KEY}",
42
+ "options": {"原样并进请求体的参数": "..."}}
43
+ }
44
+ ```
45
+
46
+ baseURL 的写法和各家 SDK 一致:openai 两种协议写到 `/v1` 为止,anthropic 不带 `/v1`。