magic-agent 1.0.0

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.
Files changed (126) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -0
  3. package/cmd/magic-agent/main.go +12 -0
  4. package/cmd/magic-client/main.go +144 -0
  5. package/go.mod +11 -0
  6. package/go.sum +12 -0
  7. package/internal/agent/arkclaw.go +587 -0
  8. package/internal/agent/arkclaw_stream.go +312 -0
  9. package/internal/agent/arkclaw_stream_test.go +428 -0
  10. package/internal/agent/arkclaw_test.go +827 -0
  11. package/internal/agent/ask.go +694 -0
  12. package/internal/agent/ask_test.go +587 -0
  13. package/internal/agent/attachments.go +200 -0
  14. package/internal/agent/attachments_test.go +481 -0
  15. package/internal/agent/capability.go +154 -0
  16. package/internal/agent/capability_test.go +190 -0
  17. package/internal/agent/claude.go +406 -0
  18. package/internal/agent/codebuddy.go +1044 -0
  19. package/internal/agent/codebuddy_account_test.go +128 -0
  20. package/internal/agent/codebuddy_gateway.go +479 -0
  21. package/internal/agent/codebuddy_gateway_stream.go +358 -0
  22. package/internal/agent/codebuddy_gateway_test.go +673 -0
  23. package/internal/agent/codex.go +599 -0
  24. package/internal/agent/codex_home.go +206 -0
  25. package/internal/agent/codex_home_test.go +130 -0
  26. package/internal/agent/codex_stream_test.go +97 -0
  27. package/internal/agent/codex_test.go +280 -0
  28. package/internal/agent/custom_models.go +197 -0
  29. package/internal/agent/custom_models_test.go +283 -0
  30. package/internal/agent/dsh.go +754 -0
  31. package/internal/agent/dsh_sdk.go +679 -0
  32. package/internal/agent/dsh_sdk_test.go +726 -0
  33. package/internal/agent/dsh_test.go +759 -0
  34. package/internal/agent/engine.go +469 -0
  35. package/internal/agent/engine_base.go +497 -0
  36. package/internal/agent/engine_base_test.go +308 -0
  37. package/internal/agent/engine_test.go +858 -0
  38. package/internal/agent/env.go +88 -0
  39. package/internal/agent/env_test.go +106 -0
  40. package/internal/agent/jsonextract.go +128 -0
  41. package/internal/agent/jsonextract_test.go +110 -0
  42. package/internal/agent/keepalive_test.go +216 -0
  43. package/internal/agent/llmengine.go +470 -0
  44. package/internal/agent/llmengine_test.go +551 -0
  45. package/internal/agent/llmhttp.go +437 -0
  46. package/internal/agent/llmhttp_test.go +304 -0
  47. package/internal/agent/llmmodels.go +205 -0
  48. package/internal/agent/login.go +27 -0
  49. package/internal/agent/main_test.go +23 -0
  50. package/internal/agent/models.go +686 -0
  51. package/internal/agent/models_test.go +877 -0
  52. package/internal/agent/openclaw.go +543 -0
  53. package/internal/agent/openclaw_acp.go +949 -0
  54. package/internal/agent/openclaw_acp_test.go +406 -0
  55. package/internal/agent/openclaw_test.go +451 -0
  56. package/internal/agent/output.go +191 -0
  57. package/internal/agent/permission.go +362 -0
  58. package/internal/agent/permission_test.go +526 -0
  59. package/internal/agent/prompt.go +65 -0
  60. package/internal/agent/runcmd.go +259 -0
  61. package/internal/agent/runcmd_test.go +81 -0
  62. package/internal/agent/runcmd_unix.go +56 -0
  63. package/internal/agent/runcmd_windows.go +66 -0
  64. package/internal/agent/runner.go +180 -0
  65. package/internal/agent/runner_test.go +263 -0
  66. package/internal/agent/spawnhook_test.go +96 -0
  67. package/internal/agent/stream.go +882 -0
  68. package/internal/agent/stream_test.go +565 -0
  69. package/internal/agent/streamjson.go +185 -0
  70. package/internal/agent/tags.go +52 -0
  71. package/internal/agent/toolkind.go +96 -0
  72. package/internal/agent/toolkind_test.go +70 -0
  73. package/internal/agent/tools_suffix_test.go +261 -0
  74. package/internal/agent/trae.go +342 -0
  75. package/internal/agent/workspace_test.go +166 -0
  76. package/internal/boundary/boundary_test.go +165 -0
  77. package/internal/cli/ask.go +1113 -0
  78. package/internal/cli/ask_test.go +1088 -0
  79. package/internal/cli/attach_test.go +239 -0
  80. package/internal/cli/contract_test.go +157 -0
  81. package/internal/cli/control.go +149 -0
  82. package/internal/cli/control_test.go +192 -0
  83. package/internal/cli/engines_test.go +205 -0
  84. package/internal/cli/events.go +198 -0
  85. package/internal/cli/events_test.go +166 -0
  86. package/internal/cli/keepalive_test.go +391 -0
  87. package/internal/cli/login.go +78 -0
  88. package/internal/cli/permission_test.go +295 -0
  89. package/internal/cli/prompt_source_test.go +222 -0
  90. package/internal/cli/root.go +752 -0
  91. package/internal/cli/session.go +486 -0
  92. package/internal/cli/session_test.go +202 -0
  93. package/internal/cli/sessionlog.go +325 -0
  94. package/internal/cli/sessionlog_command_test.go +204 -0
  95. package/internal/cli/sessionlog_test.go +262 -0
  96. package/internal/cli/sessionlog_user_test.go +129 -0
  97. package/internal/cli/toolkind_wire_test.go +84 -0
  98. package/internal/cli/workspace_test.go +188 -0
  99. package/internal/client/cors_test.go +136 -0
  100. package/internal/client/lan.go +68 -0
  101. package/internal/client/lan_test.go +53 -0
  102. package/internal/client/pair.go +163 -0
  103. package/internal/client/pair_test.go +109 -0
  104. package/internal/client/profile.go +184 -0
  105. package/internal/client/service.go +888 -0
  106. package/internal/client/service_test.go +654 -0
  107. package/internal/client/session_events_test.go +186 -0
  108. package/internal/client/session_messages_test.go +150 -0
  109. package/internal/config/config.go +439 -0
  110. package/internal/config/config_test.go +448 -0
  111. package/internal/session/session.go +447 -0
  112. package/internal/session/session_append.go +241 -0
  113. package/internal/session/session_append_test.go +161 -0
  114. package/internal/session/session_kill_unix_test.go +90 -0
  115. package/internal/session/session_mark_test.go +95 -0
  116. package/internal/session/session_test.go +237 -0
  117. package/internal/session/sessionlog.go +128 -0
  118. package/internal/session/sessionlog_read_test.go +157 -0
  119. package/npm/dist/darwin-arm64/magic-agent +0 -0
  120. package/npm/dist/darwin-x64/magic-agent +0 -0
  121. package/npm/dist/linux-arm64/magic-agent +0 -0
  122. package/npm/dist/linux-x64/magic-agent +0 -0
  123. package/npm/dist/win32-x64/magic-agent.exe +0 -0
  124. package/npm/install.js +121 -0
  125. package/npm/lib/platform.js +78 -0
  126. package/package.json +36 -0
@@ -0,0 +1,304 @@
1
+ package agent
2
+
3
+ // llmhttp_test.go - llm 引擎「配置文件模型」直连链路的单测(httptest 打桩,不碰真网络)。
4
+ //
5
+ // 覆盖:
6
+ // - models.json 解析 / 查表(id 精确 + 忽略大小写;空 -m → 首条;未知 id → 不命中)
7
+ // - 非流式直连:真实模型名(model 字段)、extraBody 落请求体顶层、role 消息、token 计数、
8
+ // <think> 标签剥离
9
+ // - 流式直连:SSE delta 分流 thinking/text、**标签被分块切开**也要正确剥离、[DONE] 收尾
10
+ // - 错误:非 2xx → `HTTP <code>: 片段`(isRetryable 认这个前缀)
11
+ // - 路由:命中配置文件时**不启动 llm CLI**(BinPath 指向不存在的文件也必须成功)
12
+
13
+ import (
14
+ "context"
15
+ "encoding/json"
16
+ "fmt"
17
+ "io"
18
+ "net/http"
19
+ "net/http/httptest"
20
+ "os"
21
+ "path/filepath"
22
+ "strings"
23
+ "testing"
24
+ "time"
25
+ )
26
+
27
+ // writeModelsJSON 造一份临时 models.json 并把它设为配置来源。
28
+ // 注意 URL 占位符由调用方替换成 httptest 的地址。
29
+ func writeModelsJSON(t *testing.T, entries []map[string]any) string {
30
+ t.Helper()
31
+ dir := t.TempDir()
32
+ p := filepath.Join(dir, "models.json")
33
+ raw, err := json.Marshal(entries)
34
+ if err != nil {
35
+ t.Fatalf("marshal models.json: %v", err)
36
+ }
37
+ if err := os.WriteFile(p, raw, 0o600); err != nil {
38
+ t.Fatalf("write models.json: %v", err)
39
+ }
40
+ t.Setenv("MAGIC_AGENT_MODELS", p)
41
+ return p
42
+ }
43
+
44
+ func TestLoadAndLookupLLMConfigModels(t *testing.T) {
45
+ writeModelsJSON(t, []map[string]any{
46
+ {"id": "MiniMax-M3", "name": "MiniMax-M3", "url": "http://127.0.0.1:1/v1/chat/completions", "apiKey": "k1"},
47
+ {"id": "minimax-nothink", "model": "MiniMax-M3", "name": "MiniMax-M3 (no thinking)",
48
+ "url": "http://127.0.0.1:1/v1", "apiKey": "k2",
49
+ "extraBody": map[string]any{"thinking": map[string]any{"type": "disabled"}}},
50
+ {"name": "没有 id 的行应被忽略", "url": "http://127.0.0.1:1/v1"},
51
+ })
52
+
53
+ list, err := LoadLLMConfigModels()
54
+ if err != nil {
55
+ t.Fatalf("LoadLLMConfigModels: %v", err)
56
+ }
57
+ if len(list) != 2 {
58
+ t.Fatalf("应跳过没有 id 的行,得到 %d 条", len(list))
59
+ }
60
+
61
+ // 精确 + 忽略大小写
62
+ if m, ok := LookupLLMConfigModel("minimax-nothink"); !ok || m.WireModel() != "MiniMax-M3" {
63
+ t.Errorf("minimax-nothink 应命中且真实模型名为 MiniMax-M3,得 %+v ok=%v", m, ok)
64
+ }
65
+ if m, ok := LookupLLMConfigModel("minimax-m3"); !ok || m.ID != "MiniMax-M3" {
66
+ t.Errorf("大小写不同的 id 也应命中,得 %+v ok=%v", m, ok)
67
+ }
68
+ if _, ok := LookupLLMConfigModel("nope"); ok {
69
+ t.Error("未知 id 不应命中")
70
+ }
71
+ // 空 -m → 首条
72
+ if m, ok := ResolveLLMConfigModel(""); !ok || m.ID != "MiniMax-M3" {
73
+ t.Errorf("空 -m 应取首条,得 %+v ok=%v", m, ok)
74
+ }
75
+ // 端点补全:只给 base 的要补 /chat/completions,写全的保持原样
76
+ if got := list[1].Endpoint(); got != "http://127.0.0.1:1/v1/chat/completions" {
77
+ t.Errorf("Endpoint 补全失败: %s", got)
78
+ }
79
+ if got := list[0].Endpoint(); got != "http://127.0.0.1:1/v1/chat/completions" {
80
+ t.Errorf("已写全的端点不应被改写: %s", got)
81
+ }
82
+ }
83
+
84
+ func TestResolveLLMConfigModelWithoutFile(t *testing.T) {
85
+ t.Setenv("MAGIC_AGENT_MODELS", filepath.Join(t.TempDir(), "nope.json"))
86
+ if _, ok := ResolveLLMConfigModel(""); ok {
87
+ t.Error("没有配置文件时空 -m 不应命中(应回退 llm CLI 默认)")
88
+ }
89
+ if _, ok := ResolveLLMConfigModel("MiniMax-M3"); ok {
90
+ t.Error("没有配置文件时任何 id 都不应命中")
91
+ }
92
+ }
93
+
94
+ func TestCompleteConfiguredDirect(t *testing.T) {
95
+ var gotBody map[string]any
96
+ var gotAuth, gotPath string
97
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
98
+ gotPath = r.URL.Path
99
+ gotAuth = r.Header.Get("Authorization")
100
+ raw, _ := io.ReadAll(r.Body)
101
+ _ = json.Unmarshal(raw, &gotBody)
102
+ fmt.Fprint(w, `{"choices":[{"message":{"content":"<think>内部推理</think>你好"}}],`+
103
+ `"usage":{"prompt_tokens":11,"completion_tokens":3}}`)
104
+ }))
105
+ defer srv.Close()
106
+
107
+ writeModelsJSON(t, []map[string]any{
108
+ {"id": "MiniMax-M3", "name": "MiniMax-M3", "url": srv.URL + "/v1", "apiKey": "sk-test"},
109
+ {"id": "minimax-nothink", "model": "MiniMax-M3", "name": "MiniMax-M3 (no thinking)",
110
+ "url": srv.URL + "/v1/chat/completions", "apiKey": "sk-test",
111
+ "extraBody": map[string]any{"thinking": map[string]any{"type": "disabled"}}},
112
+ })
113
+
114
+ e := &LLMEngine{BinPath: "/nonexistent/llm"} // 命中配置文件就不该去启动 CLI
115
+ resp, err := e.Complete(context.Background(), Request{
116
+ Engine: "llm",
117
+ Model: "minimax-nothink",
118
+ SystemPrompt: "你是助手",
119
+ Messages: []Message{{Role: "user", Content: "只回两个字:你好"}},
120
+ MaxTokens: 64,
121
+ })
122
+ if err != nil {
123
+ t.Fatalf("Complete: %v", err)
124
+ }
125
+ if resp.Text != "你好" {
126
+ t.Errorf("思维链剥离后正文应为「你好」,得 %q", resp.Text)
127
+ }
128
+ if resp.Model != "minimax-nothink" {
129
+ t.Errorf("Model 应回填配置里的 id(唯一标识,真实名会重复),得 %q", resp.Model)
130
+ }
131
+ if resp.InputTokens != 11 || resp.OutputTokens != 3 || resp.TotalTokens != 14 {
132
+ t.Errorf("token 计数应回填 11/3/14,得 %d/%d/%d", resp.InputTokens, resp.OutputTokens, resp.TotalTokens)
133
+ }
134
+ if gotAuth != "Bearer sk-test" {
135
+ t.Errorf("鉴权头应带 Bearer apiKey,得 %q", gotAuth)
136
+ }
137
+ if gotPath != "/v1/chat/completions" {
138
+ t.Errorf("端点补全错误: %s", gotPath)
139
+ }
140
+ // 关键契约:model = 真实模型名;extraBody 落到请求体顶层;messages 用原生 role
141
+ if gotBody["model"] != "MiniMax-M3" {
142
+ t.Errorf("请求体 model 应为真实模型名,得 %v", gotBody["model"])
143
+ }
144
+ if gotBody["stream"] != false {
145
+ t.Errorf("非流式应 stream=false,得 %v", gotBody["stream"])
146
+ }
147
+ if gotBody["max_tokens"] != float64(64) {
148
+ t.Errorf("调用方给的 max_tokens 应透传,得 %v", gotBody["max_tokens"])
149
+ }
150
+ thinking, _ := gotBody["thinking"].(map[string]any)
151
+ if thinking == nil || thinking["type"] != "disabled" {
152
+ t.Errorf("extraBody 未 merge 到请求体顶层: %v", gotBody["thinking"])
153
+ }
154
+ msgs, _ := gotBody["messages"].([]any)
155
+ if len(msgs) != 2 {
156
+ t.Fatalf("messages 应有 system+user 两条,得 %v", gotBody["messages"])
157
+ }
158
+ first, _ := msgs[0].(map[string]any)
159
+ if first["role"] != "system" || first["content"] != "你是助手" {
160
+ t.Errorf("system 消息不对: %v", first)
161
+ }
162
+ }
163
+
164
+ func TestCompleteConfiguredHTTPError(t *testing.T) {
165
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
166
+ w.WriteHeader(http.StatusUnauthorized)
167
+ fmt.Fprint(w, "External\nauthentication failed.")
168
+ }))
169
+ defer srv.Close()
170
+ writeModelsJSON(t, []map[string]any{{"id": "m1", "url": srv.URL + "/v1", "apiKey": "bad"}})
171
+
172
+ _, err := (&LLMEngine{BinPath: "/nonexistent/llm"}).Complete(context.Background(),
173
+ Request{Engine: "llm", Model: "m1", Messages: []Message{{Role: "user", Content: "hi"}}})
174
+ if err == nil {
175
+ t.Fatal("401 应报错")
176
+ }
177
+ if !strings.Contains(err.Error(), "HTTP 401") {
178
+ t.Errorf("错误应带 `HTTP <code>:` 前缀,得 %q", err.Error())
179
+ }
180
+ if strings.Contains(err.Error(), "\n") {
181
+ t.Errorf("错误正文应压成单行,得 %q", err.Error())
182
+ }
183
+ }
184
+
185
+ func TestStreamConfiguredDirect(t *testing.T) {
186
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
187
+ w.Header().Set("Content-Type", "text/event-stream")
188
+ flusher, _ := w.(http.Flusher)
189
+ send := func(s string) {
190
+ fmt.Fprint(w, s)
191
+ if flusher != nil {
192
+ flusher.Flush()
193
+ }
194
+ }
195
+ // reasoning 走独立通道;content 里带 <think> 标签,且**标签被切成两块**(严酷用例)
196
+ send("data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"先想\"}}]}\n\n")
197
+ send("data: {\"choices\":[{\"delta\":{\"content\":\"<thi\"}}]}\n\n")
198
+ send("data: {\"choices\":[{\"delta\":{\"content\":\"nk>秘密</think>你\"}}]}\n\n")
199
+ send("data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}\n\n")
200
+ send("data: [DONE]\n\n")
201
+ }))
202
+ defer srv.Close()
203
+ writeModelsJSON(t, []map[string]any{{"id": "mini", "url": srv.URL + "/v1", "apiKey": "k"}})
204
+
205
+ var kinds []string
206
+ res, err := (&LLMEngine{BinPath: "/nonexistent/llm"}).Stream(context.Background(),
207
+ Request{Engine: "llm", Model: "mini", Messages: []Message{{Role: "user", Content: "hi"}}},
208
+ func(ev StreamEvent) { kinds = append(kinds, string(ev.Kind)+":"+ev.Text) })
209
+ if err != nil {
210
+ t.Fatalf("Stream: %v", err)
211
+ }
212
+ if res.Response.Text != "你好" {
213
+ t.Errorf("正文应为「你好」(标签跨块也要剥掉),得 %q", res.Response.Text)
214
+ }
215
+ if res.Response.Model != "mini" {
216
+ t.Errorf("流式结果的 Model 也应是配置 id(唯一标识),得 %q", res.Response.Model)
217
+ }
218
+ if !strings.Contains(res.Thinking, "先想") || !strings.Contains(res.Thinking, "秘密") {
219
+ t.Errorf("思考通道应同时收到 reasoning 通道与标签内的内容,得 %q", res.Thinking)
220
+ }
221
+ if res.Response.InputTokens != 5 || res.Response.OutputTokens != 2 {
222
+ t.Errorf("流式 usage 应回填 5/2,得 %d/%d", res.Response.InputTokens, res.Response.OutputTokens)
223
+ }
224
+ joined := strings.Join(kinds, "|")
225
+ if strings.Contains(joined, "<thi") || strings.Contains(joined, "think>") {
226
+ t.Errorf("不该把标签碎片发给前端: %s", joined)
227
+ }
228
+ }
229
+
230
+ func TestStreamConfiguredNoText(t *testing.T) {
231
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
232
+ fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"只有思考\"}}]}\n\n")
233
+ fmt.Fprint(w, "data: [DONE]\n\n")
234
+ }))
235
+ defer srv.Close()
236
+ writeModelsJSON(t, []map[string]any{{"id": "mini", "url": srv.URL + "/v1"}})
237
+
238
+ _, err := (&LLMEngine{BinPath: "/nonexistent/llm"}).Stream(context.Background(),
239
+ Request{Engine: "llm", Model: "mini", Messages: []Message{{Role: "user", Content: "hi"}}}, nil)
240
+ if err == nil || !strings.Contains(err.Error(), "没有正文") {
241
+ t.Errorf("只有思考没有正文时应报错,得 %v", err)
242
+ }
243
+ }
244
+
245
+ func TestConfiguredModelRejectsSession(t *testing.T) {
246
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
247
+ fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"}}]}`)
248
+ }))
249
+ defer srv.Close()
250
+ writeModelsJSON(t, []map[string]any{{"id": "mini", "url": srv.URL + "/v1"}})
251
+
252
+ e := &LLMEngine{BinPath: "/nonexistent/llm"}
253
+ if _, err := e.Complete(context.Background(), Request{Engine: "llm", Model: "mini",
254
+ SessionID: "abc", Messages: []Message{{Role: "user", Content: "hi"}}}); err == nil {
255
+ t.Error("显式 --session 时应明确报错(直连无会话库),不能静默丢上下文")
256
+ }
257
+ if _, err := e.Complete(context.Background(), Request{Engine: "llm", Model: "mini",
258
+ Continue: true, Messages: []Message{{Role: "user", Content: "hi"}}}); err == nil {
259
+ t.Error("显式 -c 时应明确报错")
260
+ }
261
+ }
262
+
263
+ // 未知 id(不在配置文件里)必须仍走 llm CLI 路径 —— 用不存在的 BinPath 触发 CLI 探测失败,
264
+ // 错误里应出现 llm CLI 相关字样,而不是直连才有的报错。
265
+ func TestUnknownModelFallsBackToCLI(t *testing.T) {
266
+ writeModelsJSON(t, []map[string]any{{"id": "mini", "url": "http://127.0.0.1:1/v1"}})
267
+ _, err := (&LLMEngine{BinPath: "/nonexistent/llm"}).Complete(context.Background(),
268
+ Request{Engine: "llm", Model: "not-in-config", Messages: []Message{{Role: "user", Content: "hi"}}})
269
+ if err == nil {
270
+ t.Fatal("BinPath 不存在时应失败")
271
+ }
272
+ if !strings.Contains(err.Error(), "llm CLI") {
273
+ t.Errorf("未知 id 应回退 llm CLI 路径,得 %q", err.Error())
274
+ }
275
+ }
276
+
277
+ func TestExpandEnvKeep(t *testing.T) {
278
+ t.Setenv("LLM_TEST_KEY", "secret")
279
+ cases := map[string]string{
280
+ "${LLM_TEST_KEY}": "secret",
281
+ "$LLM_TEST_KEY": "secret",
282
+ "Bearer $LLM_TEST_KEY!": "Bearer secret!",
283
+ "${LLM_TEST_KEY_NOT_SET}": "${LLM_TEST_KEY_NOT_SET}", // 未设置 → 保留占位(暴露配置问题)
284
+ "plain": "plain",
285
+ }
286
+ for in, want := range cases {
287
+ if got := expandEnvKeep(in); got != want {
288
+ t.Errorf("expandEnvKeep(%q) = %q, want %q", in, got, want)
289
+ }
290
+ }
291
+ }
292
+
293
+ // 默认超时:条目里给了 timeout(秒)就用它,调用方给了 Timeout 优先。
294
+ func TestConfiguredTimeout(t *testing.T) {
295
+ if got := llmConfiguredTimeout(LLMConfigModel{Timeout: 30}, Request{}); got != 30*time.Second {
296
+ t.Errorf("条目 timeout 应生效,得 %v", got)
297
+ }
298
+ if got := llmConfiguredTimeout(LLMConfigModel{Timeout: 30}, Request{Timeout: 5 * time.Second}); got != 5*time.Second {
299
+ t.Errorf("调用方 Timeout 应优先,得 %v", got)
300
+ }
301
+ if got := llmConfiguredTimeout(LLMConfigModel{}, Request{}); got != DefaultLLMTimeout {
302
+ t.Errorf("都没给时用引擎默认,得 %v", got)
303
+ }
304
+ }
@@ -0,0 +1,205 @@
1
+ package agent
2
+
3
+ // llmmodels.go - `~/.magic-agent/models.json`:llm 引擎的「配置文件模型」注册表。
4
+ //
5
+ // 用户定稿(2026-09-17)原话:
6
+ //
7
+ // 「llm模式下可选的是配置文件中的」+「配置文件里不是有模型真实名字吗 参数传id
8
+ // 否则无法区分用一个名字的模型」
9
+ //
10
+ // 由此确定的契约:
11
+ //
12
+ // id —— 调用标识(`-m <id>`、界面下拉里的值)。**唯一**,用来区分真实名相同的变体
13
+ // model —— 线上「真实模型名」(缺省 = id)。同一个真实名可以挂多条配置
14
+ // name —— 展示名(只给人看,不参与执行)
15
+ // 其余键 —— 端点 url / apiKey / timeout / extraBody 等,执行时按 id 查表直连
16
+ //
17
+ // 为什么不能只把 id 原样交给 simonw/LLM CLI:llm CLI 只认它自己注册的 model_id,
18
+ // 且无法表达每条配置自带的 extraBody —— 实测 `minimax-nothink` 与 `MiniMax-M3`
19
+ // 真实模型名相同,只靠 `{"thinking":{"type":"disabled"}}` 区分(直连实测:默认变体
20
+ // 正文里混 `<think>`,disabled 变体直接出正文)。所以必须由 magic-agent 直连端点。
21
+ //
22
+ // 文件形态(扁平数组,**首条即默认**):
23
+ //
24
+ // [
25
+ // { "id": "MiniMax-M3", "name": "MiniMax-M3", "vendor": "MiniMax",
26
+ // "url": "https://api.minimaxi.com/v1/chat/completions", "apiKey": "sk-…", "timeout": 600 },
27
+ // { "id": "minimax-nothink", "model": "MiniMax-M3", "name": "MiniMax-M3 (no thinking)",
28
+ // "url": "https://api.minimaxi.com/v1/chat/completions", "apiKey": "sk-…",
29
+ // "extraBody": { "thinking": { "type": "disabled" } } }
30
+ // ]
31
+ //
32
+ // 路径:$MAGIC_AGENT_MODELS > ~/.magic-agent/models.json。
33
+ // 文件缺失 / 非法 JSON → 整个直连链路不生效(Lookup 返回 false),llm 引擎退回原有行为
34
+ //(把 -m 原样透传给 llm CLI),不会因为配置写坏而让 llm 引擎整体不可用。
35
+
36
+ import (
37
+ "encoding/json"
38
+ "fmt"
39
+ "os"
40
+ "path/filepath"
41
+ "strings"
42
+ )
43
+
44
+ // LLMConfigModel 是 models.json 的一条配置。
45
+ type LLMConfigModel struct {
46
+ ID string `json:"id"` // 调用标识(-m 的值)
47
+ Model string `json:"model"` // 线上真实模型名(空 = 用 ID)
48
+ Name string `json:"name"` // 展示名(不参与执行)
49
+ Vendor string `json:"vendor"` // 供应商标注(不参与执行)
50
+ URL string `json:"url"` // OpenAI 兼容端点
51
+ APIKey string `json:"apiKey"` // 密钥(支持 ${VAR} 占位)
52
+ Timeout int `json:"timeout"` // 单次调用超时(秒)
53
+ Temperature *float64 `json:"temperature"` // 该模型的默认温度
54
+ MaxOutputTokens int `json:"maxOutputTokens"` // 该模型的默认输出上限
55
+ ExtraBody map[string]any `json:"extraBody"` // 透传到请求体顶层(变体差异常在这)
56
+ }
57
+
58
+ // WireModel 返回实际发给端点的模型名:有 model 用 model,否则用 id。
59
+ func (m LLMConfigModel) WireModel() string {
60
+ if s := strings.TrimSpace(m.Model); s != "" {
61
+ return s
62
+ }
63
+ return strings.TrimSpace(m.ID)
64
+ }
65
+
66
+ // Endpoint 返回最终请求地址。配置里已写明 /chat/completions 就原样用;
67
+ // 只给了 base(如 https://api.minimaxi.com/v1)则补全 —— 实测两种写法都有人用。
68
+ func (m LLMConfigModel) Endpoint() string {
69
+ u := strings.TrimSpace(m.URL)
70
+ if u == "" {
71
+ return ""
72
+ }
73
+ if strings.HasSuffix(u, "/chat/completions") {
74
+ return u
75
+ }
76
+ return strings.TrimRight(u, "/") + "/chat/completions"
77
+ }
78
+
79
+ // LLMModelsPath 返回 models.json 的路径($MAGIC_AGENT_MODELS 优先)。
80
+ func LLMModelsPath() string {
81
+ if p := strings.TrimSpace(os.Getenv("MAGIC_AGENT_MODELS")); p != "" {
82
+ return p
83
+ }
84
+ home, err := os.UserHomeDir()
85
+ if err != nil {
86
+ return ""
87
+ }
88
+ return filepath.Join(home, ".magic-agent", "models.json")
89
+ }
90
+
91
+ // LoadLLMConfigModels 读取并校验配置文件。文件缺失 / 非法 → 空切片 + 错误
92
+ // (调用方一律按「不生效」处理,不向上抛)。
93
+ func LoadLLMConfigModels() ([]LLMConfigModel, error) {
94
+ p := LLMModelsPath()
95
+ if p == "" {
96
+ return nil, fmt.Errorf("cannot resolve models.json path")
97
+ }
98
+ raw, err := os.ReadFile(p)
99
+ if err != nil {
100
+ return nil, err
101
+ }
102
+ var list []LLMConfigModel
103
+ if err := json.Unmarshal(raw, &list); err != nil {
104
+ return nil, fmt.Errorf("invalid models.json (%s): %w", p, err)
105
+ }
106
+ out := make([]LLMConfigModel, 0, len(list))
107
+ for _, m := range list {
108
+ if strings.TrimSpace(m.ID) == "" {
109
+ continue // 没有 id 的行无法被 -m 寻址,跳过
110
+ }
111
+ m.URL = expandEnvKeep(m.URL)
112
+ m.APIKey = expandEnvKeep(m.APIKey)
113
+ out = append(out, m)
114
+ }
115
+ return out, nil
116
+ }
117
+
118
+ // LookupLLMConfigModel 按 id 查配置(精确优先,其次忽略大小写)。
119
+ func LookupLLMConfigModel(id string) (LLMConfigModel, bool) {
120
+ want := strings.TrimSpace(id)
121
+ if want == "" {
122
+ return LLMConfigModel{}, false
123
+ }
124
+ list, err := LoadLLMConfigModels()
125
+ if err != nil {
126
+ return LLMConfigModel{}, false
127
+ }
128
+ for _, m := range list {
129
+ if m.ID == want {
130
+ return m, true
131
+ }
132
+ }
133
+ for _, m := range list {
134
+ if strings.EqualFold(m.ID, want) {
135
+ return m, true
136
+ }
137
+ }
138
+ return LLMConfigModel{}, false
139
+ }
140
+
141
+ // ResolveLLMConfigModel 解析本次调用要用的配置条目:
142
+ //
143
+ // 给了 -m → 按 id 查(查不到 → ok=false,回退 llm CLI 路径)
144
+ // 没给 -m → 取**首条**(「首条即默认」,配置文件就是 llm 引擎的模型注册表)
145
+ func ResolveLLMConfigModel(model string) (LLMConfigModel, bool) {
146
+ if strings.TrimSpace(model) != "" {
147
+ return LookupLLMConfigModel(stripModelPrefix(model))
148
+ }
149
+ list, err := LoadLLMConfigModels()
150
+ if err != nil || len(list) == 0 {
151
+ return LLMConfigModel{}, false
152
+ }
153
+ return list[0], true
154
+ }
155
+
156
+ // expandEnvKeep 展开 ${VAR} / $VAR;**未设置的变量保留原文**(不置空),
157
+ // 让 401/404 直接把「占位没填」这件事暴露出来,而不是静默用空密钥。
158
+ func expandEnvKeep(s string) string {
159
+ if s == "" || !strings.Contains(s, "$") {
160
+ return s
161
+ }
162
+ var sb strings.Builder
163
+ for i := 0; i < len(s); {
164
+ if s[i] != '$' {
165
+ sb.WriteByte(s[i])
166
+ i++
167
+ continue
168
+ }
169
+ // ${VAR}
170
+ if i+1 < len(s) && s[i+1] == '{' {
171
+ if end := strings.IndexByte(s[i+2:], '}'); end >= 0 {
172
+ name := s[i+2 : i+2+end]
173
+ if v, ok := os.LookupEnv(name); ok {
174
+ sb.WriteString(v)
175
+ } else {
176
+ sb.WriteString(s[i : i+2+end+1]) // 原样保留
177
+ }
178
+ i += 2 + end + 1
179
+ continue
180
+ }
181
+ }
182
+ // $VAR(字母/数字/下划线)
183
+ j := i + 1
184
+ for j < len(s) && (isEnvNameChar(s[j])) {
185
+ j++
186
+ }
187
+ if j > i+1 {
188
+ name := s[i+1 : j]
189
+ if v, ok := os.LookupEnv(name); ok {
190
+ sb.WriteString(v)
191
+ } else {
192
+ sb.WriteString(s[i:j])
193
+ }
194
+ i = j
195
+ continue
196
+ }
197
+ sb.WriteByte('$')
198
+ i++
199
+ }
200
+ return sb.String()
201
+ }
202
+
203
+ func isEnvNameChar(c byte) bool {
204
+ return c == '_' || (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
205
+ }
@@ -0,0 +1,27 @@
1
+ package agent
2
+
3
+ // login.go - 交互式登录入口的抽象(`magic-agent --login <engine>`)。
4
+ //
5
+ // 为什么要有这一层:登录态由各引擎的 CLI 自己管,而**登录时必须带上哪个账号**
6
+ // 只有引擎自己知道 —— codebuddy / codebuddy-ai 跑的是同一个 CLI,靠
7
+ // ACC_PRODUCT_CONFIG_V3 切 authentication.id(见 codebuddy.go 的账号隔离一节)。
8
+ // 手工拼这几个环境变量太容易错,错了就会把登录结果写进别的账号的票据文件。
9
+ //
10
+ // 于是约定:引擎把「怎么拉起来」讲清楚(bin / args / extraEnv),
11
+ // CLI 层只负责 exec 并把 stdio 直通给用户。
12
+
13
+ // LoginRunner 由「支持交互式登录」的引擎实现。
14
+ //
15
+ // 只有需要额外账号环境的引擎才实现它;其余引擎的登录态就是 CLI 自己的事
16
+ // (直接运行那个 CLI 即可),CLI 层会给出明确提示而不是硬造一条命令。
17
+ type LoginRunner interface {
18
+ // LoginCommand 返回拉起交互式登录会话所需的三元组:
19
+ //
20
+ // bin 可执行文件路径
21
+ // args 启动参数(通常为空 —— 裸启动即交互式界面,在里面执行 /login)
22
+ // extraEnv 追加到当前环境之上的环境变量(KEY=VALUE;重复 key 取后者)
23
+ //
24
+ // 返回的 extraEnv 会由 ChildEnvWith 与本进程环境合并(同样按 env.go 的规则
25
+ // 剔除父进程专属变量)。
26
+ LoginCommand() (bin string, args []string, extraEnv []string, err error)
27
+ }
@@ -0,0 +1,23 @@
1
+ package agent
2
+
3
+ // main_test.go - 包级测试隔离。
4
+ //
5
+ // ⚠️ 为什么需要:llm 引擎现在会读**用户真实机器**上的 `~/.magic-agent/models.json`
6
+ //(见 llmmodels.go):`-m <id>` 命中即直连端点。若不隔离,单测会
7
+ // ① 把本机注册表当成"假 CLI 的输出"(TestEngineListModelsWithFakeCLI 断言失败)
8
+ // ② 更糟:请求真的打到线上端点(实测跑出过一次真实模型回答)
9
+ //
10
+ // 做法:把 MAGIC_AGENT_MODELS 指到一个不存在的临时路径 → 直连链路整体不生效,
11
+ // 所有 CLI 路径的单测回到"完全可控"的假 CLI 语义。
12
+ // 需要测直连链路的用例(llmhttp_test.go)自己用 t.Setenv 覆盖这个变量。
13
+
14
+ import (
15
+ "os"
16
+ "path/filepath"
17
+ "testing"
18
+ )
19
+
20
+ func TestMain(m *testing.M) {
21
+ os.Setenv("MAGIC_AGENT_MODELS", filepath.Join(os.TempDir(), "magic-agent-test-no-models.json"))
22
+ os.Exit(m.Run())
23
+ }