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.
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/cmd/magic-agent/main.go +12 -0
- package/cmd/magic-client/main.go +144 -0
- package/go.mod +11 -0
- package/go.sum +12 -0
- package/internal/agent/arkclaw.go +587 -0
- package/internal/agent/arkclaw_stream.go +312 -0
- package/internal/agent/arkclaw_stream_test.go +428 -0
- package/internal/agent/arkclaw_test.go +827 -0
- package/internal/agent/ask.go +694 -0
- package/internal/agent/ask_test.go +587 -0
- package/internal/agent/attachments.go +200 -0
- package/internal/agent/attachments_test.go +481 -0
- package/internal/agent/capability.go +154 -0
- package/internal/agent/capability_test.go +190 -0
- package/internal/agent/claude.go +406 -0
- package/internal/agent/codebuddy.go +1044 -0
- package/internal/agent/codebuddy_account_test.go +128 -0
- package/internal/agent/codebuddy_gateway.go +479 -0
- package/internal/agent/codebuddy_gateway_stream.go +358 -0
- package/internal/agent/codebuddy_gateway_test.go +673 -0
- package/internal/agent/codex.go +599 -0
- package/internal/agent/codex_home.go +206 -0
- package/internal/agent/codex_home_test.go +130 -0
- package/internal/agent/codex_stream_test.go +97 -0
- package/internal/agent/codex_test.go +280 -0
- package/internal/agent/custom_models.go +197 -0
- package/internal/agent/custom_models_test.go +283 -0
- package/internal/agent/dsh.go +754 -0
- package/internal/agent/dsh_sdk.go +679 -0
- package/internal/agent/dsh_sdk_test.go +726 -0
- package/internal/agent/dsh_test.go +759 -0
- package/internal/agent/engine.go +469 -0
- package/internal/agent/engine_base.go +497 -0
- package/internal/agent/engine_base_test.go +308 -0
- package/internal/agent/engine_test.go +858 -0
- package/internal/agent/env.go +88 -0
- package/internal/agent/env_test.go +106 -0
- package/internal/agent/jsonextract.go +128 -0
- package/internal/agent/jsonextract_test.go +110 -0
- package/internal/agent/keepalive_test.go +216 -0
- package/internal/agent/llmengine.go +470 -0
- package/internal/agent/llmengine_test.go +551 -0
- package/internal/agent/llmhttp.go +437 -0
- package/internal/agent/llmhttp_test.go +304 -0
- package/internal/agent/llmmodels.go +205 -0
- package/internal/agent/login.go +27 -0
- package/internal/agent/main_test.go +23 -0
- package/internal/agent/models.go +686 -0
- package/internal/agent/models_test.go +877 -0
- package/internal/agent/openclaw.go +543 -0
- package/internal/agent/openclaw_acp.go +949 -0
- package/internal/agent/openclaw_acp_test.go +406 -0
- package/internal/agent/openclaw_test.go +451 -0
- package/internal/agent/output.go +191 -0
- package/internal/agent/permission.go +362 -0
- package/internal/agent/permission_test.go +526 -0
- package/internal/agent/prompt.go +65 -0
- package/internal/agent/runcmd.go +259 -0
- package/internal/agent/runcmd_test.go +81 -0
- package/internal/agent/runcmd_unix.go +56 -0
- package/internal/agent/runcmd_windows.go +66 -0
- package/internal/agent/runner.go +180 -0
- package/internal/agent/runner_test.go +263 -0
- package/internal/agent/spawnhook_test.go +96 -0
- package/internal/agent/stream.go +882 -0
- package/internal/agent/stream_test.go +565 -0
- package/internal/agent/streamjson.go +185 -0
- package/internal/agent/tags.go +52 -0
- package/internal/agent/toolkind.go +96 -0
- package/internal/agent/toolkind_test.go +70 -0
- package/internal/agent/tools_suffix_test.go +261 -0
- package/internal/agent/trae.go +342 -0
- package/internal/agent/workspace_test.go +166 -0
- package/internal/boundary/boundary_test.go +165 -0
- package/internal/cli/ask.go +1113 -0
- package/internal/cli/ask_test.go +1088 -0
- package/internal/cli/attach_test.go +239 -0
- package/internal/cli/contract_test.go +157 -0
- package/internal/cli/control.go +149 -0
- package/internal/cli/control_test.go +192 -0
- package/internal/cli/engines_test.go +205 -0
- package/internal/cli/events.go +198 -0
- package/internal/cli/events_test.go +166 -0
- package/internal/cli/keepalive_test.go +391 -0
- package/internal/cli/login.go +78 -0
- package/internal/cli/permission_test.go +295 -0
- package/internal/cli/prompt_source_test.go +222 -0
- package/internal/cli/root.go +752 -0
- package/internal/cli/session.go +486 -0
- package/internal/cli/session_test.go +202 -0
- package/internal/cli/sessionlog.go +325 -0
- package/internal/cli/sessionlog_command_test.go +204 -0
- package/internal/cli/sessionlog_test.go +262 -0
- package/internal/cli/sessionlog_user_test.go +129 -0
- package/internal/cli/toolkind_wire_test.go +84 -0
- package/internal/cli/workspace_test.go +188 -0
- package/internal/client/cors_test.go +136 -0
- package/internal/client/lan.go +68 -0
- package/internal/client/lan_test.go +53 -0
- package/internal/client/pair.go +163 -0
- package/internal/client/pair_test.go +109 -0
- package/internal/client/profile.go +184 -0
- package/internal/client/service.go +888 -0
- package/internal/client/service_test.go +654 -0
- package/internal/client/session_events_test.go +186 -0
- package/internal/client/session_messages_test.go +150 -0
- package/internal/config/config.go +439 -0
- package/internal/config/config_test.go +448 -0
- package/internal/session/session.go +447 -0
- package/internal/session/session_append.go +241 -0
- package/internal/session/session_append_test.go +161 -0
- package/internal/session/session_kill_unix_test.go +90 -0
- package/internal/session/session_mark_test.go +95 -0
- package/internal/session/session_test.go +237 -0
- package/internal/session/sessionlog.go +128 -0
- package/internal/session/sessionlog_read_test.go +157 -0
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/install.js +121 -0
- package/npm/lib/platform.js +78 -0
- package/package.json +36 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// codex_test.go - Codex 引擎单元测试。
|
|
4
|
+
//
|
|
5
|
+
// 覆盖:注册表、buildArgs 关键 flag 映射、NDJSON envelope 解析、
|
|
6
|
+
// Continue 走 resume --last、max-tokens/temperature 静默忽略、CLI 缺失报错。
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"context"
|
|
10
|
+
"os"
|
|
11
|
+
"strings"
|
|
12
|
+
"testing"
|
|
13
|
+
"time"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
func TestCodexLookup(t *testing.T) {
|
|
17
|
+
e := Lookup("codex")
|
|
18
|
+
if e == nil {
|
|
19
|
+
t.Fatal("Lookup(\"codex\") = nil")
|
|
20
|
+
}
|
|
21
|
+
if e.Name() != "codex" {
|
|
22
|
+
t.Errorf("Name() = %q want codex", e.Name())
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func TestCodexBuildArgsNewSession(t *testing.T) {
|
|
27
|
+
dir := t.TempDir()
|
|
28
|
+
w, log := argsCaptureCLI(t, dir, "codex", codexStubWithText("hi from codex"))
|
|
29
|
+
e := &CodexEngine{BinPath: w.bin}
|
|
30
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
31
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
32
|
+
}); err != nil {
|
|
33
|
+
t.Fatalf("Complete: %v", err)
|
|
34
|
+
}
|
|
35
|
+
data, _ := readFile(log)
|
|
36
|
+
args := string(data)
|
|
37
|
+
for _, want := range []string{
|
|
38
|
+
"exec",
|
|
39
|
+
"--skip-git-repo-check",
|
|
40
|
+
"--json",
|
|
41
|
+
// 不带 --ephemeral:codex 0.133 在该模式下不落盘,续接会报 "no rollout found"
|
|
42
|
+
"-s read-only", // off 模式默认 read-only 沙箱
|
|
43
|
+
} {
|
|
44
|
+
if !strings.Contains(args, want) {
|
|
45
|
+
t.Errorf("want %q in args, got %q", want, args)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func TestCodexBuildArgsModel(t *testing.T) {
|
|
51
|
+
dir := t.TempDir()
|
|
52
|
+
w, log := argsCaptureCLI(t, dir, "codex", codexStubWithText("ok"))
|
|
53
|
+
e := &CodexEngine{BinPath: w.bin}
|
|
54
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
55
|
+
Model: "codex/MiniMax-M3",
|
|
56
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
57
|
+
}); err != nil {
|
|
58
|
+
t.Fatalf("Complete: %v", err)
|
|
59
|
+
}
|
|
60
|
+
data, _ := readFile(log)
|
|
61
|
+
if !strings.Contains(string(data), "-m MiniMax-M3") {
|
|
62
|
+
t.Errorf("model flag missing: %q", string(data))
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func TestCodexSystemPrompt(t *testing.T) {
|
|
67
|
+
dir := t.TempDir()
|
|
68
|
+
w, log := argsCaptureCLI(t, dir, "codex", codexStubWithText("ok"))
|
|
69
|
+
e := &CodexEngine{BinPath: w.bin}
|
|
70
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
71
|
+
SystemPrompt: "你是助手",
|
|
72
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
73
|
+
}); err != nil {
|
|
74
|
+
t.Fatalf("Complete: %v", err)
|
|
75
|
+
}
|
|
76
|
+
data, _ := readFile(log)
|
|
77
|
+
args := string(data)
|
|
78
|
+
// -c developer_instructions="<TOML 字符串>":json.Marshal 输出应包含转义。
|
|
79
|
+
if !strings.Contains(args, "-c developer_instructions=") {
|
|
80
|
+
t.Errorf("-c developer_instructions missing: %q", args)
|
|
81
|
+
}
|
|
82
|
+
if !strings.Contains(args, "你是助手") {
|
|
83
|
+
t.Errorf("system prompt content missing: %q", args)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func TestCodexResumeSession(t *testing.T) {
|
|
88
|
+
dir := t.TempDir()
|
|
89
|
+
w, log := argsCaptureCLI(t, dir, "codex", codexStubWithText("续上"))
|
|
90
|
+
e := &CodexEngine{BinPath: w.bin}
|
|
91
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
92
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
93
|
+
SessionID: "thr-1",
|
|
94
|
+
}); err != nil {
|
|
95
|
+
t.Fatalf("Complete: %v", err)
|
|
96
|
+
}
|
|
97
|
+
data, _ := readFile(log)
|
|
98
|
+
args := string(data)
|
|
99
|
+
if !strings.Contains(args, "exec resume thr-1") {
|
|
100
|
+
t.Errorf("exec resume thr-1 not passed: %q", args)
|
|
101
|
+
}
|
|
102
|
+
// 续接时不应重新开新会话(--ephemeral 一律不带;见 buildArgs 注释)
|
|
103
|
+
// 续接时也不带沙箱 flag(codex 0.133 `exec resume` 不接受 -s)
|
|
104
|
+
if strings.Contains(args, "-s read-only") || strings.Contains(args, "--dangerously-bypass-approvals-and-sandbox") {
|
|
105
|
+
t.Errorf("resume 不应带 sandbox flag: %q", args)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func TestCodexContinueUsesLast(t *testing.T) {
|
|
110
|
+
dir := t.TempDir()
|
|
111
|
+
w, log := argsCaptureCLI(t, dir, "codex", codexStubWithText("ok"))
|
|
112
|
+
e := &CodexEngine{BinPath: w.bin}
|
|
113
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
114
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
115
|
+
Continue: true,
|
|
116
|
+
}); err != nil {
|
|
117
|
+
t.Fatalf("Complete: %v", err)
|
|
118
|
+
}
|
|
119
|
+
data, _ := readFile(log)
|
|
120
|
+
args := string(data)
|
|
121
|
+
if !strings.Contains(args, "exec resume --last") {
|
|
122
|
+
t.Errorf("exec resume --last not passed: %q", args)
|
|
123
|
+
}
|
|
124
|
+
// 同 TestCodexResumeSession:resume --last 也不带 sandbox flag
|
|
125
|
+
if strings.Contains(args, "-s ") || strings.Contains(args, "--dangerously-bypass-approvals-and-sandbox") {
|
|
126
|
+
t.Errorf("resume --last 不应带 sandbox flag: %q", args)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func TestCodexMaxTokensIgnored(t *testing.T) {
|
|
131
|
+
dir := t.TempDir()
|
|
132
|
+
w, log := argsCaptureCLI(t, dir, "codex", codexStubWithText("ok"))
|
|
133
|
+
e := &CodexEngine{BinPath: w.bin}
|
|
134
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
135
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
136
|
+
MaxTokens: 4096,
|
|
137
|
+
}); err != nil {
|
|
138
|
+
t.Fatalf("Complete: %v", err)
|
|
139
|
+
}
|
|
140
|
+
data, _ := readFile(log)
|
|
141
|
+
if strings.Contains(string(data), "max_tokens") || strings.Contains(string(data), "model_max_output_tokens") {
|
|
142
|
+
t.Errorf("MaxTokens 应静默忽略: %q", string(data))
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func TestCodexTemperatureIgnored(t *testing.T) {
|
|
147
|
+
dir := t.TempDir()
|
|
148
|
+
w, log := argsCaptureCLI(t, dir, "codex", codexStubWithText("ok"))
|
|
149
|
+
e := &CodexEngine{BinPath: w.bin}
|
|
150
|
+
temp := 0.5
|
|
151
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
152
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
153
|
+
Temperature: &temp,
|
|
154
|
+
}); err != nil {
|
|
155
|
+
t.Fatalf("Complete: %v", err)
|
|
156
|
+
}
|
|
157
|
+
data, _ := readFile(log)
|
|
158
|
+
if strings.Contains(strings.ToLower(string(data)), "temperature") {
|
|
159
|
+
t.Errorf("temperature 应静默忽略: %q", string(data))
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// codexStubWithText 构造一个"会落 -o 文件 + 输出 agent_message 事件"的 stub,
|
|
164
|
+
// 让 codex.Complete 完整跑通(从 NDJSON 事件流或落盘文件取到 Text)。
|
|
165
|
+
func codexStubWithText(text string) string {
|
|
166
|
+
// 把 text 中的双引号/反斜杠做转义,避免破坏 shell 字符串
|
|
167
|
+
esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`, "\n", `\n`, "$", `\$`).Replace(text)
|
|
168
|
+
return `#!/bin/sh
|
|
169
|
+
# 1) 落 -o 文件
|
|
170
|
+
prev=""
|
|
171
|
+
for arg in "$@"; do
|
|
172
|
+
if [ "$prev" = "-o" ]; then
|
|
173
|
+
printf '` + esc + `\n' > "$arg"
|
|
174
|
+
fi
|
|
175
|
+
prev="$arg"
|
|
176
|
+
done
|
|
177
|
+
# 2) 输出 NDJSON 事件流(item.completed.agent_message 让正文直接被解析到)
|
|
178
|
+
echo '{"type":"thread.started","thread_id":"stub-1"}'
|
|
179
|
+
echo '{"type":"item.completed","item":{"type":"agent_message","text":"` + esc + `"}}'
|
|
180
|
+
echo '{"type":"turn.completed","usage":{"input_tokens":1,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0}}'
|
|
181
|
+
`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
func TestCodexParseStdoutExtractsAll(t *testing.T) {
|
|
185
|
+
// codex --json 典型事件流
|
|
186
|
+
stdout := strings.Join([]string{
|
|
187
|
+
`{"type":"thread.started","thread_id":"thr-xyz"}`,
|
|
188
|
+
`{"type":"turn.started"}`,
|
|
189
|
+
`{"type":"item.completed","item":{"type":"agent_message","text":"这是答案"}}`,
|
|
190
|
+
`{"type":"turn.completed","usage":{"input_tokens":1200,"cached_input_tokens":300,"output_tokens":50,"reasoning_output_tokens":20}}`,
|
|
191
|
+
}, "\n")
|
|
192
|
+
e := &CodexEngine{}
|
|
193
|
+
resp := e.parseStdout(stdout)
|
|
194
|
+
if resp.Text != "这是答案" {
|
|
195
|
+
t.Errorf("Text = %q", resp.Text)
|
|
196
|
+
}
|
|
197
|
+
if resp.SessionID != "thr-xyz" {
|
|
198
|
+
t.Errorf("SessionID = %q", resp.SessionID)
|
|
199
|
+
}
|
|
200
|
+
// input(1200) + cached(300) = 1500; output(50) + reasoning(20) = 70
|
|
201
|
+
if resp.InputTokens != 1500 {
|
|
202
|
+
t.Errorf("InputTokens = %d want 1500", resp.InputTokens)
|
|
203
|
+
}
|
|
204
|
+
if resp.OutputTokens != 70 {
|
|
205
|
+
t.Errorf("OutputTokens = %d want 70", resp.OutputTokens)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
func TestCodexCompleteWithOutputFile(t *testing.T) {
|
|
210
|
+
// 走兜底:stdout 没 agent_message 事件但 -o 落盘文件有内容
|
|
211
|
+
cli := writeFakeCLI(t, "codex", `#!/bin/sh
|
|
212
|
+
# 解析 -o 参数,落盘文件;同时 echo thread.started
|
|
213
|
+
prev=""
|
|
214
|
+
for arg in "$@"; do
|
|
215
|
+
if [ "$prev" = "-o" ]; then
|
|
216
|
+
printf '兜底正文\n' > "$arg"
|
|
217
|
+
fi
|
|
218
|
+
prev="$arg"
|
|
219
|
+
done
|
|
220
|
+
echo '{"type":"thread.started","thread_id":"thr-9"}'
|
|
221
|
+
`)
|
|
222
|
+
e := &CodexEngine{BinPath: cli}
|
|
223
|
+
resp, err := e.Complete(context.Background(), Request{
|
|
224
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
225
|
+
})
|
|
226
|
+
if err != nil {
|
|
227
|
+
t.Fatalf("Complete: %v", err)
|
|
228
|
+
}
|
|
229
|
+
if !strings.Contains(resp.Text, "兜底正文") {
|
|
230
|
+
t.Errorf("Text 应从 -o 文件兜底读出: %q", resp.Text)
|
|
231
|
+
}
|
|
232
|
+
if resp.SessionID != "thr-9" {
|
|
233
|
+
t.Errorf("SessionID = %q", resp.SessionID)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func TestCodexNotFound(t *testing.T) {
|
|
238
|
+
e := &CodexEngine{BinPath: "/nonexistent/codex"}
|
|
239
|
+
_, err := e.Complete(context.Background(), Request{
|
|
240
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
241
|
+
})
|
|
242
|
+
if err == nil {
|
|
243
|
+
t.Fatal("expected error for missing CLI")
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
func TestCodexTimeoutPassesProcessDeadline(t *testing.T) {
|
|
248
|
+
dir := t.TempDir()
|
|
249
|
+
w, log := argsCaptureCLI(t, dir, "codex", codexStubWithText("ok"))
|
|
250
|
+
e := &CodexEngine{BinPath: w.bin}
|
|
251
|
+
if _, err := e.Complete(context.Background(), Request{
|
|
252
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
253
|
+
Timeout: 2 * time.Minute,
|
|
254
|
+
}); err != nil {
|
|
255
|
+
t.Fatalf("Complete: %v", err)
|
|
256
|
+
}
|
|
257
|
+
// codex 不在 args 层暴露 timeout(由 ctx deadline 接管);只验证进程能跑通。
|
|
258
|
+
_ = log
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
func TestCodexToMLBasicString(t *testing.T) {
|
|
262
|
+
cases := []struct {
|
|
263
|
+
in, want string
|
|
264
|
+
}{
|
|
265
|
+
{"hello", `"hello"`},
|
|
266
|
+
{`含"引号`, `"含\"引号"`},
|
|
267
|
+
{"换\n行", `"换\n行"`},
|
|
268
|
+
{"反\\斜杠", `"反\\斜杠"`},
|
|
269
|
+
}
|
|
270
|
+
for _, tc := range cases {
|
|
271
|
+
if got := tomlBasicString(tc.in); got != tc.want {
|
|
272
|
+
t.Errorf("tomlBasicString(%q) = %q, want %q", tc.in, got, tc.want)
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// readFile 小工具,直接复用 os.ReadFile。
|
|
278
|
+
func readFile(p string) ([]byte, error) {
|
|
279
|
+
return os.ReadFile(p)
|
|
280
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// custom_models.go - 把桌面 App 里配好的**自定义模型端点**同步给独立 CLI。
|
|
4
|
+
//
|
|
5
|
+
// 背景(2026-09-28):codebuddy 系引擎改用**独立安装**的 CLI 后,用户在 App 里配好的
|
|
6
|
+
// 自定义模型(`custom-local:*`)在 `-m` 里一律失败:
|
|
7
|
+
//
|
|
8
|
+
// Custom model <id> has no endpoint url configured. Set the "url" field for
|
|
9
|
+
// this model in your model settings (models.json) and try again.
|
|
10
|
+
//
|
|
11
|
+
// 根因:CLI 的自定义模型端点只从 `<配置目录>/models.json` 读,而桌面 App 是在
|
|
12
|
+
// **启动 CLI 时把端点(url / apiKey)注入子进程**的 —— 独立进程没有这份注入。
|
|
13
|
+
// 我们在 App 的合并配置缓存里能读到这些端点(与模型清单同源),补写一次即可。
|
|
14
|
+
//
|
|
15
|
+
// 为什么不选"把 custom-local 从清单里过滤掉":那是把可用模型砍掉;补端点是让它们
|
|
16
|
+
// 真正可用。清单与端点同源(都取该引擎的 acc 缓存),不会出现"列了却没端点"。
|
|
17
|
+
//
|
|
18
|
+
// 归谁所有:models.json 是 **CLI 自己的**配置文件(CLI 的 models 控制器会读写它)。
|
|
19
|
+
// 所以这里只做**合并**:只补缺失的 id,不删、不改、不动其它字段;无新增时不落盘
|
|
20
|
+
//(避免无谓写盘与 mtime 抖动)。App 缓存一律只读。
|
|
21
|
+
|
|
22
|
+
import (
|
|
23
|
+
"encoding/json"
|
|
24
|
+
"errors"
|
|
25
|
+
"io/fs"
|
|
26
|
+
"os"
|
|
27
|
+
"path/filepath"
|
|
28
|
+
"sort"
|
|
29
|
+
"strings"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// customModelsFileName CLI 读自定义模型端点的文件名(见 CLI 的 getModelsPath)。
|
|
33
|
+
const customModelsFileName = "models.json"
|
|
34
|
+
|
|
35
|
+
// customModelIDPrefix 自定义模型 id 前缀(App 与 CLI 共用这个约定)。
|
|
36
|
+
const customModelIDPrefix = "custom-local:"
|
|
37
|
+
|
|
38
|
+
// syncCustomModelsFromApp 把 App 合并配置缓存(acc-product-config)里的
|
|
39
|
+
// `custom-local:*` 模型合并进 `<configDir>/models.json`,返回新增条数。
|
|
40
|
+
//
|
|
41
|
+
// accConfigPath 为空 / 文件不存在 / 里面没有自定义模型 → 返回 (0, nil):这是常态,
|
|
42
|
+
// 不是错误(没装桌面 App、或用户没配自定义模型)。
|
|
43
|
+
//
|
|
44
|
+
// 失败语义:读 App 缓存失败视为"没得同步"(返回 0, nil,不阻断模型探测);
|
|
45
|
+
// 只有**写** models.json 失败才返回错误 —— 调用方按 best-effort 处理即可。
|
|
46
|
+
func syncCustomModelsFromApp(accConfigPath, configDir string) (int, error) {
|
|
47
|
+
if strings.TrimSpace(accConfigPath) == "" || strings.TrimSpace(configDir) == "" {
|
|
48
|
+
return 0, nil
|
|
49
|
+
}
|
|
50
|
+
models := readAppCustomModels(accConfigPath)
|
|
51
|
+
if len(models) == 0 {
|
|
52
|
+
return 0, nil
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
path := filepath.Join(configDir, customModelsFileName)
|
|
56
|
+
root := map[string]any{}
|
|
57
|
+
switch b, err := os.ReadFile(path); {
|
|
58
|
+
case err == nil:
|
|
59
|
+
// 解析失败就当空文件:CLI 自己也会这么兜(readModelsJson 失败返回 {models:[]})。
|
|
60
|
+
// 但**不覆盖**未识别的字段 —— 解析失败时下面只会写回 models/availableModels。
|
|
61
|
+
_ = json.Unmarshal(b, &root)
|
|
62
|
+
if root == nil {
|
|
63
|
+
root = map[string]any{}
|
|
64
|
+
}
|
|
65
|
+
case errors.Is(err, fs.ErrNotExist):
|
|
66
|
+
// 首次同步:新建
|
|
67
|
+
default:
|
|
68
|
+
return 0, err
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
existing := map[string]bool{}
|
|
72
|
+
raw, _ := root["models"].([]any)
|
|
73
|
+
for _, it := range raw {
|
|
74
|
+
if m, ok := it.(map[string]any); ok {
|
|
75
|
+
if id, _ := m["id"].(string); id != "" {
|
|
76
|
+
existing[id] = true
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
ids := make([]string, 0, len(models))
|
|
82
|
+
for id := range models {
|
|
83
|
+
ids = append(ids, id)
|
|
84
|
+
}
|
|
85
|
+
sort.Strings(ids) // 固定顺序,便于 diff
|
|
86
|
+
|
|
87
|
+
added := 0
|
|
88
|
+
for _, id := range ids {
|
|
89
|
+
if existing[id] {
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
raw = append(raw, models[id])
|
|
93
|
+
existing[id] = true
|
|
94
|
+
added++
|
|
95
|
+
}
|
|
96
|
+
if added == 0 {
|
|
97
|
+
return 0, nil // 已在位:不落盘
|
|
98
|
+
}
|
|
99
|
+
root["models"] = raw
|
|
100
|
+
|
|
101
|
+
// availableModels 与 CLI 自己的 saveCustomModel 行为对齐(它每次都会维护这一项)。
|
|
102
|
+
// 实测(2026-09-28):只写 custom-local id 不会挤掉官方模型的 -m 可用性。
|
|
103
|
+
avail := make([]string, 0, len(raw))
|
|
104
|
+
seen := map[string]bool{}
|
|
105
|
+
if old, ok := root["availableModels"].([]any); ok {
|
|
106
|
+
for _, v := range old {
|
|
107
|
+
if s, ok := v.(string); ok && s != "" && !seen[s] {
|
|
108
|
+
seen[s] = true
|
|
109
|
+
avail = append(avail, s)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for _, id := range ids {
|
|
114
|
+
if !seen[id] {
|
|
115
|
+
seen[id] = true
|
|
116
|
+
avail = append(avail, id)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
root["availableModels"] = avail
|
|
120
|
+
|
|
121
|
+
b, err := json.MarshalIndent(root, "", " ")
|
|
122
|
+
if err != nil {
|
|
123
|
+
return 0, err
|
|
124
|
+
}
|
|
125
|
+
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
|
126
|
+
return 0, err
|
|
127
|
+
}
|
|
128
|
+
// 含第三方 apiKey:与 CLI 自身凭据同级(仅属主可读)。
|
|
129
|
+
if err := os.WriteFile(path, b, 0o600); err != nil {
|
|
130
|
+
return 0, err
|
|
131
|
+
}
|
|
132
|
+
return added, nil
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// readAppCustomModels 从 App 的合并配置缓存里收集自定义模型条目(id → 原样条目)。
|
|
136
|
+
//
|
|
137
|
+
// 原样保留整个对象:CLI 的 models.json 条目就是它自己那套字段
|
|
138
|
+
// (id / name / url / apiKey / supportsImages / supportsReasoning / supportsToolCall /
|
|
139
|
+
// reasoning / maxInputTokens / maxOutputTokens / useCustomProtocol / vendor / tags),
|
|
140
|
+
// 逐字段挑写容易漏(实测同一条目在不同缓存里字段数不同)。
|
|
141
|
+
//
|
|
142
|
+
// 跳过 disabled 条目;解析失败返回 nil(视作没有)。
|
|
143
|
+
func readAppCustomModels(accConfigPath string) map[string]map[string]any {
|
|
144
|
+
b, err := os.ReadFile(accConfigPath)
|
|
145
|
+
if err != nil {
|
|
146
|
+
return nil
|
|
147
|
+
}
|
|
148
|
+
var doc any
|
|
149
|
+
if err := json.Unmarshal(b, &doc); err != nil {
|
|
150
|
+
return nil
|
|
151
|
+
}
|
|
152
|
+
out := map[string]map[string]any{}
|
|
153
|
+
collectCustomModels(doc, out)
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// collectCustomModels 深度遍历合并配置缓存,挑出 custom-local:* 条目(先到先得)。
|
|
158
|
+
func collectCustomModels(node any, out map[string]map[string]any) {
|
|
159
|
+
switch v := node.(type) {
|
|
160
|
+
case map[string]any:
|
|
161
|
+
if id, ok := v["id"].(string); ok && strings.HasPrefix(id, customModelIDPrefix) {
|
|
162
|
+
if disabled, _ := v["disabled"].(bool); !disabled {
|
|
163
|
+
if _, dup := out[id]; !dup {
|
|
164
|
+
out[id] = v
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for _, child := range v {
|
|
169
|
+
collectCustomModels(child, out)
|
|
170
|
+
}
|
|
171
|
+
case []any:
|
|
172
|
+
for _, child := range v {
|
|
173
|
+
collectCustomModels(child, out)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ensureCustomModelEndpoint 仅当本次请求用的是一个自定义模型时,才补一次端点。
|
|
179
|
+
//
|
|
180
|
+
// 为什么不止在 listModels 里同步:用户可能在 App 里刚加了一个自定义模型,就直接
|
|
181
|
+
// `-m custom-local:<新>` 调用(没打开过模型选择器)。这条兜底让那种路径也能用上。
|
|
182
|
+
// best-effort:不同步失败也不影响正常模型(官方模型根本不读 models.json)。
|
|
183
|
+
func (c codebuddyCore) ensureCustomModelEndpoint(model string) {
|
|
184
|
+
if !strings.HasPrefix(model, customModelIDPrefix) {
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
_, _ = syncCustomModelsFromApp(c.accConfigPath, c.effectiveConfigDir())
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// effectiveConfigDir 本次子进程实际使用的配置目录:用户显式设了 CODEBUDDY_CONFIG_DIR
|
|
191
|
+
// 就跟着走(CLI 也这么读,见 codebuddyAccountEnv),否则用引擎自带的那个。
|
|
192
|
+
func (c codebuddyCore) effectiveConfigDir() string {
|
|
193
|
+
if v := strings.TrimSpace(os.Getenv("CODEBUDDY_CONFIG_DIR")); v != "" {
|
|
194
|
+
return v
|
|
195
|
+
}
|
|
196
|
+
return c.configDir
|
|
197
|
+
}
|