magic-agent 2.4.2 → 2.6.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/CLIENT-CONTRACT.md +290 -30
- package/README.md +15 -14
- package/client-ui/pc/app.css +32 -0
- package/client-ui/pc/js/04-task-list.js +5 -1
- package/client-ui/pc/js/07-chat-stream-render.js +3 -3
- package/client-ui/pc/js/90-glue.js +37 -2
- package/client-ui/pc/js/92-composer.js +19 -4
- package/client-ui/pc/js/93-rail.js +16 -5
- package/client-ui/shared/conv/02-media-kinds.js +2 -2
- package/client-ui/shared/conv/03-chat-message.js +241 -23
- package/client-ui/shared/conv/06-ask-answer.js +75 -1
- package/client-ui/shared/conv/09-stream-apply.js +63 -7
- package/client-ui/shared/conv/19-attachments.js +157 -6
- package/client-ui/shared/conv/27-conv-ledger.js +5 -2
- package/client-ui/shared/conv/29-msg-variants.js +81 -0
- package/client-ui/shared/conv/conv.css +79 -19
- package/client-ui/shared/conv/dom-map.md +1 -0
- package/client-ui/shared/conversation.css +13 -2
- package/client-ui/shared/conversation.js +336 -50
- package/client-ui/shared/transport-http.js +14 -0
- package/client-ui-mobile/index.html +2932 -333
- package/client-ui-mobile/reqs.css +102 -0
- package/client-ui-mobile/reqs.js +485 -70
- package/client-ui-mobile/reqshot.js +505 -0
- package/client-ui-mobile/scripts.css +21 -0
- package/client-ui-mobile/scripts.js +539 -0
- package/client-ui-mobile/vendor/cose-base.js +3214 -0
- package/client-ui-mobile/vendor/cytoscape-fcose.js +1549 -0
- package/client-ui-mobile/vendor/cytoscape.min.js +32 -0
- package/client-ui-mobile/vendor/layout-base.js +5230 -0
- package/cmd/magic-client/browsectl.go +199 -0
- package/cmd/magic-client/clientctl.go +33 -9
- package/cmd/magic-client/main.go +57 -26
- package/internal/agent/ask.go +45 -0
- package/internal/agent/ask_test.go +39 -0
- package/internal/agent/attachments_test.go +6 -2
- package/internal/agent/capability.go +7 -1
- package/internal/agent/claude.go +20 -15
- package/internal/agent/codebuddy.go +125 -34
- package/internal/agent/codebuddy_account_test.go +54 -0
- package/internal/agent/codebuddy_stream_test.go +103 -0
- package/internal/agent/credits.go +713 -0
- package/internal/agent/credits_minimax_test.go +97 -0
- package/internal/agent/credits_test.go +211 -0
- package/internal/agent/custom_models.go +155 -0
- package/internal/agent/custom_models_test.go +125 -0
- package/internal/agent/dsh.go +123 -49
- package/internal/agent/dsh_resume.go +13 -2
- package/internal/agent/dsh_test.go +108 -6
- package/internal/agent/engine.go +37 -1
- package/internal/agent/engine_base.go +21 -17
- package/internal/agent/engine_base_test.go +2 -2
- package/internal/agent/engine_test.go +47 -86
- package/internal/agent/fallback.go +313 -0
- package/internal/agent/fallback_test.go +340 -0
- package/internal/agent/launch.go +43 -1
- package/internal/agent/llmhttp.go +46 -4
- package/internal/agent/llmhttp_test.go +157 -0
- package/internal/agent/models.go +53 -1
- package/internal/agent/models_test.go +20 -5
- package/internal/agent/pidalive_windows_test.go +40 -0
- package/internal/agent/runcmd.go +26 -0
- package/internal/agent/runcmd_windows.go +36 -5
- package/internal/agent/stream.go +136 -6
- package/internal/agent/stream_test.go +25 -38
- package/internal/agent/stream_timeline_test.go +2 -37
- package/internal/agent/streamjson.go +108 -6
- package/internal/agent/tools_suffix_test.go +6 -3
- package/internal/agent/trae.go +50 -243
- package/internal/agent/trae_exec.go +433 -0
- package/internal/agent/trae_exec_test.go +181 -0
- package/internal/agent/wraplaunch_test.go +113 -0
- package/internal/cli/ask.go +160 -37
- package/internal/cli/ask_test.go +1 -1
- package/internal/cli/checkin.go +77 -0
- package/internal/cli/clientcmd.go +3 -2
- package/internal/cli/control.go +46 -5
- package/internal/cli/control_test.go +65 -0
- package/internal/cli/events.go +4 -0
- package/internal/cli/quotas.go +125 -0
- package/internal/cli/requirements.go +51 -0
- package/internal/cli/root.go +122 -5
- package/internal/cli/session.go +7 -10
- package/internal/cli/suggest.go +7 -5
- package/internal/cli/team.go +37 -9
- package/internal/cli/team_test.go +39 -0
- package/internal/client/ab.go +372 -0
- package/internal/client/ab_test.go +287 -0
- package/internal/client/browse.go +84 -36
- package/internal/client/browse_test.go +107 -9
- package/internal/client/browseinput.go +481 -0
- package/internal/client/browseinput_test.go +216 -0
- package/internal/client/corecmd.go +23 -0
- package/internal/client/corecmd_other.go +8 -0
- package/internal/client/corecmd_windows.go +24 -0
- package/internal/client/daemon.go +90 -17
- package/internal/client/daemon_test.go +33 -0
- package/internal/client/engines_checkin_test.go +57 -0
- package/internal/client/fallback_flags_test.go +64 -0
- package/internal/client/jobs.go +294 -21
- package/internal/client/jobs_test.go +299 -2
- package/internal/client/knowledge.go +854 -0
- package/internal/client/knowledge_test.go +510 -0
- package/internal/client/lan.go +193 -7
- package/internal/client/lan_test.go +110 -0
- package/internal/client/quotas_merge_test.go +107 -0
- package/internal/client/requirements.go +198 -1
- package/internal/client/requirements_shots_test.go +200 -0
- package/internal/client/restart.go +134 -0
- package/internal/client/restart_test.go +151 -0
- package/internal/client/scripts.go +1047 -0
- package/internal/client/scripts_decode_other.go +27 -0
- package/internal/client/scripts_decode_test.go +125 -0
- package/internal/client/scripts_decode_windows.go +206 -0
- package/internal/client/scripts_test.go +726 -0
- package/internal/client/service.go +322 -18
- package/internal/client/service_test.go +51 -0
- package/internal/client/suggest.go +105 -2
- package/internal/client/suggest_test.go +86 -0
- package/internal/client/team_flags.go +25 -11
- package/internal/client/team_flags_test.go +39 -0
- package/internal/requirements/bootstrap.go +16 -0
- package/internal/requirements/consolidate.go +100 -9
- package/internal/requirements/doc.go +154 -3
- package/internal/requirements/graph.go +138 -0
- package/internal/requirements/memory.go +7 -0
- package/internal/requirements/modules.go +312 -0
- package/internal/requirements/modules_test.go +373 -0
- package/internal/requirements/relations.go +155 -0
- package/internal/requirements/relations_test.go +231 -0
- package/internal/requirements/requirements.go +10 -0
- package/internal/requirements/shots.go +418 -0
- package/internal/requirements/shots_test.go +387 -0
- package/internal/session/session.go +92 -0
- package/internal/session/sessionlog.go +23 -0
- package/internal/session/sessionlog_read_test.go +39 -0
- package/internal/suggest/suggest.go +23 -1
- package/internal/team/event.go +3 -1
- package/internal/team/execute.go +21 -6
- package/internal/team/fallback_route_test.go +80 -0
- package/internal/team/planner.go +15 -3
- package/internal/team/route.go +89 -17
- package/internal/team/team.go +18 -4
- package/npm/apk/magic-agent-mobile-release.apk +0 -0
- package/npm/apk/version.json +1 -5
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +8 -2
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
package agent
|
|
2
|
+
|
|
3
|
+
// fallback_test.go - 主备降级(2026-10-24;2026-10-26 按真机报障补口径)。
|
|
4
|
+
//
|
|
5
|
+
// 钉子:同引擎换模型也降级 / 换引擎不把主引擎的模型带过去 / 只有正文算产出 /
|
|
6
|
+
// 降级要发 fallback 帧且不带主引擎残留的终帧 / 降级清会话锚点且只降一级 /
|
|
7
|
+
// 没降级要给得出原因(原因进 FallbackInfo)。
|
|
8
|
+
|
|
9
|
+
import (
|
|
10
|
+
"context"
|
|
11
|
+
"errors"
|
|
12
|
+
"strings"
|
|
13
|
+
"testing"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
type fallbackFakeEngine struct {
|
|
17
|
+
name string
|
|
18
|
+
text string
|
|
19
|
+
events []string
|
|
20
|
+
err error
|
|
21
|
+
// 记录最后一次收到的请求(验降级时清掉了会话锚点 / 模型怎么传的)。
|
|
22
|
+
lastReq Request
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func (f *fallbackFakeEngine) Name() string { return f.name }
|
|
26
|
+
func (f *fallbackFakeEngine) Detect() (bool, string) { return true, "fake://" + f.name }
|
|
27
|
+
func (f *fallbackFakeEngine) Complete(_ context.Context, req Request) (Response, error) {
|
|
28
|
+
f.lastReq = req
|
|
29
|
+
return Response{Engine: f.name, Model: req.Model, Text: f.text}, f.err
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Stream 默认可用 —— 与内置引擎一致(它们都是 Streamer)。
|
|
33
|
+
func (f *fallbackFakeEngine) Stream(_ context.Context, req Request, onEvent func(StreamEvent)) (StreamResult, error) {
|
|
34
|
+
f.lastReq = req
|
|
35
|
+
// 先以「思考」形态吐一遍(纯过程不拦降级),再把 events 当正文吐。
|
|
36
|
+
if len(f.events) > 0 {
|
|
37
|
+
onEvent(StreamEvent{Kind: KindThinking, Text: "让我想想"})
|
|
38
|
+
}
|
|
39
|
+
for _, s := range f.events {
|
|
40
|
+
onEvent(StreamEvent{Kind: KindText, Text: s})
|
|
41
|
+
}
|
|
42
|
+
return StreamResult{Response: Response{Engine: f.name, Model: req.Model, Text: f.text}}, f.err
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// fallbackFakeNonStream 不支持流式的引擎(只有 Engine 接口,没有 Stream)。
|
|
46
|
+
type fallbackFakeNonStream struct {
|
|
47
|
+
name string
|
|
48
|
+
text string
|
|
49
|
+
req Request
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func (f *fallbackFakeNonStream) Name() string { return f.name }
|
|
53
|
+
func (f *fallbackFakeNonStream) Detect() (bool, string) { return true, "fake://" + f.name }
|
|
54
|
+
func (f *fallbackFakeNonStream) Complete(_ context.Context, req Request) (Response, error) {
|
|
55
|
+
f.req = req
|
|
56
|
+
return Response{Engine: f.name, Text: f.text}, nil
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// withFallbackEngines 临时替换注册表:Lookup 只认这里登记的假引擎,测完还原
|
|
60
|
+
// (别让内置引擎的惰性注册被这个用例打断,见 engine.go 的 registry 注释)。
|
|
61
|
+
func withFallbackEngines(t *testing.T, engines ...Engine) {
|
|
62
|
+
t.Helper()
|
|
63
|
+
old := registry
|
|
64
|
+
registry = append([]Engine(nil), engines...)
|
|
65
|
+
t.Cleanup(func() { registry = old })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// collectFallback 收事件的助手:按 kind 分类存下来。
|
|
69
|
+
type fallbackRecorder struct {
|
|
70
|
+
notices []string
|
|
71
|
+
fallback []FallbackEvent
|
|
72
|
+
texts []string
|
|
73
|
+
turnEnds int
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func (r *fallbackRecorder) on(ev StreamEvent) {
|
|
77
|
+
switch ev.Kind {
|
|
78
|
+
case KindNotice:
|
|
79
|
+
r.notices = append(r.notices, ev.Text)
|
|
80
|
+
case KindFallback:
|
|
81
|
+
if ev.Fallback != nil {
|
|
82
|
+
r.fallback = append(r.fallback, *ev.Fallback)
|
|
83
|
+
}
|
|
84
|
+
case KindText:
|
|
85
|
+
r.texts = append(r.texts, ev.Text)
|
|
86
|
+
case KindTurnEnd:
|
|
87
|
+
r.turnEnds++
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func TestStreamWithFallbackOnZeroOutput(t *testing.T) {
|
|
92
|
+
primary := &fallbackFakeEngine{name: "fb-primary", err: errors.New("402 insufficient_balance_error")}
|
|
93
|
+
backup := &fallbackFakeEngine{name: "fb-backup", text: "备用答案"}
|
|
94
|
+
withFallbackEngines(t, primary, backup)
|
|
95
|
+
|
|
96
|
+
rec := &fallbackRecorder{}
|
|
97
|
+
res, info, err := StreamWithFallback(context.Background(), primary, Request{
|
|
98
|
+
Model: "m1",
|
|
99
|
+
FallbackEngine: "fb-backup",
|
|
100
|
+
FallbackModel: "m2",
|
|
101
|
+
}, rec.on)
|
|
102
|
+
if err != nil {
|
|
103
|
+
t.Fatalf("主引擎失败、备用成功时不该报错:%v", err)
|
|
104
|
+
}
|
|
105
|
+
if res.Text != "备用答案" {
|
|
106
|
+
t.Errorf("应返回备用引擎的正文,得到 %q", res.Text)
|
|
107
|
+
}
|
|
108
|
+
if len(rec.fallback) != 1 {
|
|
109
|
+
t.Fatalf("应发一条 fallback 帧,得到 %#v", rec.fallback)
|
|
110
|
+
}
|
|
111
|
+
fe := rec.fallback[0]
|
|
112
|
+
if fe.FromEngine != "fb-primary" || fe.ToEngine != "fb-backup" || !fe.Switched || fe.FromModel != "m1" {
|
|
113
|
+
t.Errorf("fallback 帧两端不对:%#v", fe)
|
|
114
|
+
}
|
|
115
|
+
if !strings.Contains(fe.Reason, "402") {
|
|
116
|
+
t.Errorf("fallback 帧应带主引擎失败原因:%q", fe.Reason)
|
|
117
|
+
}
|
|
118
|
+
/* 换引擎:备用模型属于主引擎的清单,不能带过去(z 以前把 codebuddy 的 hy3 喂给 dsh)。 */
|
|
119
|
+
if backup.lastReq.Model != "" || !backup.lastReq.FallbackDown {
|
|
120
|
+
t.Errorf("换引擎时模型应清空并标记 FallbackDown:model=%q down=%v",
|
|
121
|
+
backup.lastReq.Model, backup.lastReq.FallbackDown)
|
|
122
|
+
}
|
|
123
|
+
if backup.lastReq.FallbackEngine != "" {
|
|
124
|
+
t.Error("降级请求里不该再留着备用字段(只降一级)")
|
|
125
|
+
}
|
|
126
|
+
if !info.Used || info.Engine != "fb-backup" || info.SwitchedEngine != true {
|
|
127
|
+
t.Errorf("FallbackInfo 应记录降级已生效:%#v", info)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 同引擎换模型(手机上最常见的配法:dsh 主 modelverse、备 volcengine)——
|
|
132
|
+
// 2026-10-26 真机作业记录里这一路以前被直接判成「没配备用」。
|
|
133
|
+
func TestStreamWithFallbackSameEngineOtherModel(t *testing.T) {
|
|
134
|
+
var calls []string
|
|
135
|
+
primary := &fallbackFakeEngine{name: "fb-same", err: errors.New("402 Token Plan 用量上限")}
|
|
136
|
+
backup := &fallbackFakeEngine{name: "fb-same", text: "备用模型给的答案"}
|
|
137
|
+
withFallbackEngines(t, backup)
|
|
138
|
+
|
|
139
|
+
rec := &fallbackRecorder{}
|
|
140
|
+
res, info, err := StreamWithFallback(context.Background(), primary, Request{
|
|
141
|
+
Model: "modelverse/x",
|
|
142
|
+
FallbackEngine: "fb-same",
|
|
143
|
+
FallbackModel: "volcengine/x",
|
|
144
|
+
}, func(ev StreamEvent) {
|
|
145
|
+
if ev.Kind == KindFallback {
|
|
146
|
+
calls = append(calls, "fallback")
|
|
147
|
+
}
|
|
148
|
+
rec.on(ev)
|
|
149
|
+
})
|
|
150
|
+
if err != nil {
|
|
151
|
+
t.Fatalf("同引擎换模型应能降级:%v", err)
|
|
152
|
+
}
|
|
153
|
+
if res.Text != "备用模型给的答案" {
|
|
154
|
+
t.Errorf("应返回备用模型的正文,得到 %q", res.Text)
|
|
155
|
+
}
|
|
156
|
+
if backup.lastReq.Model != "volcengine/x" {
|
|
157
|
+
t.Errorf("同引擎换模型时必须带上备用模型,得到 %q", backup.lastReq.Model)
|
|
158
|
+
}
|
|
159
|
+
if len(rec.fallback) != 1 || rec.fallback[0].Switched || rec.fallback[0].ToModel != "volcengine/x" {
|
|
160
|
+
t.Fatalf("fallback 帧应说明是同引擎换模型:%#v", rec.fallback)
|
|
161
|
+
}
|
|
162
|
+
if !info.Used || info.SwitchedEngine {
|
|
163
|
+
t.Errorf("FallbackInfo:%#v", info)
|
|
164
|
+
}
|
|
165
|
+
// 人话里要能看到模型名 —— 用户配的正是模型。
|
|
166
|
+
if !strings.Contains(FallbackNoticeText(rec.fallback[0]), "volcengine/x") {
|
|
167
|
+
t.Errorf("提示里应带备用模型名:%q", FallbackNoticeText(rec.fallback[0]))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 只有过程没有正文(思考 / 空 delta)也降级:已流出的思考是过程,不是答案。
|
|
172
|
+
func TestStreamWithFallbackIgnoresThinkingOnly(t *testing.T) {
|
|
173
|
+
primary := &fallbackFakeEngine{name: "fb-think", err: errors.New("余额不足")}
|
|
174
|
+
// 主引擎只吐了思考与空白正文,没给答案。
|
|
175
|
+
primary.events = []string{"", " "}
|
|
176
|
+
backup := &fallbackFakeEngine{name: "fb-think-backup", text: "答案"}
|
|
177
|
+
withFallbackEngines(t, primary, backup)
|
|
178
|
+
|
|
179
|
+
rec := &fallbackRecorder{}
|
|
180
|
+
_, info, err := StreamWithFallback(context.Background(), primary, Request{
|
|
181
|
+
FallbackEngine: "fb-think-backup",
|
|
182
|
+
}, rec.on)
|
|
183
|
+
if err != nil {
|
|
184
|
+
t.Fatalf("只吐过思考时仍应降级:%v", err)
|
|
185
|
+
}
|
|
186
|
+
if !info.Used {
|
|
187
|
+
t.Errorf("只吐过思考时仍应降级:%#v", info)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 已经吐过正文:不降级,保留原始错误,并给出原因。
|
|
192
|
+
func TestStreamWithFallbackKeepsPartialOutput(t *testing.T) {
|
|
193
|
+
primary := &fallbackFakeEngine{name: "fb-partial", events: []string{"已经写了一"}, err: errors.New("boom")}
|
|
194
|
+
backup := &fallbackFakeEngine{name: "fb-backup2", text: "不该出现"}
|
|
195
|
+
withFallbackEngines(t, primary, backup)
|
|
196
|
+
|
|
197
|
+
rec := &fallbackRecorder{}
|
|
198
|
+
_, info, err := StreamWithFallback(context.Background(), primary, Request{
|
|
199
|
+
FallbackEngine: "fb-backup2",
|
|
200
|
+
}, rec.on)
|
|
201
|
+
if err == nil {
|
|
202
|
+
t.Fatal("已经产出正文时应当保留原始错误")
|
|
203
|
+
}
|
|
204
|
+
if len(rec.fallback) != 0 {
|
|
205
|
+
t.Error("已经产出正文时不该降级(会出现两段互相矛盾的答案)")
|
|
206
|
+
}
|
|
207
|
+
if !strings.Contains(info.Reason, "正文") {
|
|
208
|
+
t.Errorf("没降级要给原因:%q", info.Reason)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func TestStreamWithFallbackClearsSession(t *testing.T) {
|
|
213
|
+
primary := &fallbackFakeEngine{name: "fb-sess", err: errors.New("expired")}
|
|
214
|
+
backup := &fallbackFakeEngine{name: "fb-sess2", text: "ok"}
|
|
215
|
+
withFallbackEngines(t, primary, backup)
|
|
216
|
+
|
|
217
|
+
_, _, err := StreamWithFallback(context.Background(), primary, Request{
|
|
218
|
+
SessionID: "sess-of-primary",
|
|
219
|
+
Continue: true,
|
|
220
|
+
FallbackEngine: "fb-sess2",
|
|
221
|
+
}, func(StreamEvent) {})
|
|
222
|
+
if err != nil {
|
|
223
|
+
t.Fatalf("降级应成功:%v", err)
|
|
224
|
+
}
|
|
225
|
+
if backup.lastReq.SessionID != "" || backup.lastReq.Continue {
|
|
226
|
+
t.Errorf("降级必须清掉会话锚点:session=%q continue=%v", backup.lastReq.SessionID, backup.lastReq.Continue)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 主引擎残留的终帧不能漏给调用方:否则这一轮在备用开口前就被画成「已结束」。
|
|
231
|
+
func TestStreamWithFallbackDropsPrimaryTerminalFrames(t *testing.T) {
|
|
232
|
+
primary := &fallbackFakeEngine{name: "fb-term", err: errors.New("boom")}
|
|
233
|
+
backup := &fallbackFakeEngine{name: "fb-term2", text: "ok"}
|
|
234
|
+
withFallbackEngines(t, primary, backup)
|
|
235
|
+
|
|
236
|
+
rec := &fallbackRecorder{}
|
|
237
|
+
_, _, err := StreamWithFallback(context.Background(), primary, Request{
|
|
238
|
+
FallbackEngine: "fb-term2",
|
|
239
|
+
}, rec.on)
|
|
240
|
+
if err != nil {
|
|
241
|
+
t.Fatalf("应降级成功:%v", err)
|
|
242
|
+
}
|
|
243
|
+
// 假引擎的 Stream 不走 accumulator、不会自己发终帧;这里直接验过滤口径。
|
|
244
|
+
if _, keep := stripNonResetKinds(StreamEvent{Kind: KindTurnEnd}); keep {
|
|
245
|
+
t.Error("turn_end 应被吞掉")
|
|
246
|
+
}
|
|
247
|
+
if _, keep := stripNonResetKinds(StreamEvent{Kind: KindTurnFailed}); keep {
|
|
248
|
+
t.Error("turn_failed 应被吞掉")
|
|
249
|
+
}
|
|
250
|
+
if _, keep := stripNonResetKinds(StreamEvent{Kind: KindThinking, Text: "思考"}); !keep {
|
|
251
|
+
t.Error("思考等过程事件应照常转发")
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func TestRunWithFallback(t *testing.T) {
|
|
256
|
+
primary := &fallbackFakeEngine{name: "fb-c1", err: errors.New("quota")}
|
|
257
|
+
backup := &fallbackFakeEngine{name: "fb-c2", text: "完成"}
|
|
258
|
+
withFallbackEngines(t, primary, backup)
|
|
259
|
+
|
|
260
|
+
var note string
|
|
261
|
+
resp, info, err := RunWithFallback(context.Background(), primary, Request{FallbackEngine: "fb-c2"},
|
|
262
|
+
RunOptions{}, func(fe FallbackEvent) { note = FallbackNoticeText(fe) })
|
|
263
|
+
if err != nil {
|
|
264
|
+
t.Fatalf("应降级成功:%v", err)
|
|
265
|
+
}
|
|
266
|
+
if resp.Text != "完成" || !strings.Contains(note, "降级") || !strings.Contains(note, "fb-c2") {
|
|
267
|
+
t.Errorf("resp=%q note=%q", resp.Text, note)
|
|
268
|
+
}
|
|
269
|
+
if !info.Used || info.Engine != "fb-c2" {
|
|
270
|
+
t.Errorf("FallbackInfo:%#v", info)
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 同引擎换模型(非流式路径同样要生效)。
|
|
275
|
+
func TestRunWithFallbackSameEngineOtherModel(t *testing.T) {
|
|
276
|
+
primary := &fallbackFakeEngine{name: "fb-rm", err: errors.New("402")}
|
|
277
|
+
backup := &fallbackFakeEngine{name: "fb-rm", text: "备用"}
|
|
278
|
+
withFallbackEngines(t, backup)
|
|
279
|
+
|
|
280
|
+
_, info, err := RunWithFallback(context.Background(), primary, Request{
|
|
281
|
+
Model: "a", FallbackEngine: "fb-rm", FallbackModel: "b",
|
|
282
|
+
}, RunOptions{}, nil)
|
|
283
|
+
if err != nil {
|
|
284
|
+
t.Fatalf("应降级成功:%v", err)
|
|
285
|
+
}
|
|
286
|
+
if backup.lastReq.Model != "b" {
|
|
287
|
+
t.Errorf("同引擎换模型应带上备用模型:%q", backup.lastReq.Model)
|
|
288
|
+
}
|
|
289
|
+
if !info.Used || info.Model != "b" {
|
|
290
|
+
t.Errorf("FallbackInfo.Model 应是备用模型:%#v", info)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 引擎与模型都相同 = 没有可降级的目标:不白跑一次,原因说出来。
|
|
295
|
+
func TestFallbackNoopWhenIdentical(t *testing.T) {
|
|
296
|
+
primary := &fallbackFakeEngine{name: "fb-id", err: errors.New("boom")}
|
|
297
|
+
withFallbackEngines(t, primary)
|
|
298
|
+
|
|
299
|
+
_, info, err := RunWithFallback(context.Background(), primary, Request{
|
|
300
|
+
FallbackEngine: "fb-id", FallbackModel: "",
|
|
301
|
+
}, RunOptions{}, nil)
|
|
302
|
+
if err == nil {
|
|
303
|
+
t.Fatal("引擎与模型都相同时应返回原始错误")
|
|
304
|
+
}
|
|
305
|
+
if !strings.Contains(info.Reason, "没有可降级") {
|
|
306
|
+
t.Errorf("应说明没有降级目标:%q", info.Reason)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
func TestFallbackIgnoresUnknownEngine(t *testing.T) {
|
|
311
|
+
primary := &fallbackFakeEngine{name: "fb-only", err: errors.New("boom")}
|
|
312
|
+
withFallbackEngines(t, primary)
|
|
313
|
+
|
|
314
|
+
_, info, err := StreamWithFallback(context.Background(), primary, Request{
|
|
315
|
+
FallbackEngine: "不存在的引擎",
|
|
316
|
+
}, func(StreamEvent) {})
|
|
317
|
+
if err == nil {
|
|
318
|
+
t.Fatal("备用引擎不存在时应返回原始错误")
|
|
319
|
+
}
|
|
320
|
+
if !strings.Contains(info.Reason, "不存在") {
|
|
321
|
+
t.Errorf("应说明备用引擎不存在:%q", info.Reason)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 备用没有流式能力:流式路径不降级(原因说出来)。
|
|
326
|
+
func TestFallbackStreamNeedsStreamer(t *testing.T) {
|
|
327
|
+
primary := &fallbackFakeEngine{name: "fb-ns-p", err: errors.New("boom")}
|
|
328
|
+
backup := &fallbackFakeNonStream{name: "fb-ns", text: "非流式答案"}
|
|
329
|
+
withFallbackEngines(t, primary, backup)
|
|
330
|
+
|
|
331
|
+
_, info, err := StreamWithFallback(context.Background(), primary, Request{
|
|
332
|
+
FallbackEngine: "fb-ns",
|
|
333
|
+
}, func(StreamEvent) {})
|
|
334
|
+
if err == nil {
|
|
335
|
+
t.Fatal("备用不支持流式时应返回原始错误")
|
|
336
|
+
}
|
|
337
|
+
if !strings.Contains(info.Reason, "流式") {
|
|
338
|
+
t.Errorf("应说明备用不支持流式:%q", info.Reason)
|
|
339
|
+
}
|
|
340
|
+
}
|
package/internal/agent/launch.go
CHANGED
|
@@ -8,7 +8,7 @@ package agent
|
|
|
8
8
|
//
|
|
9
9
|
// npm 全局装的 shim:Windows 上是 claude.cmd / codebuddy.cmd(批处理),
|
|
10
10
|
// macOS/Linux 上是 claude / codebuddy(无扩展名的脚本)
|
|
11
|
-
// 独立安装的原生二进制:
|
|
11
|
+
// 独立安装的原生二进制:traex.exe / dsh 等
|
|
12
12
|
//
|
|
13
13
|
// **批处理不能被直接 exec**。Windows 的 exec.Command 遇到 `.cmd` 会走
|
|
14
14
|
// cmd.exe 解析层,参数里的非 ASCII 字符会被代码页转换吃掉(中文 → 乱码),
|
|
@@ -112,6 +112,48 @@ func launchWrap(bin string, args []string) launchSpec {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/* WrapLaunch 把「可执行文件 + 参数」变成**可直接 exec** 的形式,供本仓之外的
|
|
116
|
+
* 调用方使用(目前是 internal/client 的脚本管理,见 scripts.go)。
|
|
117
|
+
*
|
|
118
|
+
* 与 launchWrap 的区别(三条,都重要):
|
|
119
|
+
*
|
|
120
|
+
* 1. **不做 emptyStringFlags 改写**。那张表登记的是**引擎 prompt flag**
|
|
121
|
+
* (--tools),是引擎语义的一部分;脚本的参数是用户自己填的定长参数,
|
|
122
|
+
* 套上引擎的白名单只会莫名其妙地改写用户的参数。
|
|
123
|
+
* 2. **返回两个值而不是 launchSpec**。launchSpec 是引擎内部的启动决策,
|
|
124
|
+
* 带上 wrapped 诊断位;对外只需要「拿这俩去 exec」。
|
|
125
|
+
* 3. **会传播退出码**(launchWrap 不会)。见下面那段说明。
|
|
126
|
+
*
|
|
127
|
+
* 为什么需要它:Windows 上 `.cmd/.bat/.ps1` **不能直接 exec** ——
|
|
128
|
+
* cmd.exe 解析层会把参数里的中文、& | ^ % 拆坏(见本文件头)。
|
|
129
|
+
* 脚本管理要跑用户登记的脚本文件,Windows 上多半就是 `.cmd` / `.ps1`,
|
|
130
|
+
* 不走这层包装就会静默失败。
|
|
131
|
+
*
|
|
132
|
+
* ── 为什么要传播退出码 ──
|
|
133
|
+
*
|
|
134
|
+
* 实测(2026-10-24):PowerShell 用 `& 'script.cmd'` 调一个 `exit /b 3` 的
|
|
135
|
+
* 批处理时,**PowerShell 自己的退出码是 1,不是 3** —— 脚本的退出码被
|
|
136
|
+
* PowerShell 吞掉了。后果是「脚本失败」在界面上显示成退出码 1,
|
|
137
|
+
* 具体的 3 看不到(用户排查时少一条关键信息)。
|
|
138
|
+
*
|
|
139
|
+
* 补一句 `exit $LASTEXITCODE` 就能把它带出来(实测退出码变回 3)。
|
|
140
|
+
* 引擎路径不需要这个 —— 引擎的成败看的是输出与 `result` 事件,不是退出码。
|
|
141
|
+
*/
|
|
142
|
+
func WrapLaunch(bin string, args []string) (string, []string) {
|
|
143
|
+
if !platformIsWindows() {
|
|
144
|
+
return bin, args
|
|
145
|
+
}
|
|
146
|
+
if !needsPowerShellWrap(bin) {
|
|
147
|
+
return bin, args
|
|
148
|
+
}
|
|
149
|
+
script := powershellInvokeScript(bin, args)
|
|
150
|
+
// 退出码透传:$LASTEXITCODE 是「最近一次原生命令」的退出码。
|
|
151
|
+
// 对 .ps1 它是 $null(PowerShell 脚本没有原生退出码)—— 那时
|
|
152
|
+
// `exit $null` 等于 exit 0,也合理(PowerShell 脚本正常跑完就是成功)。
|
|
153
|
+
script += "; exit $LASTEXITCODE"
|
|
154
|
+
return powershellBin(), []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}
|
|
155
|
+
}
|
|
156
|
+
|
|
115
157
|
// emptyStringFlags 全局登记:本项目里哪些 flag 会以**空串**作为值。
|
|
116
158
|
//
|
|
117
159
|
// 为什么要全局而不是按引擎传:改写发生在 launchWrap 这一层,
|
|
@@ -94,13 +94,55 @@ func buildConfiguredBody(m LLMConfigModel, req Request, stream bool) (map[string
|
|
|
94
94
|
}
|
|
95
95
|
if req.NoThinking && !LLMModelDisablesThinking(m) {
|
|
96
96
|
// 没有同线 no-think 变体时,用 llm/OpenAI 兼容端点认的官方参数。
|
|
97
|
-
//
|
|
98
|
-
//
|
|
97
|
+
// 个别模型不接受 `none`(如 MiniMax-M3.1-Flash 要求 adaptive thinking,
|
|
98
|
+
// 端点回 400 / 2013)—— 那不是「不能少思考」,只是这个档位不被允许:
|
|
99
|
+
// 由 doConfiguredRequestThinkingAware 换成它允许的最低档重试一次。
|
|
99
100
|
body["reasoning_effort"] = "none"
|
|
100
101
|
}
|
|
101
102
|
return body, nil
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
// noThinkingFallbackEffort 是「模型不接受完全关闭思考」时退而使用的最低档。
|
|
106
|
+
//
|
|
107
|
+
// 2026-10-24 用户纠正过一处误判:「invalid params ... requires adaptive thinking;
|
|
108
|
+
// reasoning effort none is not allowed」**不代表模型不能少思考**,只是 `none` 这个
|
|
109
|
+
// 档位不被允许。实测 MiniMax-M3.1-Flash:`none` → 400;`minimal` → 200;
|
|
110
|
+
// `thinking.type=adaptive` → 200;`thinking.type=disabled` → 400。所以正确的做法是
|
|
111
|
+
// 换档重试,而不是把用户选的模型换掉、也不是让整条链路失败。
|
|
112
|
+
const noThinkingFallbackEffort = "minimal"
|
|
113
|
+
|
|
114
|
+
// rejectsNoThinking 判断错误是不是「这个模型不接受关闭思考的写法」。
|
|
115
|
+
// 认两条签名:`requires adaptive thinking` 与 `reasoning effort ... not allowed`;
|
|
116
|
+
// 其它 4xx(鉴权、模型不存在、额度…)一律原样返回,不瞎重试。
|
|
117
|
+
func rejectsNoThinking(err error) bool {
|
|
118
|
+
if err == nil {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
msg := strings.ToLower(err.Error())
|
|
122
|
+
if strings.Contains(msg, "requires adaptive thinking") {
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
return strings.Contains(msg, "reasoning effort") && strings.Contains(msg, "not allowed")
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// doConfiguredRequestThinkingAware 发一次请求;若模型拒绝 `reasoning_effort=none`,
|
|
129
|
+
// 换成它允许的最低档(minimal)重试一次。
|
|
130
|
+
//
|
|
131
|
+
// 只在「我们主动塞了 none」时才换档:配置里显式声明的不思考变体
|
|
132
|
+
// (extraBody.thinking.type=disabled)走的是另一条路,端点拒绝它是配置错误,
|
|
133
|
+
// 换档只会把真正的原因盖住。
|
|
134
|
+
func doConfiguredRequestThinkingAware(ctx context.Context, m LLMConfigModel, req Request, body map[string]any) (*http.Response, error) {
|
|
135
|
+
resp, err := doConfiguredRequest(ctx, m, req, body)
|
|
136
|
+
if err == nil || !rejectsNoThinking(err) {
|
|
137
|
+
return resp, err
|
|
138
|
+
}
|
|
139
|
+
if !req.NoThinking || LLMModelDisablesThinking(m) {
|
|
140
|
+
return nil, err
|
|
141
|
+
}
|
|
142
|
+
body["reasoning_effort"] = noThinkingFallbackEffort
|
|
143
|
+
return doConfiguredRequest(ctx, m, req, body)
|
|
144
|
+
}
|
|
145
|
+
|
|
104
146
|
// attachToLastUserMessage 把附件塞进最后一条 user 消息的 content:
|
|
105
147
|
//
|
|
106
148
|
// 图片 → {"type":"image_url","image_url":{"url":"data:<mime>;base64,<...>"}}(在前)
|
|
@@ -207,7 +249,7 @@ func (e *LLMEngine) completeConfigured(ctx context.Context, m LLMConfigModel, re
|
|
|
207
249
|
}
|
|
208
250
|
cctx, cancel := context.WithTimeout(ctx, llmConfiguredTimeout(m, req))
|
|
209
251
|
defer cancel()
|
|
210
|
-
resp, err :=
|
|
252
|
+
resp, err := doConfiguredRequestThinkingAware(cctx, m, req, body)
|
|
211
253
|
if err != nil {
|
|
212
254
|
return Response{}, err
|
|
213
255
|
}
|
|
@@ -309,7 +351,7 @@ func (e *LLMEngine) streamConfigured(ctx context.Context, m LLMConfigModel, req
|
|
|
309
351
|
}
|
|
310
352
|
cctx, cancel := context.WithTimeout(ctx, llmConfiguredTimeout(m, req))
|
|
311
353
|
defer cancel()
|
|
312
|
-
resp, err :=
|
|
354
|
+
resp, err := doConfiguredRequestThinkingAware(cctx, m, req, body)
|
|
313
355
|
if err != nil {
|
|
314
356
|
return StreamResult{}, err
|
|
315
357
|
}
|
|
@@ -20,6 +20,7 @@ import (
|
|
|
20
20
|
"os"
|
|
21
21
|
"path/filepath"
|
|
22
22
|
"strings"
|
|
23
|
+
"sync"
|
|
23
24
|
"testing"
|
|
24
25
|
"time"
|
|
25
26
|
)
|
|
@@ -301,6 +302,162 @@ func TestBuildConfiguredBodyNoThinking(t *testing.T) {
|
|
|
301
302
|
}
|
|
302
303
|
}
|
|
303
304
|
|
|
305
|
+
// 2026-10-24 用户纠正的判定:「invalid params ... requires adaptive thinking;
|
|
306
|
+
// reasoning effort none is not allowed」**不代表模型不能少思考**,只是 `none` 这个档位
|
|
307
|
+
// 不被允许(实测 MiniMax-M3.1-Flash:none → 400,minimal / thinking.type=adaptive → 200)。
|
|
308
|
+
// 所以应当**换档重试**,而不是把用户选的模型换掉、也不是让整条链路失败。
|
|
309
|
+
func TestConfiguredNoThinkingFallsBackToAllowedEffort(t *testing.T) {
|
|
310
|
+
var mu sync.Mutex
|
|
311
|
+
var bodies []map[string]any
|
|
312
|
+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
313
|
+
raw, _ := io.ReadAll(r.Body)
|
|
314
|
+
var b map[string]any
|
|
315
|
+
_ = json.Unmarshal(raw, &b)
|
|
316
|
+
mu.Lock()
|
|
317
|
+
bodies = append(bodies, b)
|
|
318
|
+
mu.Unlock()
|
|
319
|
+
if b["reasoning_effort"] == "none" {
|
|
320
|
+
w.WriteHeader(http.StatusBadRequest)
|
|
321
|
+
fmt.Fprint(w, `{"type":"error","error":{"type":"bad_request_error",`+
|
|
322
|
+
`"message":"invalid params, model \"MiniMax-M3.1-Flash\" requires adaptive thinking; `+
|
|
323
|
+
`reasoning effort none is not allowed (2013)","http_code":"400"}}`)
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
fmt.Fprint(w, `{"choices":[{"message":{"content":"处理过期 token","reasoning_content":"内部思路"}}]}`)
|
|
327
|
+
}))
|
|
328
|
+
defer srv.Close()
|
|
329
|
+
writeModelsJSON(t, []map[string]any{
|
|
330
|
+
{"id": "MiniMax-M3.1-Flash", "url": srv.URL + "/v1", "apiKey": "k"},
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
resp, err := (&LLMEngine{BinPath: "/nonexistent/llm"}).Complete(context.Background(), Request{
|
|
334
|
+
Engine: "llm", Model: "MiniMax-M3.1-Flash", NoThinking: true,
|
|
335
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
336
|
+
})
|
|
337
|
+
if err != nil {
|
|
338
|
+
t.Fatalf("拒绝 none 时应换档重试而不是报错:%v", err)
|
|
339
|
+
}
|
|
340
|
+
if resp.Text != "处理过期 token" {
|
|
341
|
+
t.Errorf("正文应正常返回,得 %q", resp.Text)
|
|
342
|
+
}
|
|
343
|
+
mu.Lock()
|
|
344
|
+
defer mu.Unlock()
|
|
345
|
+
if len(bodies) != 2 {
|
|
346
|
+
t.Fatalf("应请求两次(none 失败 → minimal 重试),得 %d 次", len(bodies))
|
|
347
|
+
}
|
|
348
|
+
if bodies[0]["reasoning_effort"] != "none" {
|
|
349
|
+
t.Errorf("第一次应仍按原口径传 none,得 %v", bodies[0]["reasoning_effort"])
|
|
350
|
+
}
|
|
351
|
+
if bodies[1]["reasoning_effort"] != noThinkingFallbackEffort {
|
|
352
|
+
t.Errorf("重试应换成 %q,得 %v", noThinkingFallbackEffort, bodies[1]["reasoning_effort"])
|
|
353
|
+
}
|
|
354
|
+
// 关键:换的是档位,不是模型 —— 用户选的就是这一条配置。
|
|
355
|
+
if bodies[1]["model"] != "MiniMax-M3.1-Flash" {
|
|
356
|
+
t.Errorf("不该换模型,得 %v", bodies[1]["model"])
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 与「关闭思考」无关的 4xx(鉴权 / 模型不存在…)必须原样报错,不许瞎重试。
|
|
361
|
+
func TestConfiguredBadRequestUnrelatedNotRetried(t *testing.T) {
|
|
362
|
+
var mu sync.Mutex
|
|
363
|
+
calls := 0
|
|
364
|
+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
365
|
+
mu.Lock()
|
|
366
|
+
calls++
|
|
367
|
+
mu.Unlock()
|
|
368
|
+
w.WriteHeader(http.StatusBadRequest)
|
|
369
|
+
fmt.Fprint(w, `{"error":{"message":"model not found"}}`)
|
|
370
|
+
}))
|
|
371
|
+
defer srv.Close()
|
|
372
|
+
writeModelsJSON(t, []map[string]any{{"id": "m1", "url": srv.URL + "/v1", "apiKey": "k"}})
|
|
373
|
+
|
|
374
|
+
_, err := (&LLMEngine{BinPath: "/nonexistent/llm"}).Complete(context.Background(), Request{
|
|
375
|
+
Engine: "llm", Model: "m1", NoThinking: true,
|
|
376
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
377
|
+
})
|
|
378
|
+
if err == nil || !strings.Contains(err.Error(), "HTTP 400") {
|
|
379
|
+
t.Fatalf("无关的 400 应原样报错,得 %v", err)
|
|
380
|
+
}
|
|
381
|
+
mu.Lock()
|
|
382
|
+
defer mu.Unlock()
|
|
383
|
+
if calls != 1 {
|
|
384
|
+
t.Errorf("无关错误不该重试,实际请求 %d 次", calls)
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 配置里显式声明的不思考变体(extraBody.thinking.type=disabled)被端点拒绝时,
|
|
389
|
+
// 那是**配置错误** —— 不许把它悄悄改成 minimal 把真实原因盖住。
|
|
390
|
+
func TestConfiguredDisabledVariantErrorNotRewritten(t *testing.T) {
|
|
391
|
+
var mu sync.Mutex
|
|
392
|
+
calls := 0
|
|
393
|
+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
394
|
+
mu.Lock()
|
|
395
|
+
calls++
|
|
396
|
+
mu.Unlock()
|
|
397
|
+
w.WriteHeader(http.StatusBadRequest)
|
|
398
|
+
fmt.Fprint(w, `{"error":{"message":"model \"x\" requires adaptive thinking; `+
|
|
399
|
+
`thinking.type=\"disabled\" (including reasoning.effort=none) is not allowed (2013)"}}`)
|
|
400
|
+
}))
|
|
401
|
+
defer srv.Close()
|
|
402
|
+
writeModelsJSON(t, []map[string]any{
|
|
403
|
+
{"id": "x-nothink", "model": "x", "url": srv.URL + "/v1", "apiKey": "k",
|
|
404
|
+
"extraBody": map[string]any{"thinking": map[string]any{"type": "disabled"}}},
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
_, err := (&LLMEngine{BinPath: "/nonexistent/llm"}).Complete(context.Background(), Request{
|
|
408
|
+
Engine: "llm", Model: "x-nothink", NoThinking: true,
|
|
409
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
410
|
+
})
|
|
411
|
+
if err == nil || !strings.Contains(err.Error(), "is not allowed") {
|
|
412
|
+
t.Fatalf("配置错误应如实报出,得 %v", err)
|
|
413
|
+
}
|
|
414
|
+
mu.Lock()
|
|
415
|
+
defer mu.Unlock()
|
|
416
|
+
if calls != 1 {
|
|
417
|
+
t.Errorf("配置声明的不思考变体不该被改写重试,实际请求 %d 次", calls)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// 流式路径同样要换档重试(两条路径共用 doConfiguredRequestThinkingAware)。
|
|
422
|
+
func TestStreamConfiguredNoThinkingFallsBack(t *testing.T) {
|
|
423
|
+
var mu sync.Mutex
|
|
424
|
+
var bodies []map[string]any
|
|
425
|
+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
426
|
+
raw, _ := io.ReadAll(r.Body)
|
|
427
|
+
var b map[string]any
|
|
428
|
+
_ = json.Unmarshal(raw, &b)
|
|
429
|
+
mu.Lock()
|
|
430
|
+
bodies = append(bodies, b)
|
|
431
|
+
mu.Unlock()
|
|
432
|
+
if b["reasoning_effort"] == "none" {
|
|
433
|
+
w.WriteHeader(http.StatusBadRequest)
|
|
434
|
+
fmt.Fprint(w, `{"error":{"message":"requires adaptive thinking; reasoning effort none is not allowed (2013)"}}`)
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
w.Header().Set("Content-Type", "text/event-stream")
|
|
438
|
+
fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"content\":\"好\"}}]}\n\n")
|
|
439
|
+
fmt.Fprint(w, "data: [DONE]\n\n")
|
|
440
|
+
}))
|
|
441
|
+
defer srv.Close()
|
|
442
|
+
writeModelsJSON(t, []map[string]any{{"id": "MiniMax-M3.1-Flash", "url": srv.URL + "/v1", "apiKey": "k"}})
|
|
443
|
+
|
|
444
|
+
res, err := (&LLMEngine{BinPath: "/nonexistent/llm"}).Stream(context.Background(), Request{
|
|
445
|
+
Engine: "llm", Model: "MiniMax-M3.1-Flash", NoThinking: true,
|
|
446
|
+
Messages: []Message{{Role: "user", Content: "hi"}},
|
|
447
|
+
}, nil)
|
|
448
|
+
if err != nil {
|
|
449
|
+
t.Fatalf("流式也应在换档后成功:%v", err)
|
|
450
|
+
}
|
|
451
|
+
if res.Response.Text != "好" {
|
|
452
|
+
t.Errorf("正文应为「好」,得 %q", res.Response.Text)
|
|
453
|
+
}
|
|
454
|
+
mu.Lock()
|
|
455
|
+
defer mu.Unlock()
|
|
456
|
+
if len(bodies) != 2 || bodies[1]["reasoning_effort"] != noThinkingFallbackEffort {
|
|
457
|
+
t.Errorf("流式路径也应换档重试,实际 %d 次、第二次档位 %v", len(bodies), bodies[1]["reasoning_effort"])
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
304
461
|
func TestExpandEnvKeep(t *testing.T) {
|
|
305
462
|
t.Setenv("LLM_TEST_KEY", "secret")
|
|
306
463
|
cases := map[string]string{
|