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,686 @@
1
+ package agent
2
+
3
+ // models.go - 引擎「支持的模型清单」的动态探测(--engines 使用)。
4
+ //
5
+ // 核心约束:模型清单**一律不硬编码**。每个引擎只用它自己的权威入口
6
+ // 现取现算,CLI 升级 / 用户注册新模型后 --engines 自动跟随:
7
+ //
8
+ // 引擎 动态来源
9
+ // claude ~/.claude/settings.json(顶层 model + env 里 ANTHROPIC_*MODEL /
10
+ // CLAUDE_CODE_*MODEL 这些 **id 格**)①
11
+ // codebuddy 扩展来源链(两端同链 ③):客户端合并配置缓存 acc-product-config
12
+ // → 远程配置缓存 ∪ App 包 product.json → `codebuddy --help` 的
13
+ // "Currently supported: (...)" 清单(末级兜底)
14
+ // trae `trae-cli models --json`
15
+ // llm `llm models`(用户经 llm CLI 自注册的模型)
16
+ // codex `codex debug models`(raw model catalog)
17
+ // openclaw `openclaw models list --json`
18
+ // arkclaw 无 —— 模型由网关按 claw_id 绑定,返回 ErrNoModelSource ②
19
+ // dsh `$DSH_HOME/settings.yaml` 里已配置的模型(llm-pi-ai.providers.<route>.models,
20
+ // 无则回落 agent-default-model);输出 `route/model`,SDK 通道下就是 -m 的可取值
21
+ //
22
+ // ① claude 没有 models 子命令(--help 只有 agents/auth/doctor/mcp/plugin/
23
+ // project/... ),可用的模型标识只能从用户配置里读:settings.json 的
24
+ // env 键是 claude 自己的契约 —— 每个档位一组四个变量
25
+ // (ANTHROPIC_DEFAULT_{SONNET,OPUS,HAIKU,FABLE...}_MODEL = 模型 id,
26
+ // _MODEL_NAME = 显示名,另有 _MODEL_DESCRIPTION / _MODEL_SUPPORTED_CAPABILITIES),
27
+ // 同形态还有 ANTHROPIC_MODEL / ANTHROPIC_SMALL_FAST_MODEL /
28
+ // CLAUDE_CODE_SUBAGENT_MODEL。这里按「命名空间 ANTHROPIC_ / CLAUDE_CODE_ +
29
+ // 后缀 MODEL」过滤键名(**只取 id 格**,显示名不算),不写死具体模型名。
30
+ // ② 探测失败/无来源都不算致命:CLI 层把原因写进 models_note,models 字段
31
+ // 留空(见 cli/root.go runEngines)。
32
+ // ③ codebuddy 两端的清单链(2026-09-21 定稿,2026-09-28 两端对齐):客户端合并
33
+ // 配置缓存 <appHome>/cache/acc-product-config-v*.json(客户端模型选择器同源;
34
+ // AI 端 27 条 = 23 预制 + 4 custom-local,WorkBuddy 端 61 条 = 53 预制 +
35
+ // 8 custom-local)
36
+ // → 客户端未运行过时回退「远程配置缓存 ∪ App 包 product.json」超集近似
37
+ // → 回退 --help。**两端都走这条链**:WorkBuddy 端的 --help 只有 17 条
38
+ // (9 预制 + 8 custom-local),官方主力模型 hy3 / hy4-preview /
39
+ // deepseek-v4.1-flash / glm-5.3 全不在内,只按 --help 出清单会大面积缺失
40
+ // (且与同引擎的 model_credits 读自不同文件,倍率查得到、清单查不到)。
41
+ //
42
+ // 各引擎的 ListModels 都是**只读**子命令(不发起推理、不消耗额度),
43
+ // 但会真的启动 CLI 进程(openclaw ~2.5s 最慢),因此探测并发执行、
44
+ // 单引擎超时 DefaultModelProbeTimeout(见 cli/root.go)。
45
+
46
+ import (
47
+ "context"
48
+ "encoding/json"
49
+ "errors"
50
+ "fmt"
51
+ "os"
52
+ "path/filepath"
53
+ "sort"
54
+ "strconv"
55
+ "strings"
56
+ "time"
57
+ )
58
+
59
+ // DefaultModelProbeTimeout 单个引擎模型探测的超时(CLI 启动 + 输出)。
60
+ // 实测最慢的是 openclaw models list --json(~2.5s),30s 足够宽裕。
61
+ const DefaultModelProbeTimeout = 30 * time.Second
62
+
63
+ // ErrNoModelSource 引擎没有「动态模型清单」来源(而非探测失败)。
64
+ // ListModels 返回的错误里用 %w 包住它,调用方可据此区分语义。
65
+ var ErrNoModelSource = errors.New("no dynamic model source")
66
+
67
+ // ModelLister 可选接口:引擎能动态列出当前可用模型标识。
68
+ //
69
+ // 只有「自家 CLI/配置里存在权威清单」的引擎才实现它;未实现的引擎
70
+ // (目前无)在 --engines 里只有 models_note 说明。返回值即 -m 可用的
71
+ // 模型标识(顺序 = CLI 自己的展示顺序,已去重去空)。
72
+ type ModelLister interface {
73
+ ListModels(ctx context.Context) ([]string, error)
74
+ }
75
+
76
+ // ModelListerOf 取引擎的模型探测能力;未实现返回 nil。
77
+ func ModelListerOf(e Engine) ModelLister {
78
+ l, _ := e.(ModelLister)
79
+ return l
80
+ }
81
+
82
+ // ModelCreditLister 可选接口:引擎能给出各模型的**积分倍率**(与客户端计费口径
83
+ // 一致)。返回 map[模型id]倍率,倍率是规范化后的数字字符串(客户端的
84
+ // "x0.34 credits" / "x0.77" → "0.34" / "0.77");客户端不给倍率的模型
85
+ // (如 custom-local)不出现在 map 里。探测失败 / 无来源返回 nil。
86
+ type ModelCreditLister interface {
87
+ ModelCredits(ctx context.Context) map[string]string
88
+ }
89
+
90
+ // ModelCreditListOf 返回引擎的 ModelCreditLister 实现(未实现返回 nil)。
91
+ func ModelCreditListOf(e Engine) ModelCreditLister {
92
+ l, _ := e.(ModelCreditLister)
93
+ return l
94
+ }
95
+
96
+ // parseCreditMultiplier 把客户端计费字段规范化成纯倍率数字字符串:
97
+ //
98
+ // "x2.20 credits" -> "2.20" "x0.77" -> "0.77" " X1.5 CREDITS " -> "1.5"
99
+ //
100
+ // 其他形态(空 / 缺 x / 非数字)返回 ""(该模型不计入倍率表)。
101
+ func parseCreditMultiplier(raw string) string {
102
+ s := strings.TrimSpace(strings.ToLower(raw))
103
+ s = strings.TrimSuffix(strings.TrimSpace(s), "credits")
104
+ s = strings.TrimSpace(s)
105
+ s = strings.TrimPrefix(s, "x")
106
+ if s == "" {
107
+ return ""
108
+ }
109
+ if _, err := strconv.ParseFloat(s, 64); err != nil {
110
+ return ""
111
+ }
112
+ return s
113
+ }
114
+
115
+ // probeCLI 执行只读探测子命令并返回 stdout(stderr 只在失败时并入错误信息)。
116
+ func probeCLI(ctx context.Context, engine, bin string, args ...string) (string, error) {
117
+ if bin == "" {
118
+ return "", fmt.Errorf("%s CLI not found", engine)
119
+ }
120
+ stdout, stderr, err := runCLI(ctx, bin, args...)
121
+ if err != nil {
122
+ return "", wrapCliError(engine, stdout, stderr, err)
123
+ }
124
+ return stdout, nil
125
+ }
126
+
127
+ // dedupeModels 去空、去首尾空白、按首次出现顺序去重。
128
+ func dedupeModels(in []string) []string {
129
+ seen := make(map[string]bool, len(in))
130
+ out := make([]string, 0, len(in))
131
+ for _, raw := range in {
132
+ v := strings.TrimSpace(raw)
133
+ if v == "" || seen[v] {
134
+ continue
135
+ }
136
+ seen[v] = true
137
+ out = append(out, v)
138
+ }
139
+ return out
140
+ }
141
+
142
+ // ── JSON 类输出的通用解析 ────────────────────────────────────────
143
+
144
+ // jsonModelsArray 从 CLI 的 JSON 输出里取「模型对象数组」。
145
+ // 兼容两种形态:顶层数组(trae)与 {"models":[...]} 包裹(codex / openclaw)。
146
+ // 输出头部的日志噪声由 firstJSONValue 自动跳过。
147
+ func jsonModelsArray(stdout string) ([]map[string]any, error) {
148
+ raw, ok := firstJSONValue(stdout)
149
+ if !ok {
150
+ return nil, errors.New("no JSON object/array in output")
151
+ }
152
+ var arr []map[string]any
153
+ if err := json.Unmarshal([]byte(raw), &arr); err == nil {
154
+ return arr, nil
155
+ }
156
+ var wrapped struct {
157
+ Models []map[string]any `json:"models"`
158
+ }
159
+ if err := json.Unmarshal([]byte(raw), &wrapped); err != nil {
160
+ return nil, fmt.Errorf("unmarshal model list: %w", err)
161
+ }
162
+ return wrapped.Models, nil
163
+ }
164
+
165
+ // modelNamesFromObjects 按候选字段名依次取值(第一个非空命中),保持原顺序。
166
+ func modelNamesFromObjects(objs []map[string]any, fields ...string) []string {
167
+ out := make([]string, 0, len(objs))
168
+ for _, obj := range objs {
169
+ for _, f := range fields {
170
+ if v, ok := obj[f].(string); ok && strings.TrimSpace(v) != "" {
171
+ out = append(out, v)
172
+ break
173
+ }
174
+ }
175
+ }
176
+ return dedupeModels(out)
177
+ }
178
+
179
+ // firstJSONValue 返回 s 中第一个**能被 json.Valid 接受**的对象/数组字面量。
180
+ //
181
+ // 不能用"首个 '[' 或 '{'"直接定起点:CLI 会把日志混进 stdout,比如
182
+ // openclaw 的 "[飞书插件] 已注册…"(首个 '[' 属于日志,不是 JSON)。
183
+ // 因此逐个候选起点做括号配对 + json.Valid 校验,取第一个真正合法的。
184
+ func firstJSONValue(s string) (string, bool) {
185
+ for i := 0; i < len(s); i++ {
186
+ if c := s[i]; c == '{' || c == '[' {
187
+ if raw, ok := scanBalanced(s[i:], c); ok && json.Valid([]byte(raw)) {
188
+ return raw, true
189
+ }
190
+ }
191
+ }
192
+ return "", false
193
+ }
194
+
195
+ // scanBalanced 从 s[0](= open 括号)起做括号配对,返回配平的字面量。
196
+ // 字符串内的括号通过 inStr 状态跳过(含反斜杠转义)。
197
+ func scanBalanced(s string, open byte) (string, bool) {
198
+ closeCh := byte('}')
199
+ if open == '[' {
200
+ closeCh = ']'
201
+ }
202
+ depth, inStr, escape := 0, false, false
203
+ for i := 0; i < len(s); i++ {
204
+ c := s[i]
205
+ if inStr {
206
+ switch c {
207
+ case '\\':
208
+ escape = !escape
209
+ continue
210
+ case '"':
211
+ if !escape {
212
+ inStr = false
213
+ }
214
+ escape = false
215
+ continue
216
+ default:
217
+ escape = false
218
+ continue
219
+ }
220
+ }
221
+ switch c {
222
+ case '"':
223
+ inStr = true
224
+ case open:
225
+ depth++
226
+ case closeCh:
227
+ depth--
228
+ if depth == 0 {
229
+ return s[:i+1], true
230
+ }
231
+ }
232
+ }
233
+ return "", false
234
+ }
235
+
236
+ // ── 各引擎私有格式的解析(纯函数,便于单测)──────────────────────
237
+
238
+ // parseCodebuddyHelpModels 从 `codebuddy --help` 的 --model 描述里抽清单:
239
+ //
240
+ // --model <model> Model for the current session. Please provide the model ID.
241
+ // Currently supported: (auto, hy4-preview, hy3, ...)
242
+ //
243
+ // 清单由 CLI 自己维护(含用户自定义的 custom-local:* 项),随版本自动更新。
244
+ func parseCodebuddyHelpModels(help string) []string {
245
+ const marker = "Currently supported:"
246
+ i := strings.Index(help, marker)
247
+ if i < 0 {
248
+ return nil
249
+ }
250
+ rest := help[i+len(marker):]
251
+ if j := strings.IndexByte(rest, '('); j >= 0 {
252
+ // 括号内的清单;跨行时也截到配对 ')'。
253
+ if k := strings.IndexByte(rest[j:], ')'); k >= 0 {
254
+ rest = rest[j+1 : j+k]
255
+ } else {
256
+ rest = rest[j+1:]
257
+ }
258
+ } else if k := strings.IndexByte(rest, '\n'); k >= 0 {
259
+ rest = rest[:k]
260
+ }
261
+ return dedupeModels(strings.Split(rest, ","))
262
+ }
263
+
264
+ // productJSONPath 从 CLI 二进制位置推导同 App 包内的 product.json:
265
+ //
266
+ // <App>.app/Contents/Resources/app.asar.unpacked/cli/bin/codebuddy
267
+ // -> <App>.app/Contents/Resources/app.asar.unpacked/cli/product.json
268
+ //
269
+ // 即上溯两层(bin/codebuddy → bin → cli)再拼 product.json。
270
+ // 路径不匹配时返回的路径不存在,readProductJSONModels 会静默返回 nil。
271
+ func productJSONPath(bin string) string {
272
+ if bin == "" {
273
+ return ""
274
+ }
275
+ return filepath.Join(filepath.Dir(filepath.Dir(bin)), "product.json")
276
+ }
277
+
278
+ // readProductJSONModels 读取并解析 App 包 product.json 的模型清单。
279
+ // 任何失败(文件不存在 / 权限 / 格式变了)都返回 nil,由调用方回退 --help
280
+ // —— product.json 是可选增强来源,不是硬依赖。
281
+ func readProductJSONModels(path string) []string {
282
+ ids, _ := readProductJSONCatalog(path)
283
+ return ids
284
+ }
285
+
286
+ // readProductJSONCatalog 同上,但同时返回积分倍率表(parseProductJSONCatalog)。
287
+ func readProductJSONCatalog(path string) ([]string, map[string]string) {
288
+ if path == "" {
289
+ return nil, nil
290
+ }
291
+ data, err := os.ReadFile(path)
292
+ if err != nil {
293
+ return nil, nil
294
+ }
295
+ return parseProductJSONCatalog(data)
296
+ }
297
+
298
+ // parseProductJSONCatalog 抽取 product.json 形态配置的模型 id 与积分倍率:
299
+ //
300
+ // {"models":[{"id":"gpt-5.5","name":"GPT-5.5","credits":"x3.31 credits",...},...]}
301
+ //
302
+ // 只取 id 与 credits(其余元数据 --engines 用不上);保留文件内顺序;
303
+ // id 去重;倍率经 parseCreditMultiplier 规范化,缺省/非法的不进倍率表。
304
+ //
305
+ // ⚠️ 这里**不过滤**:product.json 的 credits 覆盖不完整(WorkBuddy 端实测 48 条里
306
+ // 只有 9 条带 credits,hy3 / minimax-m3 / glm-5.1 等主力模型都在另外 39 条里),
307
+ // 按「有倍率」筛会把真模型全砍掉。acc 缓存那份才够格过滤,见 readAccCatalog。
308
+ func parseProductJSONCatalog(data []byte) ([]string, map[string]string) {
309
+ var doc struct {
310
+ Models []struct {
311
+ ID string `json:"id"`
312
+ Credits string `json:"credits"`
313
+ } `json:"models"`
314
+ }
315
+ if err := json.Unmarshal(data, &doc); err != nil {
316
+ return nil, nil
317
+ }
318
+ var ids []string
319
+ credits := map[string]string{}
320
+ for _, m := range doc.Models {
321
+ id := strings.TrimSpace(m.ID)
322
+ if id == "" {
323
+ continue
324
+ }
325
+ ids = append(ids, id)
326
+ if c := parseCreditMultiplier(m.Credits); c != "" {
327
+ credits[id] = c
328
+ }
329
+ }
330
+ ids = dedupeModels(ids)
331
+ if len(ids) == 0 {
332
+ // 空清单按「无来源」处理,调用方据此回退 --help(而非当成零个模型)。
333
+ return nil, nil
334
+ }
335
+ return ids, credits
336
+ }
337
+
338
+ // readAccCatalog 读 acc-product-config 缓存,返回清单与倍率表。
339
+ //
340
+ // acc 缓存是客户端的**全量注册表**(WorkBuddy 端实测 61 条),不是模型选择器 ——
341
+ // 直接铺给 -m 会比客户端多太多。allowlist 是客户端显式下发的选择器清单
342
+ // (agents[].models,见 agentSelectorModels),为 nil 时**不过滤**(宁可多不可少)。
343
+ //
344
+ // 三类条目才进 -m 清单:
345
+ //
346
+ // 1. 在 allowlist 里(客户端展示的具名模型)→ 保留,倍率取注册表;
347
+ // 2. custom-local:*(用户自己加的,客户端同样展示、只是不计倍率)→ 保留;
348
+ // 3. 其余(补全 codewise-* / 图像 hunyuan-image-* / 历史别名 default-1.* 等)→ 剔除。
349
+ //
350
+ // ⚠️ "auto" 特例:客户端选择器首条,但**不在注册表里**(注册表只列具名模型)——
351
+ // CLI 的 --help 认、客户端确实展示,故按 allowlist 命中处理并补进清单首位。
352
+ //
353
+ // 返回空清单 = 当无来源,调用方回退 ②(远程配置缓存 ∪ product.json)。
354
+ func readAccCatalog(path string, allowlist []string) ([]string, map[string]string) {
355
+ if path == "" {
356
+ return nil, nil
357
+ }
358
+ data, err := os.ReadFile(path)
359
+ if err != nil {
360
+ return nil, nil
361
+ }
362
+ var doc struct {
363
+ Models []struct {
364
+ ID string `json:"id"`
365
+ Credits string `json:"credits"`
366
+ } `json:"models"`
367
+ }
368
+ if err := json.Unmarshal(data, &doc); err != nil {
369
+ return nil, nil
370
+ }
371
+ on := map[string]bool{}
372
+ for _, id := range allowlist {
373
+ on[id] = true
374
+ }
375
+ var ids []string
376
+ credits := map[string]string{}
377
+ seen := map[string]bool{}
378
+ // ① auto 等「在 allowlist 但不在注册表」的条目,按清单顺序补在前面。
379
+ // 仅当该 id 是客户端合成项(auto 等少数特例,客户端用、注册表不列)才补;
380
+ // 注册表为空时更不能拿选择器里随便一条顶替(那相当于把注册表的选择器
381
+ // 当 ② 用,违反「注册表权威」语义 —— 应当回退 ② 拿真实数据)。
382
+ for _, id := range allowlist {
383
+ if seen[id] {
384
+ continue
385
+ }
386
+ if !inAccRegistry(doc.Models, id) && isSyntheticSelectorID(id) {
387
+ seen[id] = true
388
+ ids = append(ids, id)
389
+ }
390
+ }
391
+ for _, m := range doc.Models {
392
+ id := strings.TrimSpace(m.ID)
393
+ if id == "" || seen[id] {
394
+ continue
395
+ }
396
+ // ② 用户自定义模型:客户端同样展示,只是不计倍率。
397
+ custom := strings.HasPrefix(id, "custom-local:")
398
+ // ③ 具名模型:只在客户端选择器清单里才展示。
399
+ if !custom && !(len(allowlist) > 0 && on[id]) {
400
+ continue
401
+ }
402
+ seen[id] = true
403
+ ids = append(ids, id)
404
+ if rate := parseCreditMultiplier(m.Credits); rate != "" {
405
+ credits[id] = rate
406
+ }
407
+ }
408
+ ids = dedupeModels(ids)
409
+ if len(ids) == 0 {
410
+ return nil, nil
411
+ }
412
+ return ids, credits
413
+ }
414
+
415
+ // isSyntheticSelectorID 判断某个不在 acc 注册表里的 id 是否是客户端的合成项(始终保留)。
416
+ //
417
+ // 实测 acc 注册表**只列具名模型**,不列客户端用作"自动选择"的合成项。最常见的就是
418
+ // `auto` —— 客户端用它表示「由后端挑模型」,CLI 的 --help 也认。
419
+ //
420
+ // ⚠️ 此名单必须保守:宁可漏掉一两个真合成项(表现为客户端能选、-m 取不到),
421
+ // 也不要把具名模型 id 误判进合成清单(那会让它绕过过滤、无限兜底进清单)。
422
+ func isSyntheticSelectorID(id string) bool {
423
+ return id == "auto"
424
+ }
425
+
426
+ // inAccRegistry 判断某 id 是否在 acc 注册表里(区分「客户端列了但注册表没有」)。
427
+ func inAccRegistry(models []struct {
428
+ ID string `json:"id"`
429
+ Credits string `json:"credits"`
430
+ }, id string) bool {
431
+ for _, m := range models {
432
+ if strings.TrimSpace(m.ID) == id {
433
+ return true
434
+ }
435
+ }
436
+ return false
437
+ }
438
+
439
+ // parseProductJSONModels 只取清单(积分倍率见 parseProductJSONCatalog)。
440
+ func parseProductJSONModels(data []byte) []string {
441
+ ids, _ := parseProductJSONCatalog(data)
442
+ return ids
443
+ }
444
+
445
+ // readRemoteConfigCacheModels 读取 CodeBuddy 远程配置的本地缓存,返回其中
446
+ // models[].id 的并集(积分倍率见 remoteConfigCacheCatalog)。
447
+ func readRemoteConfigCacheModels(localStorageDir string) []string {
448
+ ids, _ := remoteConfigCacheCatalog(localStorageDir)
449
+ return ids
450
+ }
451
+
452
+ // remoteConfigCacheCatalog 同上,但同时返回各模型的积分倍率表。
453
+ //
454
+ // 客户端模型选择器的真实数据源是网关(CloudProductManager 经 /v3/config)下发的
455
+ // 远程配置,落地在 <config_dir>/local_storage/entry_*.info:
456
+ //
457
+ // [{"userId":"...","data":{"models":[{"id":"deepseek-v4.1-flash","credits":"x0.03",...},...],...}}, ...]
458
+ //
459
+ // 多账号条目取并集;同一模型多账号倍率不同时取先出现者。同目录还有其他形态的
460
+ // 条目(feature-flags 对象、base64+gzip 大对象、坏文件),解析不进上面的结构
461
+ // 就跳过。目录不存在 / 无可用条目 → (nil, nil)(调用方回退下一来源)。
462
+ func remoteConfigCacheCatalog(localStorageDir string) ([]string, map[string]string) {
463
+ if localStorageDir == "" {
464
+ return nil, nil
465
+ }
466
+ entries, err := os.ReadDir(localStorageDir)
467
+ if err != nil {
468
+ return nil, nil
469
+ }
470
+ var ids []string
471
+ credits := map[string]string{}
472
+ for _, e := range entries {
473
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".info") {
474
+ continue
475
+ }
476
+ data, err := os.ReadFile(filepath.Join(localStorageDir, e.Name()))
477
+ if err != nil {
478
+ continue
479
+ }
480
+ var docs []struct {
481
+ Data struct {
482
+ Models []struct {
483
+ ID string `json:"id"`
484
+ Credits string `json:"credits"`
485
+ } `json:"models"`
486
+ } `json:"data"`
487
+ }
488
+ if json.Unmarshal(data, &docs) != nil {
489
+ continue
490
+ }
491
+ for _, doc := range docs {
492
+ for _, m := range doc.Data.Models {
493
+ id := strings.TrimSpace(m.ID)
494
+ if id == "" {
495
+ continue
496
+ }
497
+ ids = append(ids, id)
498
+ if c := parseCreditMultiplier(m.Credits); c != "" {
499
+ if _, dup := credits[id]; !dup {
500
+ credits[id] = c
501
+ }
502
+ }
503
+ }
504
+ }
505
+ }
506
+ ids = dedupeModels(ids)
507
+ if len(ids) == 0 {
508
+ return nil, nil
509
+ }
510
+ return ids, credits
511
+ }
512
+
513
+ // agentSelectorModels 从远程配置缓存里抽**客户端模型选择器真正展示的清单**
514
+ // —— agents[].models 数组:
515
+ //
516
+ // [{"userId":"...","data":{"agents":[{"modelTags":["craft"],
517
+ // "models":["auto","hy4-preview","hy3","deepseek-v4.1-flash",...]}]}}]
518
+ //
519
+ // 这份清单是**显式下发**的,比「按倍率猜」准得多:倍率只说明客户端能计费,
520
+ // 不等于会展示 —— hy3-b / hy3-c / hy4-preview-dev / minimax-m2.5 / kimi-k2.5 /
521
+ // glm-4.6 都有倍率也都在注册表里,但客户端选择器里没有(它们是灰度 / 按
522
+ // modelTags 分档放的)。反之补全类(supportsExtra=true)连倍率都没有。
523
+ //
524
+ // ⚠️ **只取 mtime 最新的那一个条目**,不跨条目/跨账号取并集:共享 ~/.codebuddy 里
525
+ // 可能同时躺着多个账号的缓存(实测 WorkBuddy 端 d43e… 缓存里 userId=f231e9af 的
526
+ // 清单用的是 hy4-preview-f,而 ed6c16d4 的是 hy4-preview —— 并集会把上一账号的
527
+ // 型号混进来)。最新条目就是当前登录账号刚下发的。
528
+ //
529
+ // 目录不存在 / 无 agents 字段 / 解析失败 → (nil, false):**没有权威清单**,
530
+ // 调用方据此跳过交集过滤、保留 acc 缓存全量(宁可多不可少,见 extendedModelCatalog)。
531
+ //
532
+ // ⚠️ auto:客户端清单里首条是 "auto"(自动选模型),它**不在 acc 缓存注册表里**
533
+ // (注册表只列具名模型)但 CLI 的 --help 认、且客户端确实展示,故调用方须放行。
534
+ func agentSelectorModels(localStorageDir string) ([]string, bool) {
535
+ if localStorageDir == "" {
536
+ return nil, false
537
+ }
538
+ entries, err := os.ReadDir(localStorageDir)
539
+ if err != nil {
540
+ return nil, false
541
+ }
542
+ type hit struct {
543
+ mod time.Time
544
+ ids []string
545
+ }
546
+ var best hit
547
+ for _, e := range entries {
548
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".info") {
549
+ continue
550
+ }
551
+ p := filepath.Join(localStorageDir, e.Name())
552
+ data, err := os.ReadFile(p)
553
+ if err != nil {
554
+ continue
555
+ }
556
+ var docs []struct {
557
+ Data struct {
558
+ Agents []struct {
559
+ Models []string `json:"models"`
560
+ } `json:"agents"`
561
+ } `json:"data"`
562
+ }
563
+ if json.Unmarshal(data, &docs) != nil {
564
+ continue
565
+ }
566
+ var ids []string
567
+ for _, doc := range docs {
568
+ for _, a := range doc.Data.Agents {
569
+ for _, m := range a.Models {
570
+ if id := strings.TrimSpace(m); id != "" {
571
+ ids = append(ids, id)
572
+ }
573
+ }
574
+ }
575
+ }
576
+ ids = dedupeModels(ids)
577
+ if len(ids) == 0 {
578
+ continue
579
+ }
580
+ fi, err := os.Stat(p)
581
+ if err != nil {
582
+ continue
583
+ }
584
+ if best.ids == nil || fi.ModTime().After(best.mod) {
585
+ best = hit{mod: fi.ModTime(), ids: ids}
586
+ }
587
+ }
588
+ if best.ids == nil {
589
+ return nil, false
590
+ }
591
+ return best.ids, true
592
+ }
593
+
594
+ // parseLLMModelsText 解析 `llm models` 的纯文本输出:
595
+ //
596
+ // OpenAI Chat: gpt-4o (aliases: 4o)
597
+ // OpenRouter: x-ai/grok-4
598
+ //
599
+ // 取「模型 id」:先剥 " (aliases: ...)" 尾巴,再取最后一个 ": " 之后的部分。
600
+ func parseLLMModelsText(stdout string) []string {
601
+ var out []string
602
+ for _, line := range strings.Split(stdout, "\n") {
603
+ ln := strings.TrimSpace(line)
604
+ if ln == "" {
605
+ continue
606
+ }
607
+ if i := strings.Index(ln, " (aliases: "); i >= 0 {
608
+ ln = ln[:i]
609
+ }
610
+ if i := strings.LastIndex(ln, ": "); i >= 0 {
611
+ ln = ln[i+2:]
612
+ }
613
+ out = append(out, ln)
614
+ }
615
+ return dedupeModels(out)
616
+ }
617
+
618
+ // claudeModelEnvPrefixes Claude Code 里「模型 id」变量所在的命名空间(两个):
619
+ //
620
+ // ANTHROPIC_ ANTHROPIC_MODEL / ANTHROPIC_SMALL_FAST_MODEL /
621
+ // ANTHROPIC_DEFAULT_{HAIKU,SONNET,OPUS}_MODEL …
622
+ // CLAUDE_CODE_ CLAUDE_CODE_SUBAGENT_MODEL(子代理用的模型 id)
623
+ //
624
+ // 为什么不写死具体变量名:档位是可扩展的(本机就出现了 ANTHROPIC_DEFAULT_FABLE_MODEL,
625
+ // 二进制里只有 HAIKU/SONNET/OPUS),按「命名空间 + MODEL 结尾」过滤更耐用。
626
+ // ⚠️ 同一组的 `*_MODEL_NAME` / `*_MODEL_DESCRIPTION` / `*_MODEL_SUPPORTED_CAPABILITIES`
627
+ // 是**显示名/描述/能力位**,不是 id,被「MODEL 结尾」这条挡掉(见下)。
628
+ var claudeModelEnvPrefixes = []string{"ANTHROPIC_", "CLAUDE_CODE_"}
629
+
630
+ // claudeModelsFromSettings 从 Claude Code 的 settings.json 抽**模型标识**(-m 可取值):
631
+ // 顶层 model + env 里「ANTHROPIC_ / CLAUDE_CODE_ 开头、且以 MODEL 结尾」的字符串值。
632
+ //
633
+ // 为什么只认 **MODEL 结尾**(2026-09-28 更正):Claude Code 的 model 类 env 是
634
+ // **一组四个**变量(键名取自 CLI 二进制内的字符串):
635
+ //
636
+ // ANTHROPIC_DEFAULT_{HAIKU,SONNET,OPUS}_MODEL ← 模型 id
637
+ // ANTHROPIC_DEFAULT_{HAIKU,SONNET,OPUS}_MODEL_NAME ← 显示名(界面标签)
638
+ // ANTHROPIC_DEFAULT_{HAIKU,SONNET,OPUS}_MODEL_DESCRIPTION ← 描述
639
+ // ANTHROPIC_DEFAULT_{HAIKU,SONNET,OPUS}_MODEL_SUPPORTED_CAPABILITIES ← 能力位
640
+ //
641
+ // 只有 id 那一格算「模型」;`*_MODEL_NAME` 是给人看的标签
642
+ // (本机那份里是 GLM-5.2 / kimi-k2.6 / deepseek-v4-pro —— 实测「能跑通」不等于
643
+ // 「是模型 id」,路由可能只是回落到默认模型,所以只按命名契约取 id 格)。
644
+ //
645
+ // env 里混有非字符串值(如 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1),须跳过。
646
+ func claudeModelsFromSettings(data []byte) ([]string, error) {
647
+ var s struct {
648
+ Model string `json:"model"`
649
+ Env map[string]any `json:"env"`
650
+ }
651
+ if err := json.Unmarshal(data, &s); err != nil {
652
+ return nil, fmt.Errorf("parse claude settings: %w", err)
653
+ }
654
+ out := make([]string, 0, len(s.Env)+1)
655
+ if v := strings.TrimSpace(s.Model); v != "" {
656
+ out = append(out, v)
657
+ }
658
+ keys := make([]string, 0, len(s.Env))
659
+ for k := range s.Env {
660
+ keys = append(keys, k)
661
+ }
662
+ sort.Strings(keys) // 固定顺序,便于 diff / 测试
663
+ for _, k := range keys {
664
+ if !hasAnyPrefix(k, claudeModelEnvPrefixes) {
665
+ continue
666
+ }
667
+ // 只取 id 格:MODEL 结尾。`*_MODEL_NAME` 是显示名,不是可取值。
668
+ if !strings.HasSuffix(k, "MODEL") {
669
+ continue
670
+ }
671
+ if v, ok := s.Env[k].(string); ok && strings.TrimSpace(v) != "" {
672
+ out = append(out, v)
673
+ }
674
+ }
675
+ return dedupeModels(out), nil
676
+ }
677
+
678
+ // hasAnyPrefix 判断 s 是否以 prefixes 中任一项开头。
679
+ func hasAnyPrefix(s string, prefixes []string) bool {
680
+ for _, p := range prefixes {
681
+ if strings.HasPrefix(s, p) {
682
+ return true
683
+ }
684
+ }
685
+ return false
686
+ }